例子程序下载:
CPPUnit最新版本免费下载:
CPPUnit是基于C++的单元测试框架,可以有效提高开发的系统质量。
引言:
QA过程常采用两种测试方法:
1、单元测试(acceptance测试):为软件系统中的每一个逻辑单元制定的一系列验证方法。仅测试单元的功能,而不考虑各个单元之间的协作关系。
2、系统测试(集成测试):测试系统的功能,尤其是各单元模块之间的协作关系。
下面要讲的是如何采用CPPUnit对C/C++工程进行单元测试。
文章假设读者熟悉单元测试的概念及其重要性。
单元测试设计:
想一下开发团队中常常出现的一种场景:程序员正在使用Debugger工具测试代码。采用Debugger工具可以可以随时随地检查每个变量。步步跟踪,检查变量的值是否异常。Debugger是一种强有力的调试工具,但是调试速度相当慢,并且包含不少错误。在这种情况下调试是让人崩溃的。这些复杂有大量重复的验证方法是可以通过自动化的手段完成的,需要做的是选择合适的工具并编写少量代码。
下面要介绍的工具叫做“单元测试框架”,借助这种工具,可以通过编写一些小的模块来完成模块(可以是类、函数和库)的单元测试。
下面来看一个例子:编写一个小的模块,主要功能是求两数之和。其C语言代码如下:
BOOL addition(int a, int b){ return (a + b);}
测试单元编写成另外一个模块(C函数)。该模块测试所有可能的求两数之和的组合,通过返回True或False来判断被测模块是否通过了测试。代码如下:
BOOL additionTest(){ if ( addition(1, 2) != 3) { return (FALSE); } if ( addition(0, 0) != 0) { return (FALSE); } if ( addition(10, 0) != 10) { return (FALSE); } if ( addition(-8, 0) != -8) { return (FALSE); } if ( addition(5, -5) != 0) { return (FALSE); } if ( addition(-5, 2) != -3) { return (FALSE); } if ( addition(-4, -1) != -5) { return (FALSE); } return (TRUE);}
测试的情况包括:
正数+正数
0+0
正数+0
负数+0
正数+负数
负数+正数
负数+负数
每一次测试都是通过对比被测模块的返回值和期望值,如果二者不同,返回FALSE。如果最终返回TRUE,说明模块通过了所有的测试。
这个用以测试其他模块的小模块(函数)被称为Test Case, 其中包含了程序员需要对被测单元的一系列检查。每一个确认(对被测单元的一次调用)都必须和被测单元相对应。在这个例子中,检查了“求和操作”在操作数符号不同的情况下的运行情况。当然了,还需要另外写一些Test Case来验证其他情况下的运行情况。比如其他一些常见的加法组合。例子如下:
int additionPropertiesTest(){ //conmutative: a + b = b + a if ( addition(1, 2) != addition(2, 1) ) { return (FALSE); } //asociative: a + (b + c) = (a + b) + c if ( addition(1, addition(2, 3)) != addition(addition(2, 1), 3 ) ) { return (FALSE); } //neutral element: a + NEUTRAL = a if ( addition(10, 0) != 10 ) { return (FALSE); } //inverse element: a + INVERSE = NEUTRAL if ( addition(10, -10) != 0 ) { return (FALSE); } return (TRUE);}
上面的例子测试了多个数据相加顺序不同的情况。
上述的两个Test Case组成了一个Test Suite,Test Suite是指用来测试同一被测单元的一组Test Case。
在开发被测模块时必须同时编写这些Test Case和Test Suite的代码,被测模块变更时,要同时变更(有时需要增加)相应的Test Case和Test Suite。
举例来说,当求和模块升级为可以对小数求和的模块,就必须变更Test Case和Test Suite,加入诸如addDecimalNumbersTest之类的Test Case。
极限编程建议程序员在编写目标模块之前就开发出所有单元测试中要用到的Test Case。其主要理由是:一旦程序员处于开发过程之中,那么他就进入了一个持续改进的阶段,必须同时考虑单元模块功能、需要公布的接口、需要给方法传递的参数、外部访问、内部行为等等。在编写目标单元之前通过开发Test Case,可以对需要考虑的这些因素有更好的了解,这样编写目标模块与其他方法相比速度会更快,代码的质量也会更好。
每当开发团队需要发布新版本的时候,都要进行彻底的单元测试。所有的单元必须通过单元测试,这样就可以发布成功的版本。如果有1个或以上的单元没有通过所有的测试,Bug就出现了。遇到这种情况就需要在进行测试,如果需要的话还需要增加新的Test Case,检查可以使Bug再现的所有情况。如果新的Test Case可以使Bug重现,就可以修正这个Bug,然后再进行测试,如果模块通过了测试,就可以认为Bug已经修正,可以发布新的无Bug版本了。
为每一个发现的Bug添加新的Test Case是很有必要的,因为Bug会反复出现,当其重复出现时需要有效的测试来检测Bug。这样的话,Test Bettery会逐渐膨胀直至覆盖所有的历史Bug和潜在的错误。
测试工具:
有两个小伙子,一个叫Kent Beck,另一个叫Eric Gamma,他们写了一系列的Java类,希望可以把测试做的尽可能自动化,并称之为JUnit,JUnit使整个单元测试界产生的很大的震动。其他的开发者们把JUnit的代码移植到其他语言上,构建了一大系列称为xUnit框架的产品。其总包括C/C++的CUnit和CPPUnit,Delphi的DUnit,Visual Basic的VBUnit,.NET平台上的NUnit,等等。
所有这些框架都采用同样的规则,对语言的依赖性很小,熟悉其中一个框架就能够熟练应用其他框架。
下面要讲的是如何通过使用CPPUnit来编写测试代码并提高单元的质量。
CPPUnit采用面向对象的编程方法,中间会遇到诸如封装、继承、多态这些概念。另外,CPPUnit采用C++ SEH(Structured Exception Handling),所以还会遇到异常的概念,以及throw, try, finally, catch这些指令。
CPPUnit
每一个Test Case都需要在TestCase类的派生类中定义。TestCase类中包含了许多基本的功能,比如运行测试、在Test Suite中注册Test Case等。
比如在需要写一个在磁盘上存储数据的小模块的时候,模块(定义为DiskData类)主要实现两个功能:读取数据和装载数据。例程如下:
typedef struct _DATA{ int number; char string[256];}DATA, *LPDATA;class DiskData{public: DiskData(); ~DiskData(); LPDATA getData(); void setData(LPDATA value); bool load(char *filename); bool store(char *filename);private: DATA m_data;};
此时,首先要做的事情不是弄明白上面的代码是如何变出来的,而是要确定上面所定义的类是否完成了设计的全部功能——正确地读取和存储数据。
为此,需要设计一个新的Test Suite,其中包含两个Test Case:一个读取数据、一个存储数据。
使用CPPUnit
最新版本的CPPUnit可以在上免费下载到,其中包含所有的库文件、文档、例子程序和其他有趣的素材。
在Win32环境下,可以在VC++(6.0或更新版本)中使用CPPUnit,由于CPPUnit采用的是ANSI C++,所以可应用于C++ Builder等开发环境中的版本较少。
构建库文件的步骤可以在CPPUnit发布版本的INSTALL-WIN32.txt文件中找到。构
建好库文件之后就可以着手编写Test Suite了。
在VC++下编写单元测试程序的步骤如下:
创建一个基于MFC的对话框应用程序(或者文档应用程序)
开启RTTI:Project Settings -> C++ -> C++ Language
在include目录中加入CPPUnit\include:Tools -> Options -> Directories -> Include
连接cppunitd.lib(静态连接)或者cppunitd_dll.lib(动态连接),testrunnerd.lib。如果是在“Release”配置下编译,同样需要连接这些库文件,只是需要把名称中的“d”字母去掉。
拷贝testrunnerd.dll文件到可执行文件夹的下面(或者路径下的其他文件夹中),如果是动态连接的话,还需要拷贝cppunitd_dll.dll(“Release”配置下需要拷贝testrunner.dll和cppunit_dll.dll)。
配置好之后即可以着手进行单元测试类编码了。
待测试的DiskData类,主要实现两个功能:读取和存储磁盘上的数据。要测试这两个功能,需要两个Test Case:一个负责读取数据、一个负责存储数据。
下面是单元测试类的定义:
#if !defined(DISKDATA_TESTCASE_H_INCLUDED)#define DISKDATA_TESTCASE_H_INCLUDED#if _MSC_VER > 1000#pragma once#endif // _MSC_VER > 1000#include//为了从基类TestCase派生新的测试类#include //方便快速定义测试类的宏#include "DiskData.h"class DiskDataTestCase : public CppUnit::TestCase{ CPPUNIT_TEST_SUITE(DiskDataTestCase);//定义Test Suite的起点 CPPUNIT_TEST(loadTest);//定义Test Case CPPUNIT_TEST(storeTest); CPPUNIT_TEST_SUITE_END();//定义Test Suite的终点public: void setUp(); void tearDown();protected: void loadTest(); void storeTest();private: DiskData *fixture;};#endif
例程中,DiskDataTestCase类重载了两个方法:setUp()和tearDown()。这两个方法在Test Case开始和结束的时候自动运行。
测试逻辑是在两个Protected方法中实现的,稍后要涉及到如何为测试逻辑编码。
例程的最后定义了指向DiskData类型数据的指针fixture,用以保存测试过程中的目标对象。setUp()是初始化函数,在调用每一个Test Case之前调用setUp(),同时负责初始化目标对象。Test Case运行过程中要使用fixture。在每一个Test Case运行结束之后,调用tearDown()销毁fixture。这样,每次运行Test Case时所使用的都是新产生的fixture。
测试步骤如下:
开启测试程序
点击“Run”按键
调用setUp()方法:初始化fixture
调用第一个Test Case函数
调用tearDown()方法:释放fixture
调用setUp()方法:初始化fixture
调用第二个Test Case函数
调用tearDown()方法:释放fixture
...
经过编码:
#include "DiskDataTestCase.h"CPPUNIT_TEST_SUITE_REGISTRATION(DiskDataTestCase); void DiskDataTestCase::setUp(){ fixture = new DiskData();}void DiskDataTestCase::tearDown(){ delete fixture; fixture = NULL;}void DiskDataTestCase::loadTest(){ // our load test logic}void DiskDataTestCase::storeTest(){ // our store test logic}
现在,编码已经变得非常简单了:setUp()和tearDown()实现了创建、释放fixture,下面要做的就是为loadTest()、storeTest()编码了。
Test Case编码
搞清楚需要测试那些方面之后的工作是编码实现。可以通过使用库函数、第三方库函数、Win32 API或者C/C++操作符和指令的内部属性。
有时需要辅助的文件或者数据库表来存储正确的数据。在本例中,通过对比内部不数据和外部文件的数据来判断结果是否正确。
当出现错误时(比如内部数据和外部数据不同),需要抛出异常。可以通过CPPUNIT_FAIL(message)宏实现,也可以通过assertions宏实现。
以下是一些常用的assertion宏:
CPPUNIT_ASSERT(condition): 检查condition,如为false,抛出异常
CPPUNIT_ASSERT_MESSAGE(message, condition): 检查condition,如为false,抛出异常,并显示预先设定的信息
CPPUNIT_ASSERT_EQUAL(expected,current): 检查expected与current的值是否相等,抛出异常,显示expected和current的值
CPPUNIT_ASSERT_EQUAL_MESSAGE(message,expected,current): 检查expected的值与actual的值是否相等,抛出异常,显示expected,current的值,并显示预先设定的信息
CPPUNIT_ASSERT_DOUBLES_EQUAL(expected,current,delta): 检查expected, current之差是否小于delta,如果不小于,显示expected和current的值
下面讲一下loadTest编码的编码构想:首先需要一个外部文件,其中存储这一个DATA型数据,文件的创建方式并不重要,关键是要保证里面的数据的正确性。然后,要进行的操作是检查load函数从外部文件中读出的数据和实现存在其中的数据是否一致。代码如下:
//// 前提:外部文件中已存储了正确的数据。//#define AUX_FILENAME "ok_data.dat"#define FILE_NUMBER 19#define FILE_STRING "this is correct text stored in auxiliar file"void DiskDataTestCase::loadTest(){ // 相对路径转化为绝对路径 TCHAR absoluteFilename[MAX_PATH]; DWORD size = MAX_PATH; strcpy(absoluteFilename, AUX_FILENAME); CPPUNIT_ASSERT( RelativeToAbsolutePath(absoluteFilename, &size) ); // 执行操作 CPPUNIT_ASSERT( fixture->load(absoluteFilename) ); // 通过assertion检查运行结果 LPDATA loadedData = fixture->getData(); CPPUNIT_ASSERT(loadedData != NULL); CPPUNIT_ASSERT_EQUAL(FILE_NUMBER, loadedData->number); CPPUNIT_ASSERT( 0 == strcmp(FILE_STRING,fixture->getData()->string) );}
通过这样一个简单的Test Case测试了4个可能存在的错误:
load函数返回值
getData函数返回值
number结构的成员值
string结构的成员值
storeTest要复杂一些,因为需要把fixture中的数据存储到临时文件中,之后打开两个文件(新的临时文件和外部文件),读出数据并比照内容。代码如下:
void DiskDataTestCase::storeTest(){ DATA d; DWORD tmpSize, auxSize; BYTE *tmpBuff, *auxBuff; TCHAR absoluteFilename[MAX_PATH]; DWORD size = MAX_PATH; // 填充结构体 d.number = FILE_NUMBER; strcpy(d.string, FILE_STRING); // 相对路径转化为绝对路径 strcpy(absoluteFilename, AUX_FILENAME); CPPUNIT_ASSERT( RelativeToAbsolutePath(absoluteFilename, &size) ); // 执行操作 fixture->setData(&d); CPPUNIT_ASSERT( fixture->store("data.tmp") ); // 读出两文件的内容并对比 // ReadAllFileInMemory 是一个分配缓冲区的外部函数 // 把文件内容存入其中. 调用函数负责释放缓冲区. tmpSize = ReadAllFileInMemory("data.tmp", tmpBuff); auxSize = ReadAllFileInMemory(absoluteFilename, auxBuff); // 文件不存在则抛出异常 CPPUNIT_ASSERT_MESSAGE("New file doesn't exists?", tmpSize > 0); CPPUNIT_ASSERT_MESSAGE("Aux file doesn't exists?", auxSize > 0); // 文件大小可获得,否则抛出异常 CPPUNIT_ASSERT(tmpSize != 0xFFFFFFFF); CPPUNIT_ASSERT(auxSize != 0xFFFFFFFF); // 缓冲区必须可用,否则抛出异常 CPPUNIT_ASSERT(tmpBuff != NULL); CPPUNIT_ASSERT(auxBuff != NULL); // 两个文件的大小必须和DATA一致 CPPUNIT_ASSERT_EQUAL((DWORD) sizeof(DATA), tmpSize); CPPUNIT_ASSERT_EQUAL(auxSize, tmpSize); // 两文件的内容必须一致 CPPUNIT_ASSERT( 0 == memcmp(tmpBuff, auxBuff, sizeof(DATA)) ); delete [] tmpBuff; delete [] auxBuff; ::DeleteFile("data.tmp");}
启动用户界面
最后,看看如何显示基于MFC的用户界面对话框(事先在其内部编译了TestRunner.dll)。
打开实现类的文件(ProjectNameApp.cpp),把下列代码复制到InitInstance方法中:
#include#include BOOL CMy_TestsApp::InitInstance(){ .... // 声明Test Runner,用以注册的测试填入其中,并运行 CppUnit::MfcUi::TestRunner runner; runner.addTest( CppUnit::TestFactoryRegistry::getRegistry().makeTest() ); runner.run(); return TRUE;}
很简单,不是吗?只需要定义一个"runner"实例,添加注册过的test(test是通过CPP文件中的CPPUNIT_TEST_SUITE_REGISTRATION宏注册的),就可以运行run函数了。
编译、运行,开始你的单元测试吧:)