29 个版本

0.5.1 2024 年 6 月 10 日
0.4.9 2024 年 5 月 7 日
0.4.6 2024 年 3 月 19 日
0.4.1 2023 年 12 月 11 日
0.1.0-beta.72023 年 3 月 3 日

#652测试

Download history 111/week @ 2024-04-25 141/week @ 2024-05-02 21/week @ 2024-05-09 13/week @ 2024-05-16 8/week @ 2024-05-23 2/week @ 2024-05-30 321/week @ 2024-06-06 46/week @ 2024-06-13 14/week @ 2024-06-20 6/week @ 2024-07-04 203/week @ 2024-07-25 32/week @ 2024-08-01

235 每月下载量

AGPL-3.0

320KB
7K SLoC

Necessist

通过移除语句和方法调用运行测试,以帮助识别损坏的测试

Necessist 目前支持 Anchor (TS),Foundry,Go,Hardhat (TS) 和 Rust。

Necessist 的论文(Test Harness Mutilation)将出现在 Mutation 2024。(幻灯片预印本

内容

安装

系统要求

在您的系统上安装 pkg-configsqlite3 开发文件,例如,在 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最弱前件PS的后置条件Q具有相同的上下文(例如,作用域内的变量),并且P不意味着Q

*)试图捕捉的概念是:影响后续断言的语句。在本节中,我们将解释并说明这一选择。为了简洁,我们关注语句,但本节中的评论也适用于方法调用。

回想一下两种类型的谓词转换语义:最弱前件和最强后件。在前者中,人们根据语句之后的后置条件进行推理,给定语句之前可能成立的最弱前件。在后者中,人们根据语句之前可能成立的前置条件进行推理,给定语句之后的 strongest 后置条件。一般来说,前者更常见(参见Aldrich 2013中的解释),我们在这里使用的是前者。

从这一角度考虑一个测试。测试是一个没有输入或输出的函数。因此,确定测试是否通过的一个替代方法是以下方法。从True开始,迭代地通过测试的语句向后工作,计算每个语句的最弱前件。如果测试的第一个语句得到的前置条件是True,则测试通过。如果前置条件是False,则测试失败。

现在,假设我们应用这个程序,并考虑一个违反(*)的语句S。我们争辩说,可能没有删除S的必要

案例1S 向或从作用域中添加或删除变量(例如,S 是一个声明),或者 S 改变了变量的类型。那么移除 S 很可能会导致编译失败。(除此之外,由于 S 的前置条件和后置条件具有不同的上下文,因此不清楚如何比较它们。)

案例2S 的前置条件比后置条件更强(例如,S 是一个断言)。那么 S 对其执行的环境施加了约束。换句话说,S 测试 了一些东西。因此,移除 S 很可能削弱测试的总体目的。

相反,考虑一个满足(*)的语句 S。以下是移除 S 可能是有意义的几个原因。将 S 视为是 移动 有效环境集,而不是对其施加约束。更确切地说,如果 S 的最弱前置条件 P 不蕴含 Q,并且如果 Q 是可满足的,那么就存在一个分配给 PQ 的自由变量的赋值,可以同时满足 PQ。如果这种赋值来自于 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] = QQ ^ 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绑定)
  • breakcontinuereturn
  • 测试的最后一条语句

同样,如果

  • 它是包含语句的主要效果(例如,x.foo();)。
  • 它出现在忽略的函数、方法或宏的参数列表中(见下文)。

此外,对于某些框架,某些语句和方法被忽略。点击框架以查看其具体信息。

锚点TS

忽略的函数

  • assert
  • assert. 开头的任何内容(例如,assert.equal.
  • console. 开头的任何内容(例如,console.log.
  • expect

忽略的方法

  • toNumber
  • toString
Foundry

除了以下内容外,Foundry 框架还会忽略

  • 使用 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

忽略的函数和方法与上面的 Anchor 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 的观察特征和固有方法,以下为添加项

配置文件

配置文件允许您根据项目定制Necessist的行为。文件必须命名为necessist.toml,位于项目根目录中,并且使用toml编码。该文件可以包含以下列出的一个或多个选项。

  • ignored_functionsignored_methodsignored_macros:字符串列表,被解释为模式。如果函数、方法或宏(分别)的路径与列表中的模式匹配,则被忽略。请注意,ignored_macros目前仅由Rust框架使用。

  • ignored_path_disambiguation:字符串EitherFunctionMethod之一。对于可能指代函数或方法(见下文)路径,此选项影响是否忽略该函数或方法。

    • Either(默认值):如果路径与ignored_functionsignored_methods模式匹配,则忽略。

    • Function:只有当路径与ignored_functions模式匹配时才忽略。

    • Method:只有当路径与ignored_methods模式匹配时才忽略。

  • ignored_tests:字符串列表。名称与列表中的字符串完全匹配的测试被忽略。对于基于Mocha的框架(例如,Anchor和Hardhat),测试名称被视为传递给it的消息。

模式

模式是由字母、数字、._* 组成的字符串。除了 * 以外,每个字符都被当作普通字符处理,只匹配自身。一个 * 可以匹配任何字符串,包括空字符串。

以下是一些模式的示例

  • assert:只匹配自身
  • assert_eq:只匹配自身
  • assertEqual:只匹配自身
  • assert.Equal:只匹配自身
  • assert.*:匹配 assert.Equal,但不匹配 assertassert_eqassertEqual
  • assert*:匹配 assertassert_eqassertEqualassert.Equal
  • *.Equal:匹配 assert.Equal,但不匹配 Equal

注意

  • 模式匹配 路径,而不是单个标识符。
  • . 被当作普通字符处理,就像在 glob 模式中的处理方式,而不是在正则表达式中的处理方式。

路径

路径是由 . 分隔的标识符序列。考虑以下示例(来自 Chainlink

operator.connect(roles.oracleNode).signer.sendTransaction({
    to: operator.address,
    data,
}),

在上面的例子中,operator.connectsigner.sendTransaction 是路径。

但是请注意,像 operator.connect 这样的路径是有歧义的

  • 如果 operator 指的是包或模块,那么 operator.connect 指的是函数。
  • 如果 operator 指的是对象,那么 operator.connect 指的是方法。

默认情况下,Necessist 忽略这样的路径,如果它匹配 ignored_functionsignored_methods 模式。将上面的 ignored_path_disambiguation 选项设置为 FunctionMethod 会导致 Necessist 只在路径匹配 ignored_functionsignored_methods 模式时忽略路径(分别对应)。

限制

  • 慢。 修改测试需要重新构建它们。在即使是中等规模的项目上运行 Necessist 也可能需要几个小时。

  • 三分类需要深入了解源代码。 一般来说,Necessist 不会产生“明显”的 bug。根据我们的经验,决定一个语句或方法调用是否应该必需需要深入了解被测试的代码。Necessist 最好在具有(或打算具有)这种知识的代码库上运行。

语义版本策略

我们保留更改以下内容的权利,并将此类更改视为非破坏性的

  • Necessist 默认忽略的语法

以下内容的更改将伴随至少 Necessist 的次要版本号的升级

  • 移除候选者的输出顺序
  • 记录在 necessist.db 中的存储顺序

目标

  • 如果项目使用支持的框架,那么进入项目的目录并输入 necessist(不带任何参数)应该会生成有意义的输出。

反目标

  • 成为一个通用的突变测试工具。已经存在这样的优秀工具(例如,universalmutator)。

参考

  • Groce, A.,Ahmed, I.,Jensen, C.,McKenney, P.E.,Holmes, J.:我的代码是如何被验证(或测试)的?基于错误驱动的验证和测试。自动软件工程 25,917–960(2018)。可以找到预印本。参见第2.3节。

许可

Necessist采用AGPLv3许可证进行授权和分发。如果您需要许可证条款的例外,请联系我们

依赖项

~60MB
~1M SLoC