软件开发>杂文>6

共享软件开发的点滴心得

[中文][English][日本語]

[机器人制作] [软件开发] [豆知识] [Squeak] [关于]

 Behind unit test

蚯蚓

刘新宇

2006年五月

 

过去学语文 时,有一篇荀子的《劝学》,其中说:“蚓无爪牙之利,筋骨之强,上食埃土,下饮黄泉,用心一也。蟹六跪而二螯,非蛇蟮之穴无可寄托者,用心躁也。”,意思 是说虽然蚯蚓光秃秃的既没有强劲的爪子,也没有尖利的牙齿,但是专心集中于一点,滴水石穿。反而有着两个钳子做武器,六条腿横行的螃蟹,却只能寄居于别的 动物的洞里,原因就是心浮气躁。

 

最近数年提倡的敏捷软件开发(Agile software developing),就有点像蚯蚓这种精神。不求一蹴而就,而是一步一步,稳健前进。 保证这种不断前进的重要一环就是测试驱动开发(TDD)[1]。什么叫做保证呢?记得上高中时,老师讲“数学归纳法”。一个人怎么保证能够爬上一座高楼?首先他要站在一层上,然后只要他能够确定,在任何时候他都能“更上一层楼”,就一定能够爬上去。

 

人们长期以来,就是尝试着一步一步,用最朴素的方法,不断改进最终得到能够不断“更上一层楼”能力。这里也来模仿这一过程,并尝试解释介绍一下测试框架。第一个问题是我们怎么知道我们是对的?答案是测试一下,下面是一个很朴素的测试:

 

#include <iostream>

#include <sstream>

 

class MyNumber{

public:

     MyNumber(int x):value_(x){}

     MyNumber(const MyNumber& x):value_(x.value_){};

     std::string asString(){

          std::stringstream ss;

          ss<<value_;

          return ss.str();

     }

private:

     int value_;

};

int main(int argc, char* argv[]){

     MyNumber three(3);

     if(three.asString() == "3")

          std::cout<<"OK\n";

     else

          std::cout<<"Failed\n";

}

为了验证MyNumber的正确性,写一个含有测试代码的main是最直观的了。唯一有些麻烦的是if-else,随着逐渐向MyNumber添加功能,比如add, subtract, divide等等,if-else会越来越多。于是可以迈出改进的第一步:

 

void assertTrue(bool exp){

     if(exp)

          std::cout<<"OK\n";

     else

          std::cout<<"Failed\n";

}

然后:

int main(int argc, char* argv[]){

     MyNumber three(3);

     assertTrue(three.asString() == "3");

MyNumber two(2);

     MyNumber res=three+two;

     assertTrue(res.asString() == "5");

}

现在看起来一切正常,如同蚯蚓找到了一块松软的土壤,然后准备深入下去。目前的问题是,所有的测试都集中在main中,如果除了打算测试MyNumber的正确性之外,还想再测试新加入的MyTime,那么就只好在main中再加入针对MyTime的测试代码,随着需要测试的内容越来越多,main会越来越长,最终变成复杂的“六跪而二螯”而失去控制。所以下一步的改进是:仍然尽量保持蚯蚓般的简单和朴素。现在把针对MyNumber的测试代码提取出并组织起来[1]

 

struct TestMyNumber{

     static void test(){

          MyNumber three(3);

          assertTrue(three.asString() == "3");

          // other test scripts...

     }

};

这样main函数就简单多了:

int main(int argc, char* argv[]){

     TestMyNumber::test();  

}

此后可以方便的添加针对MyTime的测试:

struct TestMyTime{

     static void test(){

          MyTime tm1(9, 30, 12);

          assertTrue(tm1.asString()=="9:30:12");

          //other test scripts...

     }

};

然后这个测试可以在main中很容易的加入,仅仅需要一行:

int main(int argc, char* argv[]){

     TestMyNumber::test();

     TestMyTime::test();

}

 

接下来就可以准备再次“更上一层楼”了。首先分析一下哪里有不足,哪里可以改进。假设一个人按照上面的方法,又增加了针对20几个类的测试,针对每个类MyClassXxx,都有一个相应的模块TestMyClassXxx。在C++中,这个模块可能是个h文件或者还有cpp文件。那么相应的main所在的cpp文件中,就会有一长串的#include

#include "mytime.h"

#include "mydate.h"

...

...

...

#include "xxx.h"

#include "yyy.h"

 

并且,main函数还会变得冗长而重复:

int main(int argc, char* argv[]){

     TestMyNumber::test();

     TestMyTime::test();

     TestMyDate::test();

     //...

//...

//...

     TestMyXxx::test();

     TestMyYyy::test();

}

这说明main所在的主程序和所有的Test耦合。下面“这条蚯蚓”打算再前进一小步,并保持自己简单的特色。首先注意到所有的Test都是非常近似的一个形式:

struct TestMyClassName{

     static void test(){

          //test scripts...

     }

};

所以这里可以把他们抽象出来,从而引出一个概念:TestCase。一个TestCase是针对某一类具体测试的组织单元,其可以包含若干有组织的TestScripts,通常一个TestCase对应一个需要测试的Class。既然抽象出了TestCase的概念,就可以利用C++的抽象描述来定义这个概念了:

class TestCase{

public:

     virtual void test()=0;

};

 

然后每一个具体的测试,就可以从这个TestCase继承,例如:

class TestMyNumber: public TestCase{

public:

     void test(){

          MyNumber three(3);

          assertTrue(three.asString() == "3");

          //other test scripts ...

};

既然所有的TestCase有了统一的抽象接口,接下来就可以着手解决冗长的main函数和众多的#include这些问题了。这里的思路是把这20几个TestCase统一管理起来——比如利用数组。那么这个数组必须能够被这所有的Concrete TestCase看到,从而每个TestCase都可以把自己加入进去,而不必在一个位置集中大量添加(main现在就是这种集中大量使用TestCase)。一个自然的思路是“全局变量”:

std::vector<TestCase*> tests;

然后在各个concrete test case模块里面:

tests.push_back(new TestMyNumber);

最后,主程序化简为:

int main(int argc, char* argv[]){

     for(std::vector<TestCase*>::iterator it=tests.begin();

          it!=tests.end(); ++it){

          (*it)->test();

          delete (*it);

     }

}

并且主程序只需要包含一个头文件:

#include "testcase.h"

看起来很好,但是有些问题,首先是tests.push_back(new TestMyNumber);这句话放在哪里合适?如果还放在主程序的cpp文件内,在tests定义之后,main之前。那么本质还是集中大量添加test case。如果放在mytime.cpp内,那么如何保证这句话得到运行?C++有全局函数的和全局变量的概念,但是没有全局语句的概念。

解决方案是引进singleton pattern[2]。大体如下:

class TestSuite{

public:

     static TestSuite& instance(){

          static TestSuite inst;

          return inst;

     }

     ~TestSuite(){

          for(std::vector<TestCase*>::iterator it=tests.begin();

              it!=tests.end(); ++it)

              delete (*it);

     }

     bool add(TestCase* test){ tests.push_back(test); return true; }

     void run(){

          for(std::vector<TestCase*>::iterator it=tests.begin();

              it!=tests.end(); ++it)

              (*it)->test();

     }

private:

     std::vector<TestCase*> tests;

};

现在在任何地方都可以通过TestSuite::instance()获得这个test case数组了。同时这里引入了Test Suite的概念。Test suitetest case的组织形式,一个test suite包含若干test case。如果简单认为test case是针对类的,那么test suite可以认为是针对“包”的。C++中不同于Java,没有明确的package的概念。可以简单的认为C++通过namespace来划分包的概念。

有了test suite,就可以在各个test casecpp文件中分别向test suite加入各自的test case了,例如:

const bool res=TestSuite::instance().add(new TestMyNumber);

这样通过对一个常量的初始化,完成了将test case加入test suite的动作,这也是为什么TestSuite::add函数会返回一个值的原因。如果希望这个res不污染全局,还可以利用一个匿名名字空间隐藏它:

namespace{

     const bool res=TestSuite::instance().add(new TestMyNumber);

}

现在主程序就非常简单了:

#include "testsuite.h"

 

int main(int argc, char* argv[]){

     TestSuite::instance().run();

}

此后,如果想继续添加任何TestCase,都可以独立增加,而原来的代码不需要做任何改动。增加的步骤大致如下:

  1. 增加一个类,从TestCase继承。
  2. 重载TestCasetest方法,加入特定的test scripts
  3. 调用TestSuite::instance().add把自己加入。

经过这样的不断改建,这条“蚯蚓”已经相当深入了。初步实现了一个简单的自动化测试框架。现在继续前进。看看还有哪些不足,可以改进的地方大致如下:

    • Assert现在太简单,对于异常行为缺乏捕获功能;
    • 测试结果比较粗糙,就是一组OKFail输出到屏幕。可以考虑生成一个详细的Log,并且加入统计数据,比如运行了多少个TestCase,成功了多少个,失败了多少个,哪条失败了等等。
    • 只能有且仅有一个TestSuite,也就是说只能测试一个包,可以考虑使用Composite pattern[2]TestSuite也组织起来。并且提供用户有选择的执行哪些测试的功能。
    • 所有的test script都在TestCase::test内,如果某个待测试类很复杂test可能会变得庞大而不可控制。可以考虑将test case内的test scripts分解并组织起来,形成一个个的testAaa()testBbb()单元,并且提供整个test case的初始化和释放功能,所有这些要自动化进行。

如果一步一步,稳健前进,把上面的功能全部实现,那么这个框架将会变成一个和xUnit[4]极为接近的一个测试框架。而每次这些改进的步骤,都是通过一个个的重构[3]手法,逐步实现的。

上面这四个功能中,前三个实现起来都不困难,读者尽可以自己动手试试看。而最后一个要求却值得仔细思考思考。不同于JavaPython等系统,C++实现reflection比较困难。什么是reflection呢?为什么这里要提及reflection呢?如果读者恰好使用过Java开发JUnit,就会记得,凡是TestCase中以test打头的成员函数,就会被自动调用,看起来就像魔术一样[5]。其原理就是reflectionJUnit框架把TestCase类的对象拿来,然后利用reflection枚举所有的公有成员函数,找到”test”开头的函数后,就调用它们,这段代码片断如下:

private boolean isTestMethod(Method m) {

     String name= m.getName();

     Class[] parameters= m.getParameterTypes();

     Class returnType= m.getReturnType();

     return parameters.length == 0 &&

name.startsWith("test") && returnType.equals(Void.TYPE);

}

然而C++RTTI还不能枚举对象中的成员和方法,也不具备通过字符串映射到类和方法的能力。因此可行的思路大致有如下3个:

  1. 在具体的Test Casetest()函数中,调用所有的testXxx函数。
  2. Test case这个层次中再次细分,建立具体testfunctortest case拥有functor的集合,每个test casetest()函数中,遍历这个functor集合,依次调用所有的functor
  3. 2个方法的简化版本,将test casetestXxx()testYyy()这样的成员函数的指针管理起来,形成集合,在test case中的test()函数中,通过这些指针,依次调用所有的具体测试。

第一个方法最直观,写起来大致如下:

class TestMyNumber: public TestCase{

public:

     void test(){

          testInit();

          testAdd();

          //...

     }

     void testInit(){

          MyNumber three(3);

          assertTrue(three.asString() == "3");

     }

     void testAdd(){

          MyNumber three(3);

          MyNumber two(2);

          MyNumber res=three+two;

          assertTrue(res.asString() == "5");

     }

     //...

};

但是缺点在哪里呢?任何一个testXxx出现问题,则test就会被中断。而不能一口气运行到结束。如果用异常机制解决这个问题的话,代码会变成:

void test(){

     try{

          testInit();

     }

     catch(...){

     }

     try{

          testAdd();

     }

     catch(...){

     }

     //...

    

}

进一步的问题是这种结构不便于进行统计,例如统计本testCase中有多少条test script成功了,有多少条失败了。成功率是多少。

第二个方法非常彻底。每个testXxx是一个functor,在test casetest()中,把这些functor放入一个容器中,然后遍历这个容器,运行每个functor。同时统计成功率。代码写起来大致如下:

首先定义一个functor的基类test script

struct TestScript{

     virtual void operator()() = 0;

};

然后test case维护一个test script的集合:

class TestCase{

public:

     virtual void test()=0;

     virtual ~TestCase(){};

     std::vector<TestScript*> scripts;

};

具体的testXxxtestYyy都以functor的形式出现:

struct TestAdd: public TestScript{

     void operator()(){

          MyNumber three(3);

          MyNumber two(2);

          MyNumber res=three+two;

          assertTrue(res.asString() == "5");

     }

};

然后在test casetest()函数中自动化管理并测试:

class TestMyNumber: public TestCase{

public:

     void test(){

          scripts.push_back(new TestInit);

          scripts.push_back(new TestAdd);

          //...

          for(vecot<TestScript*>::iterator it=scripts.begin();

it!=script.end();++it)

              (*it)();

     }

     //...

 

这个方法虽然完整,但是对于使用者来说就不够方便了,使用者除了要从TestCase继承自己的具体case外,还要写一个一个的test script类,重载operator()以实现functor,最后随着测试逐渐复杂,会出现类数目激增问题。

最后看看折衷的第3种方法,这也是CppUnit框架中实际采用的方法。为了管理test case中的以test开头的成员函数指针,先要引入一个TestCaller类:

template<class T>

class TestCaller: public TestCase{

public:

     typedef void (T::*TestMethod)();

     TestCaller(T* test, TestMethod func):testCase(test), func(func){} 

     void test(){

          (testCase->*func)();

     }

private:

     T* testCase;

     TestMethod func;

};

这个test caller,拥有一个类函数指针成员func,为了将来调用这个指针,test caller需要知道这个类的的一个实例,这个实例由构造函数传入,并且在test()函数中,针对这个实例调用其成员函数。并且TestCaller通过继承重用了TestCase的代码,这样就可以把Test Caller也纳入到test suite的管理之中。此后用户要做的事情就简单多了:

class TestMyNumber: public TestCase{

public:

     void test(){

          TestSuite suite;

          suite.add(new TestCaller<TestMyNumber>(this,

              &TestMyNumber::testInit));

          suite.add(new TestCaller<TestMyNumber>(this,

              &TestMyNumber::testAdd));

          //...

          suite.run();

     }

     void testInit(){

          MyNumber three(3);

          assertTrue(three.asString() == "3");

     }

     void testAdd(){

          MyNumber three(3);

          MyNumber two(2);

          MyNumber res=three+two;

          assertTrue(res.asString() == "5");

     }

     //...

};

可以看到,和方法1对比,只有test()函数中有些不同。用户使用起来的工作量不大,写一个一个的testXxxtestYyy函数,然后在test()中把他们组合成一个suite,最后利用suite提供的run功能,独立并且自动化的运行,并且suite这里还使用了RAII的方式在离开test()作用域时自动释放所有的test caller。所以这里的suite不是一个真正严格意义上的singleton[6],它没有把构造函数通过private限制隐藏起来,允许用户自己构造出其他实例。

作为本文的结尾,这里再前进一小步,考虑如何简化上述第3种方法的test()函数。写冗长而近似的代码:

suite.add(new TestCaller<TestMyCase>(this, &TestMyNumber::testXxx));

非常容易出错。用户在这里估计会大量使用Ctrl-CCtrl-V。在考虑了各种自动化方法后,最直观和有效的恐怕是使用宏。

testcase.h种定义一些helper macro:

#define TEST_SCRIPT_BEGIN(CaseType) \

     {\

          typedef CaseType _CaseType; \

          TestSuite suite;

#define TEST_SCRIPT_ADD(func)     \

     suite.add(new TestCaller<_CaseType>(this, &TestMyNumber::func));

#define TEST_SCRIPT_END() \

     suite.run(); \

     }

这样用户在写test()函数时就简化为:

class TestMyNumber: public TestCase{

public:

     void test(){

           TEST_SCRIPT_BEGIN(TestMyNumber)

           TEST_SCRIPT_ADD(testInit)

           TEST_SCRIPT_ADD(testAdd)

           //...

           TEST_SCRIPT_END()   

     }

CppUni就是采用了宏的方法。这里再给出一个有趣的会自杀的类的解法,读者可以思考比较一下他们的优劣。  

所谓“自杀”,就是一个对象自己把自己释放掉。查看C++FAQ,里面有一个问题是,我可以在一个类的成员函数中调用delete this么?答案是可以的,但是必须要非常小心,此后不可以再碰this指针了,否则就会出现问题。所以可以建立这样的一个类:

template<class T>

class TestScript{

public:

     typedef T _CaseType;

     static TestScript& begin(_CaseType* test){

          TestScript* self=new TestScript(test);

          return *self;

     }

     TestScript(_CaseType* test): testCase(test){};

     template<class MemFunc>

     TestScript& operator<<(MemFunc func){

          suite.add(new TestCaller<_CaseType>(testCase, func));

          return *this;

     }

     typedef void (*_Manipulator)(TestScript&);

     void operator<<(_Manipulator op){

          op(*this);

     }

     void suicide(){ delete this; }

     ~TestScript(){

          suite.run();

     }

private:

     TestSuite suite;

     _CaseType* testCase;

};

template<class T>

inline void end(TestScript<T>& self){

     self.suicide();

}

这个类的有趣之处在于,调用静态的begin则产生一个实例,此后可以不断对这个实例使用<<运算符加入新的test script,最后调用<<end则运行全部script并且销毁这个实例。

使用方法如下:

class TestMyNumber: public TestCase{

public:

     void test(){

          TestScript<TestMyNumber>::begin(this)

              <<&TestMyNumber::testInit

              <<&TestMyNumber::testAdd

              ...

              <<end;   

     }

至此,如同荀子《劝学》中蚯蚓的精神,从最简单朴素的起点出发,一步一步,“上食埃土,下饮黄泉”,逐渐得到了一个测试框架(Test Framework)

参考书目:

[1] Robot C. Martin, Agile software development: principles, patterns, and practice. Printice Hall. 2002.

[2] Erich Gamma, etc. Design pattern: Elements of Reusable Object-Oriented Software. Addison-Wesley.

[3] Martin Fowler, Kent Beck, etc. Refactoring: Improving the Design of Existing Code. Addison Wesley, June, 1999

[4] Kent Beck. Simple Smalltalk Testing: With Patterns. http://www.xprogramming.com/testfram.htm

[5] Kent Beck, Erich Gamma, JUnit Cookbook. http://junit.sourceforge.net/doc/cookbook/cookbook.htm

[6] Andrei Alexandrescu, Modern C++ Design: Generic Programming and Design Pattern Applied. Addison Wesley, Feb. 2001


[English][上一篇][下一篇][目录][主页][联系我们: liuxinyu95@gmail.com]