5个版本
新增 0.6.4 | 2024年8月20日 |
---|---|
0.6.3 | 2024年7月23日 |
0.6.2 | 2024年7月4日 |
0.6.1 | 2024年6月28日 |
0.6.0 | 2024年6月23日 |
#145 在 过程宏
168 每月下载量
在 necessist 中使用
460KB
7.5K SLoC
Necessist
通过删除语句和方法调用运行测试,以帮助识别损坏的测试
Necessist 目前支持 Anchor (TS)、Foundry、Go、Hardhat (TS) 和 Rust。
关于 Necessist 的论文(《Test Harness Mutilation》)将在 Mutation 2024 会议发表。(论文,幻灯片,预印本)
内容
安装
系统要求
在您的系统上安装 pkg-config
和 sqlite3
开发文件,例如在 Ubuntu 上
sudo apt install pkg-config libsqlite3-dev
从 crates.io 安装 Necessist
cargo install necessist
从 github.com 安装 Necessist
cargo install --git https://github.com/trailofbits/necessist --branch release
概述
Necessist 逐步从测试中删除语句和方法调用,然后运行它们。如果一个测试在删除语句或方法调用后仍然通过,这可能表明测试存在问题。或者更糟糕的是,这表明被测试的代码存在问题。
示例
此示例来自 rust-openssl
。测试 verify_untrusted_callback_override_ok
检查失败的证书验证是否可以被回调覆盖。但是,如果回调从未被调用(例如,由于连接失败),测试仍然会通过。Necessist 通过显示测试在删除对 set_verify_callback
的调用后仍然通过,揭示了这一事实。
#[test]
fn verify_untrusted_callback_override_ok() {
let server = Server::builder().build();
let mut client = server.client();
client
.ctx()
.set_verify_callback(SslVerifyMode::PEER, |_, x509| { //
assert!(x509.current_cert().is_some()); // Test passes without this call
true // to `set_verify_callback`.
}); //
client.connect();
}
在发现之后,为了记录回调是否被调用,测试中增加了一个标志。这个标志必须设置,测试才能成功。
#[test]
fn verify_untrusted_callback_override_ok() {
static CALLED_BACK: AtomicBool = AtomicBool::new(false); // Added
let server = Server::builder().build();
let mut client = server.client();
client
.ctx()
.set_verify_callback(SslVerifyMode::PEER, |_, x509| {
CALLED_BACK.store(true, Ordering::SeqCst); // Added
assert!(x509.current_cert().is_some());
true
});
client.connect();
assert!(CALLED_BACK.load(Ordering::SeqCst)); // Added
}
与传统的突变测试比较
点击展开
传统的突变测试试图识别测试覆盖率中的差距,而Necessist试图识别现有测试中的错误。
传统的突变测试工具(例如universalmutator
)随机将故障注入到源代码中,并检查代码的测试是否仍然通过。如果它们仍然通过,这可能意味着代码的测试不足。
值得注意的是,传统的突变测试是关于找到整个测试集合中的缺陷,而不是单个测试中的缺陷。也就是说,对于任何给定的测试,随机注入故障不太可能揭示该测试中的错误。这是不幸的,因为有些测试比其他测试更重要,例如,确保代码某些部分的正确性比其他部分更重要。
相比之下,Necessist通过迭代删除语句和方法调用的方法,确实针对单个测试,因此可以揭示单个测试中的错误。
当然,两种方法可以揭示的问题集合有重叠,例如,未能找到注入的故障可能表明测试中存在错误。尽管如此,基于上述原因,我们认为这两种方法互补,而不是竞争。
可能的理论基础
点击展开
以下标准(*
)几乎描述了Necessist旨在删除的语句
- (
*
)语句S
的最弱前件P
与S
的后置条件Q
具有相同的上下文(例如,作用域内的变量),并且P
不蕴涵Q
。
(*
)试图捕捉的概念是:影响后续断言的语句。在本节中,我们将解释和论证这个选择。为了简洁,我们专注于语句,但本节中的评论也适用于方法调用。
回忆两种谓词转换语义:最弱前件和最强后置条件。前者,一个人根据语句之后的后置条件,推理出在语句之前可能成立的最弱前件。后者,一个人根据语句之前的前提条件,推理出语句之后可能成立的最强后置条件。一般来说,前者更常见(参见Aldrich 2013的解释),我们在这里使用的是前者。
通过这个角度来看待一个测试。测试是一个没有输入或输出的函数。因此,确定测试是否通过的另一种方法是以下方法。从True
开始,迭代地反向通过测试的语句,计算每个语句的最弱前件。如果到达测试第一个语句的前件是True
,那么测试通过。如果前件是False
,则测试失败。
现在,假设我们应用这个程序,考虑一个违反(*
)的语句S
。我们论证,删除S
可能没有意义。
情况 1:代码 S
添加或删除变量到作用域中(例如,S
是一个声明),或者 S
改变了变量的类型。那么移除 S
很可能导致编译失败。(此外,由于 S
的前条件和后条件有不同的上下文,因此不清楚如何比较它们。)
情况 2:S
的前条件比后条件更强(例如,S
是一个断言)。那么 S
对其执行环境的约束。换句话说,S
测试 了一些内容。因此,移除 S
很可能削弱测试的整体目的。
相反,考虑一个满足(*
)的语句 S
。以下是为什么可能需要移除 S
的原因。将 S
视为 移动 有效环境集,而不是约束它们。更准确地说,如果 S
的最弱前条件 P
不蕴含 Q
,并且如果 Q
是可满足的,那么存在一个赋值给 P
和 Q
的自由变量的赋值,该赋值满足 P
和 Q
。如果这种赋值来自于 S
实际执行的每个环境,那么 S
的必要性就值得怀疑。
(*
)的主要用途在于帮助选择 Necessist 忽略的函数、宏和方法调用。Necessist 默认忽略其中的一些。假设我们正在考虑某个框架,是否应该忽略一些函数 foo
。如果我们想象该框架测试语言的谓词转换语义,我们可以问:如果语句 S
是对 foo
的调用,那么 S
会满足(*
)吗?如果答案是“否”,那么 Necessist 很可能忽略 foo
。
以 Rust 的 clone
方法为例。对 clone
的调用可能是多余的。然而,如果我们想象 Rust 的谓词转换语义,对 clone
的调用不太可能满足(*
)。因此,Necessist 不尝试移除 clone
调用。
除了帮助选择 Necessist 忽略的函数、宏等之外,(*
)还有其他好处。例如,测试中最后一条语句应该忽略的规则来自(*
)。为了看到这一点,请注意这种语句的后条件 Q
总是 True
。因此,如果该语句没有改变上下文,那么它的最弱前条件必然蕴含 Q
。
尽管如此,(*
)并没有完全捕捉到 Necessist 实际 做 的事情。考虑像 x -= 1;
这样的语句。Necessist 将无条件地移除这样的语句,但(*
)表示 Necessist 可能不应该这样做。假设启用了 溢出检查,此语句的最弱前条件的计算可能如下所示
{ Q[(x - 1)/x] ^ x >= 1 }
x -= 1;
{ Q }
请注意,x -= 1;
不会改变上下文,并且 Q[(x - 1)/x] ^ x >= 1
可能意味着 Q
。例如,如果 Q
不包含 x
,则 Q[(x - 1)/x] = Q
和 Q ^ x >= 1
意味着 Q
。
鉴于 (*
) 和 Necessist 当前行为之间的差异,人们可能会问:哪一种应该进行调整?换句话说,Necessist 是否应该无条件地删除像 x -= 1;
这样的语句?
看待这个问题的另一种方法是:哪些语句值得删除,即哪些语句是“有趣的”?如上所述,(*
) 将一个语句视为“有趣的”,如果其删除可能会影响后续断言。但还有其他可能的、有用的“有趣”语句的定义。例如,可以考虑最强烈的后置条件(如上所述),或者除了 Hoare 逻辑之外的其他框架。
为了明确,Necessist 不正式应用 (*
),例如,Necessist 并不实际计算最弱前件。目前 (*
) 的角色是帮助指导 Necessist 应忽略哪些语句,并且 (*
) 在这个角色中似乎做得很好。因此,我们将解决上述差异的工作留待将来进行。
使用方法
Usage: necessist [OPTIONS] [TEST_FILES]... [-- <ARGS>...]
Arguments:
[TEST_FILES]... Test files to mutilate (optional)
[ARGS]... Additional arguments to pass to each test command
Options:
--allow <WARNING> Silence <WARNING>; `--allow all` silences all warnings
--default-config Create a default necessist.toml file in the project's root directory
--deny <WARNING> Treat <WARNING> as an error; `--deny all` treats all warnings as errors
--dump Dump sqlite database contents to the console
--dump-candidates Dump removal candidates and exit (for debugging)
--framework <FRAMEWORK> Assume testing framework is <FRAMEWORK> [possible values: anchor, auto, foundry, go, hardhat, rust]
--no-dry-run Do not perform dry runs
--no-sqlite Do not output to an sqlite database
--quiet Do not output to the console
--reset Discard sqlite database contents
--resume Resume from the sqlite database
--root <ROOT> Root directory of the project under test
--timeout <TIMEOUT> Maximum number of seconds to run any test; 60 is the default, 0 means no timeout
--verbose Show test outcomes besides `passed`
-h, --help Print help
-V, --version Print version
输出
默认情况下,Necessist 仅在测试通过时输出到控制台。传递 --verbose
将导致 Necessist 输出以下所有删除结果。
结果 | 含义(删除语句/方法调用后...) |
---|---|
通过 | 构建并通过了测试。 |
超时 | 构建了测试但超时。 |
失败 | 构建了测试但失败。 |
无法构建 | 测试无法构建。 |
默认情况下,Necessist 将输出到控制台和 sqlite 数据库。对于后者,可以使用像 sqlitebrowser 这样的工具来过滤/排序结果。
详细说明
一般来说,Necessist 不会尝试删除以下语句之一
- 包含其他语句的语句(例如,一个
for
循环) - 一个声明(例如,一个局部或
let
绑定) - 一个
break
、continue
或return
- 测试中的最后一个语句
同样,如果以下情况之一成立,Necessist 也不会尝试删除方法调用
- 它是包围语句的主要效果(例如,
x.foo();
)。 - 它出现在被忽略的函数、方法或宏的参数列表中(见下文)。
此外,对于某些框架,某些语句和方法会被忽略。点击一个框架查看其具体细节。
锚 TS
忽略的函数
assert
- 以
assert.
开头的任何内容(例如,assert.equal
) - 以
console.
开头的任何内容(例如,console.log
) expect
忽略的方法
toNumber
toString
铸币厂
除了以下内容外,铸币厂框架还会忽略
- 使用
vm.prank
或任何形式的vm.expect
(例如,vm.expectRevert
)后的一个语句 emit
语句
忽略的函数
- 以
assert
开头的任何内容(例如,assertEq
) - 以
vm.expect
开头的任何内容(例如,vm.expectCall
) - 以
console.log
开头的任何内容(例如,console.log
,console.logInt
) - 以
console2.log
开头的任何内容(例如,console2.log
,console2.logInt
) vm.getLabel
vm.label
Go
除了以下内容外,Go 框架还会忽略
defer
语句
忽略的函数
- 以
assert.
开头的任何内容(例如,assert.Equal
) - 以
require.
开头的任何内容(例如,require.Equal
) panic
忽略的方法*
Close
Error
Errorf
Fail
FailNow
Fatal
Fatalf
Log
Logf
Parallel
Skip
Skipf
SkipNow
* 此列表主要基于 testing.T
的方法。然而,为了防止与其他类型的冲突,省略了一些具有常见名称的方法。
Hardhat TS
忽略的函数和方法与上述锚 TS 相同。
Rust
忽略的宏
assert
assert_eq
assert_matches
assert_ne
eprint
eprintln
panic
print
println
unimplemented
unreachable
忽略的方法*
as_bytes
as_encoded_bytes
as_mut
as_mut_os_str
as_mut_os_string
as_mut_slice
as_mut_str
as_os_str
as_path
as_ref
as_slice
as_str
borrow
borrow_mut
clone
cloned
copied
deref
deref_mut
expect
expect_err
into_boxed_bytes
into_boxed_os_str
into_boxed_path
into_boxed_slice
into_boxed_str
into_bytes
into_encoded_bytes
into_os_string
into_owned
into_path_buf
into_string
into_vec
iter
iter_mut
success
to_os_string
to_owned
to_path_buf
to_string
to_vec
unwrap
unwrap_err
* 此列表基本上是 Dylint 的 unnecessary_conversion_for_trait
lint 的监控特性和固有方法,以下为添加项
clone
(例如std::clone::Clone::clone
)cloned
(例如std::iter::Iterator::cloned
)copied
(例如std::iter::Iterator::copied
)expect
(例如std::option::Option::expect
)expect_err
(例如std::result::Result::expect_err
)into_owned
(例如std::borrow::Cow::into_owned
)success
(例如assert_cmd::assert::Assert::success
)unwrap
(例如std::option::Option::unwrap
)unwrap_err
(例如std::result::Result::unwrap_err
)
配置文件
配置文件允许用户根据项目需求调整 Necessist 的行为。该文件必须命名为 necessist.toml
,位于项目根目录,并且使用 toml 编码。该文件可以包含以下列出的一个或多个选项。
-
ignored_functions
、ignored_methods
、ignored_macros
:一个字符串列表,被解释为 模式。如果一个函数、方法或宏(分别)的 路径 与列表中的模式匹配,则会被忽略。注意,ignored_macros
仅由当前的 Rust 框架使用。 -
ignored_path_disambiguation
:字符串Either
、Function
或Method
之一。对于可能指向函数或方法的 路径(见下文),此选项影响是否忽略该函数或方法。-
Either
(默认):如果路径匹配ignored_functions
或ignored_methods
中的任何一个模式,则忽略。 -
函数
:只有当路径匹配到ignored_functions
模式时才忽略。 -
方法
:只有当路径匹配到ignored_methods
模式时才忽略。
-
-
ignored_tests
:一个字符串列表。如果测试名称与列表中的字符串完全匹配,则忽略。对于基于Mocha的框架(例如,Anchor和Hardhat),测试名称被视为传递给it
的消息。
模式
模式是一个由字母、数字、.
、_
或*
组成的字符串。除了*
之外,每个字符都被当作普通字符处理,仅匹配自身。一个*
可以匹配任何字符串,包括空字符串。
以下是一些模式的示例
assert
:仅匹配自身assert_eq
:仅匹配自身assertEqual
:仅匹配自身assert.Equal
:仅匹配自身assert.*
:匹配assert.Equal
,但不匹配assert
、assert_eq
或assertEqual
assert*
:匹配assert
、assert_eq
、assertEqual
和assert.Equal
*.Equal
:匹配assert.Equal
,但不匹配Equal
注意
路径
路径是一系列由.
分隔的标识符。考虑以下示例(来自Chainlink)
operator.connect(roles.oracleNode).signer.sendTransaction({
to: operator.address,
data,
}),
在上面的示例中,operator.connect
和signer.sendTransaction
是路径。
然而,需要注意的是,像operator.connect
这样的路径是模糊的
- 如果
operator
指的是包或模块,则operator.connect
指的是一个函数。 - 如果
operator
指的是一个对象,则operator.connect
指的是一个方法。
默认情况下,Necessist忽略这样的路径,如果它匹配到ignored_functions
或ignored_methods
模式。将上述ignored_path_disambiguation
选项设置为函数
或方法
会导致Necessist只在其匹配到ignored_functions
或ignored_methods
模式时忽略路径(分别)。
限制
-
慢。修改测试需要重新构建它们。在即使是中等规模的代码库上运行Necessist可能需要几个小时。
-
分类需要深入了解源代码。一般来说,Necessist不会产生“明显”的错误。根据我们的经验,判断一个语句/方法调用是否应该是必要的需要深入了解测试下的代码。Necessist最好在拥有(或打算拥有)这种知识的代码库上运行。
语义版本控制策略
我们保留更改以下内容的权利,并将此类更改视为非破坏性更改
- 默认情况下Necessist忽略的语法
以下内容的更改将伴随着至少Necessist的次版本号的升级
- 移除候选项输出的顺序
- 记录在necessist.db中存储的顺序
目标
- 如果项目使用支持的框架,那么在项目目录中执行
cd
并输入necessist
(无参数)应产生有意义的输出。
反目标
- 成为一个通用突变测试工具。已经有很好的此类工具存在(例如,
universalmutator
)。
参考文献
- Groce, A., Ahmed, I., Jensen, C., McKenney, P.E., Holmes, J.: 我的代码是如何经过验证(或测试)的?基于错误驱动的验证和测试。自动软件工程 25, 917–960 (2018)。预印本可在此处获得。参见第2.3节。
许可协议
Necessist在AGPLv3许可下授权和分发。如果您需要许可条款的例外情况,请联系我们[联系信息]。
依赖项
~62MB
~1M SLoC