测试是软件开发过程中一个必须的环节,测试确保软件的质量符合预期。
直接交付没有经过测试的代码是不太好的,因为这很可能会浪费整个团队的时间,在一些原本早期就可以发现的问题上。而单元测试,就是发现问题一个很重要的环节。
在工具上,我们会使用下面这些:
- GCC
- CMake
- Google Test
- gcov
- lcov
演示项目
演示项目的源码可以在我的Github上获取:paulQuei/gtest-and-coverage。
要运行这个项目,你的机器上必须先安装好前面提到的工具。如果没有,请阅读下文以了解如何安装它们。
演示项目的目录结构如下:
.├── CMakeLists.txt├── googletest-release-1.8.1.zip├── include│ └── utility.h├── make_all.sh├── src│ └── utility.cpp└── test └── unit_test.cpp为了简单起见,这个软件库只有一个头文件和一个实现文件。
演示项目中的文件说明如下:
演示项目在如下的环境中测试过。
- MacBook Pro操作系统:macOS Mojave 10.14.1编译器:Apple LLVM version 10.0.0 (clang-1000.11.45.2)CMake:cmake version 3.12.1Google Test: 1.8.1lcov: lcov version 1.13
- Ubuntu操作系统:Ubuntu 16.04.5 LTS编译器:gcc (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609CMake:cmake version 3.5.1Google Test:1.8.1lcov:lcov version 1.12
关于CMake
关于如何安装CMake请参见这里:Installing CMake。
Mac系统:
brew install cmake由于篇幅所限,这里不打算对CMake做过多讲解,读者可以访问其官网或者在网络上搜寻其使用方法。
以编号为序,这段代码说明如下:
- 设置使用的CMake最低版本号为2.8.11。
- 指定项目的名称为”utility”,项目名称可以通过${CMAKE_PROJECT_NAME}进行引用。
- 指定使用C++11。
- 这里的三行是编译google test,并将其头文件路径和编译结果的库文件路径添加到环境中。因为后面在编译单元测试代码的时候需要用到。
- 添加--coverage到编译器flag中,这个参数是很重要的,因为这是生成代码覆盖率所必须的。关于该编译参数的说明见这里:Program Instrumentation Options。
- 编译我们的软件库,这里将生成libutility_lib.a库文件。
- 编译单元测试的可执行文件。
- 单元测试的可执行文件需要链接我们开发的软件库以及google test的库。另外,google test依赖了pthread,所以这个库也需要。
关于测试
这其中,单元测试是最局部和具体的。它通常需要对代码中的每一个类和函数进行测试。
xUnit是几种单元测试框架的总称。最早源于Smalltalk的单元测试框架SUnit,它是由Kent Beck开发的。
在本文中,我们使用Google开发的xUnit框架:Google Test。
Google Test介绍
实际上,这个项目中同时包含了GoogleTest和GoogleMock两个工具,本文中我们只会讲解第一个。
目前有很多的项目都使用了Google Test,例如下面这些:
- Chromium projects
- LLVM
- Protocol Buffers
- OpenCV
- tiny-dnn
编译Google Test
为了便于读者使用,我们在演示项目中包含了Google Test 1.8.1的源码压缩包。并且在CMake文件中,同时包含了Google Test的编译和使用配置工作。
为了便于下文说明,演示项目中包含了几个简单的函数。
演示项目中的软件库包含一个头文件和一个实现文件。头文件内容如下:
// utility.h#ifndef INCLUDE_UTILITY_#define INCLUDE_UTILITY_enum CalcType { ADD, MINUS, MULTIPLE, DIVIDE};class Utility {public: int ArithmeticCalculation(CalcType op, int a, int b); double ArithmeticCalculation(CalcType op, double a, double b); bool IsLeapYear(int year);};#endif这三个函数的实现也不复杂:
// utility.cpp#include "utility.h"#include <iostream>#include <limits>using namespace std;int Utility::ArithmeticCalculation(CalcType op, int a, int b) { if (op == ADD) { return a + b; } else if (op == MINUS) { return a - b; } else if (op == MULTIPLE) { return a * b; } else { if (b == 0) { cout << "CANNO Divided by 0" << endl; return std::numeric_limits<int>::max(); } return a / b; }}double Utility::ArithmeticCalculation(CalcType op, double a, double b) { if (op == ADD) { return a + b; } else if (op == MINUS) { return a - b; } else if (op == MULTIPLE) { return a * b; } else { if (b == 0) { cout << "CANNO Divided by 0" << endl; return std::numeric_limits<double>::max(); } return a / b; }}bool Utility::IsLeapYear(int year) { if (year % 100 == 0 && year % 400 == 0) { return true; } if (year % 100 != 0 && year % 4 == 0) { return true; } return false;}开始测试
要使用Google Test进行测试,整个过程也非常的简单。只要进行下面三部:
- 创建一个测试用的cpp文件
- 为上面这个测试用的cpp文件编写Makefile(或者CMake文件)。同时链接:待测试的软件库gtest库gtest_main库pthread库(Google Test使用了这个库所以需要)
- 编写测试代码,编译并运行测试的可执行程序。
是的,就是这么简单的几行代码,就对整数四则运算的函数进行了测试。
注意:在做单元测试的时候,保证每条case是独立的,case之间没有前后依赖关系是非常重要的。
这段代码应该很好理解,它分别进行了下面这些测试:
- 1 + 1 = 2
- 2 – 1 = 1
- 3 x 3 = 9
- 10 / 2 = 5
- 10 / 0 > 999999999
在实际的测试过程中,你想判断的情况可能不止上面这么简单。下面我们来看看Google Test还能做哪些测试。
测试判断
可以进行的判断方法主要有下面这些:
FatalNonfatal说明ASSERT_TRUE(condition)EXPECT_TRUE(condition)判断 condition 为 trueASSERT_FALSE(condition)EXPECT_FALSE(condition)判断 condition 为 false
FatalNonfatal说明ASSERT_EQ(expected, actual)EXPECT_EQ(expected, actual)判断两个数值相等ASSERT_NE(val1, val2)EXPECT_NE(val1, val2)val1 != val2ASSERT_LT(val1, val2)EXPECT_LT(val1, val2)val1 < val2ASSERT_LE(val1, val2)EXPECT_LE(val1, val2)val1 <= val2ASSERT_GT(val1, val2)EXPECT_GT(val1, val2)val1 > val2ASSERT_GE(val1, val2)EXPECT_GE(val1, val2)val1 >= val2
字符串判断
浮点数判断
异常判断
在某些情况下,我们可能希望多条测试case使用相同的测试数据。例如,我们的演示项目中,每条case都会需要创建Utility对象。
要使用Test Fixture我们需要创建一个类继承自Google Test中的::testing::Test。
例如,我们要测试的是一个队列数据结构。有的case会向队列中添加数据,有的case会从队列中删除数据。case执行的顺序不同,则会导致Queue中的数据不一样,这就可能会影响case的结果。
这两项重复性的工作可以由::testing::Test类中的Setup和TearDown两个函数来完成。
使用Test Fixture后,我们的代码如下所示:
class UtilityTest : public ::testing::Test {protected:void SetUp() override { cout << "SetUp runs before each case." << endl;}void TearDown() override { cout << "TearDown runs after each case." << endl;}Utility util;};要使用这里定义的Test Fixture,测试case的代码需要将开头的TEST变更为TEST_F。
使用TEST_F的case的代码结构如下:
TEST_F(TestCaseName, TestName) { ... test body ...}所以我们的测试代码写起来是这样:
TEST_F(UtilityTest, ArithmeticCalculationDouble) { EXPECT_EQ(util.ArithmeticCalculation(ADD, 1.1, 1.1), 2.2);}TEST_F(UtilityTest, ArithmeticCalculationIsLeapYear) { EXPECT_FALSE(util.IsLeapYear(1997)); EXPECT_TRUE(util.IsLeapYear(2000)); EXPECT_TRUE(util.IsLeapYear(2016)); EXPECT_FALSE(util.IsLeapYear(2100));}编写完单元测试之后,再执行编译工作便可以运行测试程序以查看测试结果了。
如果测试中包含了失败的case,则会以红色的形式输出。同时,会看到失败的case所处的源码行数,这样可以很方便的知道哪一个测试失败了,像下面这样:
nerror="javascript:errorimg.call(this);">
像下面这样:
$ ./build/unit_test --gtest_filter=*ArithmeticCalculationIntRunning main() from googletest/src/gtest_main.ccNote: Google Test filter = *ArithmeticCalculationInt[==========] Running 1 test from 1 test case.[----------] Global test environment set-up.[----------] 1 test from TestCalculationInt[ RUN ] TestCalculationInt.ArithmeticCalculationIntCANNO Divided by 0[ OK ] TestCalculationInt.ArithmeticCalculationInt (0 ms)[----------] 1 test from TestCalculationInt (0 ms total)[----------] Global test environment tear-down[==========] 1 test from 1 test case ran. (0 ms total)[ PASSED ] 1 test.在utility.h和utility.cpp中添加一些新的函数。在新添加的函数中故意包含一个bug。为新添加的函数编写测试代码,并测试出函数中包含的bug。
代码覆盖率
理论上,如果我们能做到100%的覆盖我们的所有代码,则可以说我们的代码是没有Bug的。
先来看一下,当我们在说“覆盖率”的时候我们到底是指的什么。
这其中,函数覆盖率最为简单,就不做说明了。
而分支覆盖率和条件覆盖率可能不太好理解,需要做一下说明。
这个函数中包含了一个if语句,因此if语句成立或者不成立构成了两个分支。所以如果只测试了if成立或者不成立的其中之一,其分支覆盖率只有 1/2 = 50%。
对于if (a && b)这样的语句,其一共有四种可能的情况:
- a = true, b = true
- a = true, b = false
- a = false, b = true
- a = false, b = false
很显示,在编写代码的时候,尽可能的减少代码嵌套,并且简化逻辑运算是一项很好的习惯。
有了这些概念之后,我们就可以看懂测试报告中的覆盖率了。
gcov
通常情况下,安装好GCC工具链,也就同时包含了gcov命令行工具。
因此,即便没有测试代码,直接运行编译产物也可以得到代码的覆盖率。只不过,通常情况下这样得到的覆盖率较低罢了。
这里我们以另外一个简单的代码示例来说明gcov的使用。
这是一个仅仅包含了main函数的c语言代码,main函数的逻辑也很简单。
要通过gcov生成代码覆盖率。需要在编译时,增加参数--coverage:
gcc --coverage test.c此处的编译结果除了得到可执行文件a.out,还会得到一个test.gcno文件。该文件包含了代码与行号的信息,在生成覆盖率时会需要这个文件。
当我们执行上面编译出来的可执行文件a.out时,我们还会得到每个源码文件对应的gcda后缀的文件。由test.gcno和test.gcda这两个文件,便可以得到代码的覆盖率结果了。
只需要通过gcov指定源文件的名称(不需要带后缀):gcov test,便可以得到包含覆盖率的结果文件 test.c.gcov了。
我们可以cat test.c.gcov一下,查看覆盖率的结果:
-: 0:Source:test.c -: 0:Graph:test.gcno -: 0:data:test.gcda -: 0:Runs:1 -: 0:Programs:1 -: 1:// test.c -: 2: -: 3:#include <stdio.h> -: 4: -: 5:int main (void) { -: 6: 20: 7: for (int i = 1; i < 10; i++) { 9: 8: if (i % 3 == 0) 3: 9: printf ("%d is divisible by 3\n", i); 9: 10: if (i % 11 == 0) #####: 11: printf ("%d is divisible by 11\n", i); 9: 12: } -: 13: 1: 14: return 0; -: 15:}gcov得到的结果是本文形式的。但很多时候,我们可能希望得到更加美观和便于浏览的结果。
lcov是gcov工具的图形前端。它收集多个源文件的gcov数据,并生成描述覆盖率的HTML页面。生成的结果中会包含概述页面,以方便浏览。
这个链接是lcov生成的报告样例:lcov – code coverage report。
lcov并非包含在GCC中,因此需要单独安装。
Ubuntu系统
sudo apt install lcov对于lcov的使用方法可以通过下面这条命令查询:
lcov --help这里主要关注的下面这几个参数:
- -c 或者 --capture 指定从编译产物中收集覆盖率信息。
- -d DIR 或者 --directory DIR 指定编译产物的路径。
- -e FILE PATTERN 或者 --extract FILE PATTERN 从指定的文件中根据PATTERN过滤结果。
- -o FILENAME 或者 --output-file FILENAME 指定覆盖率输出的文件名称。
最后,make_all.sh脚本中包含的相关内容如下:
COVERAGE_FILE=coverage.infoREPORT_FOLDER=coverage_reportlcov --rc lcov_branch_coverage=1 -c -d build -o ${COVERAGE_FILE}_tmplcov --rc lcov_branch_coverage=1 -e ${COVERAGE_FILE}_tmp "*src*" -o ${COVERAGE_FILE}genhtml --rc genhtml_branch_coverage=1 ${COVERAGE_FILE} -o ${REPORT_FOLDER}可以通过浏览器查看覆盖率报告的结果,像下面这样:
nerror="javascript:errorimg.call(this);">
在上面这张图中,我们可以看到哪些代码被覆盖了,哪些没有。而对于对于if-else之类的语句,也能很清楚的看到条件覆盖率的覆盖情况。例如,对于代码的27行,只覆盖了if成立时的情况,没有覆盖if不成立时的情况。
更进一步
但实际上,对于这项工作我们还可以做得更多一些。例如下面这两项工作:
使用Google Mock
在面向对象的编程中,Mock对象是模拟对象,它们以预先设定的方式模仿真实对象的行为。程序员通常会创建一个Mock对象来测试某个其他对象的行为,这与汽车设计师使用碰撞测试假人来模拟人类在车辆碰撞中的动态行为的方式非常相似。
对于演示项目的覆盖率报告是通过手动执行脚本文件生成的。
可以在持续集成工具中包含我们编写的脚本,然后将覆盖率报告的html结果发布到某个Web服务器上,最后再以邮件的形式将链接地址发送给大家。
完成了一整套这样的工作,可以非常好的提升整个项目的质量。
