使用GoogleTest和CTest进行单元测试

635 阅读2分钟

这篇文章通过使用GoogleTestCTest整合有意义的单元测试来完善这一设置。

如果尚未完成,请克隆仓库,在VSCodium中打开,并通过点击main-branch符号(红色标记)和选择分支(黄色标记)签出标签devops_2

VSCodium tag

Stephan Avenwedde (CC BY-SA 4.0)

或者,打开命令行并输入。

$ git checkout tags/devops_2

GoogleTest是一个独立于平台的、开源的C++测试框架。尽管GoogleTest不是专门用于单元测试的,但我将用它来定义Generator库的单元测试。一般来说,单元测试应该验证一个单一的、逻辑上的单元的行为。生成器库就是一个单元,所以我将写一些有意义的测试来确保功能正常。

使用GoogleTest,测试用例是由断言宏定义的。处理一个断言会产生以下结果之一。

  • 成功。测试通过。
  • 非致命性失败。测试失败,但测试功能将继续。
  • 致命失败。测试失败,测试函数将被中止。

断言宏遵循这个方案来区分致命和非致命的失败。

  • ASSERT_* 致命失败,函数被中止。
  • EXPECT_* 非致命性失败,函数不被中止。

Google推荐使用EXPECT_* 宏,因为当测试定义多个断言时,它们允许测试继续进行。一个断言宏需要两个参数。第一个参数是测试组的名称(一个可自由选择的字符串),第二个参数是测试本身的名称。Generator库只是定义了函数generate(...),因此本文中的测试属于同一个组。GeneratorTest

以下关于*generate(...)*函数的单元测试可以在GeneratorTest.cpp 中找到。

参考文献检查

generate(...)函数接收一个指向std::stringstream的引用作为参数,并返回相同的引用。因此,第一个测试是检查所传递的引用是否是函数所返回的相同引用。

TEST(GeneratorTest, ReferenceCheck){
    const int NumberOfElements = 10;
    std::stringstream buffer;
    EXPECT_EQ(
        std::addressof(buffer),
        std::addressof(Generator::generate(buffer, NumberOfElements))
    );
}

这里我使用std::addressof来检查返回对象的地址是否与我提供的输入对象相同。

元素的数量

这个测试检查字符串流引用中的元素数是否与作为参数给定的数字一致。

TEST(GeneratorTest, NumberOfElements){
    const int NumberOfElements = 50;
    int nCalcNoElements = 0;
    std::stringstream buffer;
    Generator::generate(buffer, NumberOfElements);
    std::string s_no;
    while(std::getline(buffer, s_no, ' ')) {
        nCalcNoElements++;
    }
    EXPECT_EQ(nCalcNoElements, NumberOfElements);
}

洗牌

这个测试检查随机引擎的工作是否正常。如果我连续两次调用生成函数,我希望不会得到相同的结果。

TEST(GeneratorTest, Shuffle){
    const int NumberOfElements = 50;
    std::stringstream buffer_A;
    std::stringstream buffer_B;
    Generator::generate(buffer_A, NumberOfElements);
    Generator::generate(buffer_B, NumberOfElements);
    EXPECT_NE(buffer_A.str(), buffer_B.str());
}

校验和

这是最大的测试。它检查从1到n的数字序列的数字之和是否与洗牌后的输出序列之和相同。我希望总和是匹配的,因为*generate(...)*函数应该简单地创建这样一个系列的洗牌变体。

TEST(GeneratorTest, CheckSum){
    const int NumberOfElements = 50;
    int nChecksum_in = 0;
    int nChecksum_out = 0;
    std::vector vNumbersRef(NumberOfElements); // Input vector
    std::iota(vNumbersRef.begin(), vNumbersRef.end(), 1); // Populate vector
    // Calculate reference checksum
    for(const int n : vNumbersRef){
        nChecksum_in += n;
    }
    std::stringstream buffer;
    Generator::generate(buffer, NumberOfElements);
    std::vector vNumbersGen; // Output vector
    std::string s_no;
    // Read the buffer back back to the output vector
    while(std::getline(buffer, s_no, ' ')) {
        vNumbersGen.push_back(std::stoi(s_no));
    }
    // Calculate output checksum
    for(const int n : vNumbersGen){
        nChecksum_out += n;
    }
    EXPECT_EQ(nChecksum_in, nChecksum_out);
}

上述测试也可以像普通的C++应用程序一样进行调试。

除了代码内的单元测试,CTest工具让我定义可以在可执行文件上执行的测试。简而言之,我用某些参数调用可执行文件,用正则表达式匹配输出。这让我可以简单地检查可执行文件在不正确的命令行参数下的表现。这些测试被定义在顶层的CMakeLists.txt中。下面是对三个测试案例的详细介绍。

常规用法

如果提供一个正整数作为命令行参数,我希望可执行文件产生一系列由空格分隔的数字。

add_test(NAME RegularUsage COMMAND Producer 10)
set_tests_properties(RegularUsage
    PROPERTIES PASS_REGULAR_EXPRESSION "^[0-9 ]+"
)

没有参数

如果没有提供参数,程序应该立即退出并显示原因。

add_test(NAME NoArg COMMAND Producer)
set_tests_properties(NoArg
    PROPERTIES PASS_REGULAR_EXPRESSION "^Enter the number of elements as argument"
)

错误的参数

提供一个不能转换成整数的参数也应该导致立即退出并显示错误信息。这个测试用命令行参数*"ABC "调用Producer*的可执行程序。

add_test(NAME WrongArg COMMAND Producer ABC)
set_tests_properties(WrongArg
    PROPERTIES PASS_REGULAR_EXPRESSION "^Error: Cannot parse"
)

测试测试

要运行一个单一的测试,看看它是如何被处理的,从命令行调用ctest ,提供以下参数。

  • 运行单个测试。-R
  • 启用冗长的输出。-VV

以下是该命令ctest -R Usage -VV:

$ ctest -R Usage -VV
UpdatecTest Configuration from :/home/stephan/Documents/cpp_testing sample/build/DartConfiguration.tcl
UpdateCTestConfiguration from :/home/stephan/Documents/cpp_testing sample/build/DartConfiguration.tcl
Test project /home/stephan/Documents/cpp_testing sample/build
Constructing a list of tests
Done constructing a list of tests
Updating test list for fixtures
Added 0 tests to meet fixture requirements
Checking test dependency graph...
Checking test dependency graph end

在这个代码块中,我调用了一个名为Usage的测试。

这运行了没有命令行参数的可执行文件。

test 3
    Start 3: Usage
3: Test command: /home/stephan/Documents/cpp testing sample/build/Producer

测试失败了,因为输出与正则表达式不匹配[^[0-9]+]

3: Enter the number of elements as argument
1/1 test #3. Usage ................
Failed Required regular expression not found.
Regex=[^[0-9]+]
0.00 sec round.
0% tests passed, 1 tests failed out of 1
Total Test time (real) =
0.00 sec
The following tests FAILED:
3 - Usage (Failed)
Errors while running CTest
$ 

要运行所有的测试(包括用GoogleTest定义的测试),请导航到构建目录并运行ctest

CTest run

Stephan Avenwedde (CC BY-SA 4.0)

在VSCodium内部,点击信息栏中标记为黄色的区域,调用CTest。如果所有测试都通过了,就会显示以下输出。

VSCodium

Stephan Avenwedde (CC BY-SA 4.0)

用Git Hooks实现测试自动化

到目前为止,运行测试对开发者来说是一个额外的步骤。开发者还可以提交和推送那些没有通过测试的代码。多亏了Git Hooks,我可以实现一个自动运行测试的机制,防止开发者意外地提交有问题的代码。

导航到.git/hooks ,创建一个名为pre-commit 的空文件,并复制和粘贴以下代码。

#!/usr/bin/sh
(cd build; ctest --output-on-failure -j6)

后,使这个文件可执行。

$ chmod +x pre-commit

这个脚本在试图执行提交时调用CTest。如果测试失败,就像下面的截图一样,提交会被中止。

Commit failed

Stephan Avenwedde (CC BY-SA 4.0)

如果测试成功,提交就会被处理,输出结果看起来像这样。

Commit succeeded

Stephan Avenwedde (CC BY-SA 4.0)

所描述的机制只是一个软屏障。一个开发者仍然可以使用git commit --no-verify 提交有问题的代码。我可以通过配置一个构建服务器来确保只有工作的代码被推送。这个话题将是另一篇文章的一部分。

总结

本文中提到的技术很容易实现,并能帮助你快速发现代码中的错误。使用单元测试可能会提高你的代码质量,而且,正如我所展示的,这样做不会干扰你的工作流程。GoogleTest框架为每一种可以想象的情况提供了功能;我只使用了它的一个子集的功能。在这一点上,我还想提一下GoogleTest Primer,它让你对该框架的想法、机会和功能有一个概述。