c++单元测试框架CPPUNIT的使用以及gcov统计大型项目的测试覆盖率

先来闲聊下

3月的杭州,依旧不见暖意。从老家过完年回来,气温低的有点不适应,还感冒了一次。身体没消停,思想也没消停,目前的环境,不太适合自身的发展与追求,良禽择木而栖,打算辞职。忙完了新公司的面试,空下了大半个月的时间,用来处理老东家这边手头的工作交接以及一系列离职手续相关的事项,另外,也打算为下一份工作做点准备,学学python,写个爬虫程序玩玩。后面我会写一篇博文分享一下最近面试的感触与经验。这篇博文主要来聊聊目前我手头还没有交接的工作——cppunit的使用以及gcov统计代码覆盖率的问题。

CPPUNIT

cppunit是个基于LGPL的开源项目,最初版本移植自 JUnit,是一个非常优秀的开源测试框架。正如官方介绍那样:

CppUnit is the C++ port of the famous JUnit framework for unit testing.
Test output is in XML or text format for automatic testing and GUI based for supervised tests.

废话不多说,开始正题。或许你也可以参考CppUnit Cookbook快速上手,进行单元测试案例的编写。

CppUnit原理

TestCase 代表一个测试用例,TestSuit 包含一组测试用例。一个或一组测试用例的测试对象被称为 Fixture。Fixture 就是被测试的目标,可能是一个对象或者一组相关的对象,甚至一个函数。通常编写一个TestCase包含如下四个步骤:

  • 对 fixture 进行初始化,及其他初始化操作,比如:生成一组被测试的对象,初始化值,setUp()函数;
  • 按照要测试的某个功能或者某个流程对 fixture 进行操作;
  • 验证结果是否正确,使用一系列预定义的宏;
  • 对 fixture 的及其他的资源释放等清理工作,tearDown()函数。

CppUnit实例

对 fixture 的所有测试用例可以被封装在一个 CppUnit::TestFixture 的子类中或者一个CPPUNIT_NS::TestCase子类中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//GxpMessageTest.h

#include<cppunit/extensions/HelperMacros.h>
#include<cppunit/TestFixture.h>

//两种派生方式都可以,下面那种为了兼容parasoftcpp的使用
//class GxpMessageTest:public CppUnit::TestFixture
class GxpMessageTest:public CPPUNIT_NS::TestCase
{

CPPUNIT_TEST_SUITE(GxpMessageTest);//开始创建一个TestSuite

CPPUNIT_TEST(test_calculateBasePath_0); //添加TestCase
CPPUNIT_TEST(test_calculateBasePath_1); //添加TestCase

CPPUNIT_TEST_SUITE_END();//结束创建TestSuite
protected:
/* something protected*/
string fixml;
string cfgXmlfName;
string fix;

public:
GxpMessageTest();
virtual ~GxpMessageTest();

virtual void setUp(); //初始化
virtual void tearDown(); //结束,清理

void test_calculateBasePath_0();
void test_calculateBasePath_1();

};

类GxpMessageTest.cpp的实现如下,主要针对test_calculateBasePath_0这样的测试函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
CPPUNIT_TEST_SUITE_NAMED_REGISTRATION(GxpMessageTest,"GxpMessagetest");

GxpMessageTest::GxpMessageTest()
{

}

GxpMessageTest::~GxpMessageTest()
{

}

void GxpMessageTest::setUp()
{
fixml.append(LOCATION).append("/FIX50SP2.xml");
cfgXmlfName.append(LOCATION).append("/gxpFIXpublic.xml");
fix.append("8=FIX.4.2\0019=272\00135=E\00134=126\00166666=1095350459\00150=00303\00149=BUYSIDE\00152"
"=20040916-16:19:18.328\00168=2\00156=SELLSIDE\00173=2\00111"
"=1095350459\00155=fred\00140=1\00167=1\0011=00303\00178=3\00179=string\00179=string\00179=string\00154=1\00159=3\001"
"11=1095350460\00167=2\00140=1\00159=3\0011=00303\00178=3\00179=string\00179=string\00179=string\00155=feed\00154=5\001394=3\00110="
"120\001");
}

void GxpMessageTest::tearDown()
{
//delete _cfgxml;
}

void GxpMessageTest::test_calculateBasePath_0()
{
FIX::GxpMessage object(_object);
string basepath("/root/nodeA/nodeB|N");
CPPUNIT_ASSERT_EQUAL(object.calculateBasePath(basepath,5),(string)"/root/node/nodeB|5");
}

void GxpMessageTest::test_calculateBasePath_1()
{
FIX::GxpMessage obj(_object);
/*验证类GxpMessage的构造函数GxpMessage(const FIX::Message& m) */
FIX::GxpMessage object(obj);
FIX::GxpMessage obj_null;

string basepath("/root/nodeA/group|4");
string falsepath("/root/nodeA/group|N/subnode");
string respath;
CPPUNIT_ASSERT_EQUAL((string)"/root/nodeA/group|4/subnode",object.calculateBasePath(basepath,falsepath,respath));

basepath.assign("/root/nodeA/group|4/subnode/subgroup|5");
falsepath.assign("/root/nodeA/group|N/subnode/subgroup|N/abc/group|N");
respath.clear();
CPPUNIT_ASSERT_EQUAL((string)"/root/nodeA/group|4/subnode/subgroup|5/abc/group|N",object.calculateBasePath(basepath,falsepath,respath));
}

其中需要留意的就是这几个宏,可以方便的创建测试用例:

1
2
3
4
CPPUNIT_TEST_SUITE() 开始创建一个TestSuite 
CPPUNIT_TEST() 添加TestCase
CPPUNIT_TEST_SUITE_END() 结束创建
CPPUNIT_TEST_SUITE_NAMED_REGISTRATION() 添加一个TestSuite到一个指定的TestFactoryRegistry工厂(两个参数:第一个是TestSuite;第二个是唯一标识TestSuite名称字符串)

另外,CppUnit提供了多种验证测试用例成功失败的宏:

1
2
3
4
5
6
7
8
9
10
11
CPPUNIT_ASSERT(condition)   // 确信condition为真
CPPUNIT_ASSERT_MESSAGE(message, condition)
// 当condition为假时失败, 并打印message
CPPUNIT_FAIL(message)
// 当前测试失败, 并打印message
CPPUNIT_ASSERT_EQUAL(expected, actual)
// 确信两者相等
CPPUNIT_ASSERT_EQUAL_MESSAGE(message, expected, actual)
// 失败的同时打印message
CPPUNIT_ASSERT_DOUBLES_EQUAL(expected, actual, delta)
// 当expected和actual之间差大于delta时失败

在完成一个或者多个测试类的编写后,需要用TestRunner类的实例来运行每个测试类的测试函数进行测试。TestRunner类型共有:TextUi::TestRunner,QtUi::TestRunner,MfcUi::TestRunner三个,都可以用来运行测试。

主测试程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//main.cpp
int main(int argc,char* argv[])
{
/*
CppUnit::TextUi::TestRunner runner;
CppUnit::TestFactoryRegistry &registry =CppUnit::TestFactoryRegistry::getRegistry("alltest");//得到标识MathTest名称字符串
alltest的TestFactoryRegistry

runner.addTest(registry.makeTest());//添加Test
runner.run();//运行测试案例
*/

// informs test-listener about testresults
CPPUNIT_NS::TestResult testresult;

// register listener for collecting the test-results
CPPUNIT_NS::TestResultCollector collectedresults;
testresult.addListener (&collectedresults);

// register listener for per-test progress output
CPPUNIT_NS::BriefTestProgressListener progress;
testresult.addListener (&progress);

// insert test-suite at test-runner by registry
CPPUNIT_NS::TestRunner testrunner;
testrunner.addTest (CPPUNIT_NS::TestFactoryRegistry::getRegistry("GxpMessagetest").makeTest ());
testrunner.addTest (CPPUNIT_NS::TestFactoryRegistry::getRegistry("CfgXmltest").makeTest ());
testrunner.run(testresult);

// output results in compiler-format
CPPUNIT_NS::CompilerOutputter compileroutputter(&collectedresults, std::cerr);
compileroutputter.write ();

// Output XML for Jenkins CPPunit plugin
ofstream xmlFileOut("cppTestBasicMathResults.xml");
XmlOutputter xmlOut(&collectedresults, xmlFileOut);
xmlOut.write();

// return 0 if tests were successful
return collectedresults.wasSuccessful() ? 0 : 1;

}

至此,可以很便利的使用CppUnit进行单元测试案例的编写,提高单元测试案例的编写效率以及测试效果。如果单元测试案例写的够详细,那么可以把代码覆盖率提高很多,对源代码的静态或者逻辑功能进行较为详细的测试。
另外,可以参考以下网址:

UnitTestingWithCppUnit &&
便利的开发工具 CppUnit 快速使用指南

GCOV

gcov是gcc中内建的工具,用来做代码覆盖率统计;lcov是GCOV图形化的前端工具,基于Html输出,并生成一棵完整的HTML树。

步骤概览

  1. 编译生成xx.gcno文件
  2. 执行程序生成xx.gcda文件
  3. 使用lcov命令手机覆盖率信息写入yy.info文件,可能对多个yy.info文件进行合并为一个大的info文件
  4. 使用genhtml命令生成用于展示覆盖率的html文件

gcno文件 && gcda文件

要在编译时生成gcno文件,运行后生成gcda文件,需要在编译链接过程对相关文件进行插桩,即在编译时添加编译选项-fprofile-arcs -ftest-coverage,在链接时加上选项-lgcov。如果没有-lgcov选项编译出来的.so文件在动态加载的时候会提示类似 undefined reference to ‘gcov_merge_add’ 或者 undefined reference to ‘gcov_init’这样的错误。可以在makefile里定义宏:

1
2
GCOVFLAG=-fprofile-arcs -ftest-coverage 
LINKFLAG=-lgcov

编译后每个.cpp文件对应一个gcno文件,然后运行测试程序可以生成gcda文件,虽然可以用环境变量指定gcda文件生成的路径,但是不建议这么做,因为后续的lcov收集覆盖信息需要gcno文件和gcda文件在一个目录下。

这两个文件都是二进制文件,包含不可见字符。此处需要留意的是可执行程序。对于我来说,此处的 可执行程序 是我使用CppUnit框架编写的单元测试程序,在链接的时候以动态库so的方式对需要测试的源代码进行链接。

通常的做法是,如果源代码目录名为/fix,下面包含了很多.c/.cpp/.h文件,我的做法是在/fix目录下新建一个cppunit文件夹作为单元测试案例的编写目录。编写好后,只需要在源代码目录/fix下的makefile里加入GCOVFLAG和LINKFLAG,然后在cppunit目录下的makefile里链接源代码生成的so库,然后make生成可执行程序,然后运行该可执行程序。

这将会在源代码目录/fix下生成gcno和gcda文件,对应每一个c/cpp文件。如图:

info文件 && html文件

使用lcov命令收集覆盖率信息,写入info文件。具体参数使用请查看 lcov –help .我常用命令:

1
2
3
GCOVTESTPATH=/home/niki/sources/src/gcovtest
INFONAME=$(basename `pwd`)
lcov -d . -o $GCOVTESTPATH/$INFONAME.info -c

其中gcovtest目录是我专门用来收集info文件的目录,因为会涉及到很多个文件夹,每个源代码目录都可能产生一个info文件,最终需要合并这些info文件为一个统一的info文件然后才能进行分析。这里我用到了一个小脚本all_gcov:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#! /bin/bash
./generate_info

aArg=
argsingle=

HTMLPATH=~/windows/result
for nf in `ls *.info`
do
argsingle="-a $nf "
aArg="$aArg $argsingle"
done
echo $aArg
lcov -o Main.INFO $aArg
genhtml -o $HTMLPATH Main.INFO

脚本很容易理解,generate_info脚本是用来对每个源代码目录生成info文件到$GCOVTESTPATH下,后面会介绍。脚本对$GCOVTESTPATH目录下的*.info文件进行遍历,然后合并成一个大的Main.INFO文件。然后使用genhtml命令生成html文件写到目录~/windows/result目录下。

那么generate_info脚本是什么样的?

对于一个大型项目来说,有很多的源代码目录甚至多个子目录,每个源代码目录下的源代码都需要编写单元测试案例进行测试,当每个目录下都生成了gcno和gcda文件后,这个时候就需要generate_info脚本了,大致原理是进入到每一个源代码目录下,使用lcov命令根据gcno和gcda文件生成info文件到$GCOVTESTPATH目录下。部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/bin/ksh
SRC_DIR=$GXPHOME/src
BASE_DIR=`cd .&&pwd`
MAKE_LOG=$BASE_DIR/gcov.log
COMPILE_LOG=$BASE_DIR/process.log

GCOVTESTPATH=/home/niki/sources/src/gcovtest

#进入到某个源代码目录下,根据生成的*.gcno和*.gcda文件,lcov抓取数据生成xx.info文件写到$GCOVTESTPATH目录下
function gen_infofile {
INFONAME=$(basename `pwd`)
lcov -d . -o $GCOVTESTPATH/$INFONAME.info -c >> $COMPILE_LOG 2>&1
#du filename 输出为 256 filename
filenone=$(du $GCOVTESTPATH/$INFONAME.info)
#获取文件大小
filesize=`echo $filenone | awk '{print $1;}'`
#如果文件大小为0,则删除这个info空文件
if [ "$filesize" -eq 0 ];then
¦ rm $GCOVTESTPATH/$INFONAME.info
fi

TARGET=gcov_info_file
echo "begin ${TARGET}"|tee $MAKE_LOG
echo "begin process ">$COMPILE_LOG
#xml
echo "begin gcov xml.info" |tee -a $MAKE_LOG
cd $SRC_DIR/xml
gen_infofile

#plugin
PLUGIN_SRC_DIR=$SRC_DIR/plugin
for loop in `ls $PLUGIN_SRC_DIR|grep -v demo`
do
¦ is_in_skiplist $loop
¦ if [ $SKIPRET = 1 ]; then
continue
¦ fi
¦ echo "begin gcov plugin $loop.info"|tee -a $MAKE_LOG
¦ cd $PLUGIN_SRC_DIR/$loop
gen_infofile
¦ #./gcov.sh >>$COMPILE_LOG 2>&1
done


#省略了一些类似的代码

优化:这个地方还可以优化,前提需要每个源代码目录下都编译&&运行产生了gcno和gcda文件;其实还可以写脚本执行产生gcno/gcda文件的重复性工作。

html浏览器展示

genhtml命令产生的html文件写到了~/windows/result文件夹下,用浏览器打开index.html:



可以看到还是比较详细的把代码覆盖率显示了。点开源代码也可以看到具体哪一些代码行被执行了哪一些没有被执行。总体看来还是挺不错的!

CppUnit配合Gcov

上面详细的介绍了CppUnit和Gcov的使用,那么现在对于一个新编写的源代码目录,比如/newsrcdir,需要做下面一些工作:

  1. 新建目录/newsrcdir/cppunit,编写单元测试案例到此目录下
  2. /newsrcdir/makefile中添加$GCOVFLAG和$LINKFLAG,编译源代码生成gcno文件
  3. /newsrcdir/cppunit/makefile中链接-lnewsrcdir(测试源代码生成的动态库),make all
  4. 执行生成的可执行程序
  5. /newsrcdir目录下生成了gcda文件
  6. 去到$GCOVTESTPATH下执行./all_gcov
  7. 浏览器中查看html

以上步骤可以优化,最终的all_gcov执行结果为:

好了,结束,下班,回家

写完了。下一篇,面试的感触与经验分享。

Blog:

2017-03-17 于杭州
By 史矛革

buy me a cola!

欢迎关注我的其它发布渠道