65.9K
CodeProject 正在变化。 阅读更多。
Home

doctest - 最轻量级的 C++ 单元测试框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.78/5 (12投票s)

2016年11月21日

CPOL

7分钟阅读

viewsIcon

27628

测试框架简介,其独特性以及功能亮点

引言

doctest 是一个完全开源、轻量级且功能丰富的 C++98 / C++11 单头文件测试框架,用于单元测试和 TDD。

它受到了 D 编程语言的 `unittest {}` 功能和 Python 的 `docstrings` 的启发——测试可以被视为一种文档形式,并应能驻留在它们所测试的生产代码附近。对于任何其他的 C++ 测试框架来说,这是不可能(至少是不切实际)的。

一个完整的、编译为可执行文件的自注册测试示例看起来是这样的

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

int fact(int n) { return n <= 1 ? n : fact(n - 1) * n; }

TEST_CASE("testing the factorial function") {
    CHECK(fact(0) == 1); // will fail
    CHECK(fact(1) == 1);
    CHECK(fact(2) == 2);
    CHECK(fact(10) == 3628800);
}

下面是该程序的输出

[doctest] doctest version is "1.1.3"
[doctest] run with "--help" for options
========================================================
main.cpp(6)
testing the factorial function

main.cpp(7) FAILED!
  CHECK( fact(0) == 1 )
with expansion:
  CHECK( 0 == 1 )

========================================================
[doctest] test cases:    1 |    0 passed |    1 failed |
[doctest] assertions:    4 |    3 passed |    1 failed |

请注意,这里使用了标准的 C++ 等值比较运算符——doctest 有一个核心的断言宏(也有小于、等于、大于等的宏)——但整个表达式被分解,左右两侧的值都会被记录下来。这是通过表达式模板和 C++ 的技巧实现的。而且,测试用例是自动注册的——您无需手动将其添加到列表中。

Doctest 的模型借鉴了 Catch,Catch 是目前 C++ 中最流行的测试替代方案——请在 FAQ 中查看区别。目前,Catch 中有一些功能 doctest 尚未实现,但 doctest 最终将成为 Catch 的超集。

框架背后的动机 - 它有何不同

市面上有很多 C++ 测试框架—— CatchBoost.TestUnitTest++cpputestgoogletest 以及 许多其他

doctest 与众不同之处在于它对编译时间的影响极小(数量级上)且不干扰其他代码。

它与其它框架的主要区别在于

  • 极轻量级 - 包含头文件在源文件中产生的编译时间开销低于 10 毫秒
  • 最快的断言宏 - 50,000 个断言可以在 30 秒内编译完成(甚至不到 10 秒)
  • 子用例 - 一种共享测试用例通用设置和拆卸代码的直观方式(替代 fixture)
  • 提供一种方式,使用 DOCTEST_CONFIG_DISABLE 标识符,从二进制文件中移除所有与测试相关的内容
  • 不污染全局命名空间(所有内容都在 doctest 命名空间下)且不引入任何额外的头文件
  • 即使在 MSVC / GCC / Clang 的最高警告级别下也不会产生任何警告
    • -Weverything for Clang
    • /W4 for MSVC
    • -Wall -Wextra -pedantic 和超过 50 个其他标志!
  • 高度可移植且经过充分测试的 C++98 - 每个提交都会在 CI 上使用不同的编译器和配置(gcc 4.4-6.1 / clang 3.4-3.9 / MSVC 2008-2015,debug / release,x86/x64,linux / windows / osx,valgrind,sanitizers...)进行超过 220 种不同的构建测试
  • 只有一个头文件,除了 C / C++ 标准库(仅在测试运行器中使用)外,没有外部依赖

该框架提供的独特能力

前面列出的所有优点都允许该框架以比任何其他框架都多的方式使用——测试可以直接写在生产代码中!

  • 这大大降低了编写测试的门槛——您不必
    1. 创建一个单独的源文件
    2. 在其中包含一堆东西
    3. 将其添加到构建系统中,并
    4. 将其添加到源代码控制
      您可以直接在类或功能的源文件底部——甚至头文件——编写测试!
  • 生产代码中的测试可以被视为文档或最新的注释——展示 API 的用法(正确性由编译器强制执行)。
  • 测试未通过公共 API 和头文件公开的内部代码变得更加容易。
  • C++ 中的测试驱动开发从未如此简单!

即使您不认同将测试写在生产代码中的想法,该框架仍然可以像其他框架一样使用——但这才是框架最大的优势——是其他任何框架都无法提供的!

还有许多 其他特性,并且在 路线图 中还有更多计划。

main() 入口点

正如我们在上面的示例中看到的——程序的 `main()` 入口点可以由框架提供。然而,如果您在生产代码中编写测试,您可能已经有一个 `main()` 函数了。下面的代码示例展示了如何从用户 `main()` 中使用 doctest

#define DOCTEST_CONFIG_IMPLEMENT
#include "doctest.h"
int main(int argc, char** argv) {
    doctest::Context ctx;
    ctx.setOption("abort-after", 5);  // default - stop after 5 failed asserts
    ctx.applyCommandLine(argc, argv); // apply command line - argc / argv
    ctx.setOption("no-breaks", true); // override - don't break in the debugger
    int res = ctx.run();              // run test cases unless with --no-run
    if(ctx.shouldExit())              // query flags (and --exit) rely on this
        return res;                   // propagate the result of the tests
    // your code goes here
    return res; // + your_program_res
}

通过这种设置,可以实现以下三种场景

  • 仅运行测试(使用 `--exit` 选项)
  • 仅运行用户代码(使用 `--no-run` 选项)
  • 同时运行测试和用户代码

如果您打算直接在生产代码中编写测试,那么这是必须能够实现的。

此外,该示例还展示了如何为命令行选项设置默认值和覆盖值。

请注意,`DOCTEST_CONFIG_IMPLEMENT` 或 `DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN` 标识符应该在包含框架头文件之前定义——但只能在一个源文件中——测试运行器将在此文件中实现。在其他所有地方,只需包含头文件并编写一些测试。这是单头文件库的常见做法,这些库需要其中一部分在单个源文件中编译(在这种情况下是测试运行器)。

从二进制文件中移除所有与测试相关的内容

您可能希望在构建将交付给客户的发布版本时,从生产代码中移除测试。使用 doctest 实现这一点的方法是在整个项目中定义 `DOCTEST_CONFIG_DISABLE` 预处理器标识符。

该标识符对 `TEST_CASE` 宏等的作用是,它被转换为一个永远不会被实例化的匿名模板

#define TEST_CASE(name)                       \
    template <typename T>                     \
    static inline void ANONYMOUS(ANON_FUNC_)()

这意味着所有测试用例都被从最终的二进制文件中删除了——即使在 Debug 模式下也是如此!链接器永远看不到匿名测试用例函数,因为它们从未被实例化。

`ANONYMOUS()` 宏用于在每次调用时获取唯一的标识符——它使用 __COUNTER__ 预处理器宏,该宏在每次使用时返回比上一次大 1 的整数。例如

int ANONYMOUS(ANON_VAR_); // int ANON_VAR_5;
int ANONYMOUS(ANON_VAR_); // int ANON_VAR_6;

子用例 - 共享测试用例之间设置/拆卸代码的最简单方法

假设您想在几个测试用例中打开一个文件并从中读取。如果您不想复制/粘贴相同的设置代码多次,您可以使用 doctest 的子用例机制。

TEST_CASE("testing file stuff") {
    printf("opening the file\n");
    FILE* fp = fopen("path/to/file", "r");
    
    SUBCASE("seeking in file") {
        printf("seeking\n");
        // fseek()
    }
    SUBCASE("reading from file") {
        printf("reading\n");
        // fread()
    }
    printf("closing...\n");
    fclose(fp);
}

将打印以下文本

opening the file
seeking
closing...
opening the file
reading
closing...

如您所见,测试用例被进入了两次——每次都进入了一个不同的子用例。子用例也可以无限嵌套。执行模型类似于 DFS 遍历——每次从测试用例的开始开始,遍历“树”直到到达叶节点(一个尚未遍历过的节点)——然后通过弹出进入的嵌套子用例堆栈来退出测试用例。

编译时间基准测试

doctest 主要有三种相关的编译时间基准测试

  • 包含头文件的成本
  • 断言宏的成本
  • 使用 DOCTEST_CONFIG_DISABLE 标识符移除所有测试时,构建时间下降了多少

总结

  • 包含 doctest 头文件的成本约为 10 毫秒,而 `Catch` 的成本为 430 毫秒——因此 doctest 轻 25-50 倍
  • 50,000 个断言大约在 60 秒内编译完成,比 `Catch` 快约 25%
  • 如果使用替代断言宏(适用于高级用户),50,000 个断言可以在低至 10 秒内编译完成
  • 使用 DOCTEST_CONFIG_DISABLE 禁用后,分布在 500 个测试用例中的 50,000 个断言几乎消失了——不到 2 秒!

基准测试页面 上,您可以查看设置和更多基准测试的详细信息。

结论

doctest 框架非常易于上手,并且完全透明且不干扰其他代码——包含它并编写测试将是无感的,无论是编译时间还是集成(警告、构建系统等)。使用它将尽可能地加快您的开发流程——没有其他框架如此易于使用!

历史

  • 2016 年 11 月 21 日:初始版本
© . All rights reserved.