1. UT框架

由于是面向C++的UT,所以我使用的是Catch

2. 给待测类预留seam

一个类,它的终端用户有两个:一个是这个类的使用者(例如别的模块);另一个就是测试代码。想要使这个类变得可测试,在设计该类的时候就应该预留一定的“缝隙”,我们称之为”seam”.

2.1 使用stub对象替换待测类中interface类的对象

这种类型的seam通常会在待测类(FooBar)中定义一个组合或聚类的,该接口类的成员变量。该成员变量的作用是,这个FooBar类的某个API会委托该成员变量进行调用。例如:

class FooBar
{
    private:
        Interface *m_obj;  // 以某种方式传入
        ...
    public:
        bool someAPI()
        {
            return m_obj->callSomething();
        }
};

由于这个m_obj是在正式的代码中是某个实现了Interface的类(RealInterface)的实例;而在测试代码中,我们需要以某种方式将实现了Interface的stub类(FakeInterface)的实例作为m_obj传入FooBar。大致的方法描述如下。

2.1.1 构造函数中传入

class FooBar
{
    private:
        Interface *m_obj;

    public:
        FooBar(Interface *obj)
        {
            m_obj = obj;
        }
        ...
};

2.1.2 通过set函数传入

class FooBar
{
    private:
        Interface *m_obj;

    public:
        void setObj(Interface *obj) 
        {
            m_obj = obj;
        }
        ...
};

2.1.3 通过factory函数传入

class FooBar
{
    private:
        Interface *m_obj;

    public:
        FooBar(){
            m_obj = ObjFactory.create();
        }
};

class ObjFactory
{
    private:
        Interface m_custom_obj;

    public:
        void setObj(Interface *obj)
        {
            m_custom_obj = obj;
        }

        Interface *create()
        {
            if (!m_custom_obj)
                return new RealInterface;
            else
                return m_custom_obj;
        }
};

2.2 继承父类(待测类)并重载其virtual函数(extract and override)

这种方法适用于模拟对待测类的输入(如果要模拟测类的对象与其他对象的交互,则要使用mock)。

例如,待测类的某个API(A)调用过程中会直接或者间接地调用自己的另一个API(B),那么可以通将B声明为virtual的方法,并从这个类继承出子类,在子类中重载B,其他方法保持不变。在测试代码中测试子类。

举个A直接调用B的例子:

class FooBar
{
    private:
        bool B();

    public:
        bool A(){
            return B();
        }
};

应该将其修改如下:

class FooBar
{
    protected:                      // change to "protected" so that child class has access
        virtual bool B()            // add "virtual" decorator so that child could override it
        {
            // do something and return
        }

    public:
        bool A(){
            return B();
        }
        
};

class Derived: public Foobar
{
    public:
        bool m_result;

    protected:
        virtual bool B()            // override FooBar::B via Derived::B
        {
            return m_result;
        }
}

对于A间接调用B也是类似。

2.2.1 实践技巧

对于任意类Base,首先通过编译宏,将其所有成员变量和函数设置为public。然后,对所有函数设置为virtual。这样,这个类就是完全公开的了。

从该类继承得到子类Derived,在子类中可以重载Base中的所有函数。如果Derived被用于mock对象,那么对于每一个函数,都可以设置两个成员变量:

  1. 用于判断该函数是否要被mock,如果是的话执行自定义的代码段(用于之后的assert);否则,执行Base中的实际代码
  2. 用于记录mock对象的交互情况,包括传入参数,被调用次数等

3. State-based Testing VS Interaction Testing

测试一个类的某个API有几种情况:

  1. 不同条件下调用该API使得待测试类的内部状态变化
  2. 不同条件下调用该API得到不同的返回数据(包括返回值和返回参数)
  3. 不同条件下调用该API,会以不同的方式与另一个对象进行交互

其中,前两个是属于状态的测试;第三个是属于交互的测试。

Definition: Interaction testing is testing how an object sends input to or receives input from other objects—how that object interacts with other objects.

举个例子,例如一个果园中有一个灌溉系统,用户可以向该系统设置何时浇树,包括:每天浇几次,每次浇多少。

3.1 Mock

Definition: A stub object is a fake object in the system that simulate various situations, but will never fail a test.

Definition: A mock object is a fake object in the system that decides whether the unit test has passed or failed. It does so by verifying whether the object under test interacted as expected with the fake object. There’s usually no more than one mock per test.

在上面提到的灌溉系统中,那个专门的设备(例如一个有传感器的水软管)就是一个mock.

Mock和Stub的一大区别在于:

对于待测类的某个test case,成功与否的判断对象是:

  1. 待测类 那么,该待测类的依赖对象是一个stub
  2. 依赖对象 那么,该待测类的依赖对象是一个mock

3.2 同时使用mock和stub

有很多测试case中的依赖不止一个,此时需要创建多个fake对象。特别地,如果要测试交互状况,则需要mock对象。对于一个test case,应该最多使用一个mock对象。否则,往往意味着你在一个test case中测试多个情况,这不利于测试代码的维护。

4. 封装C函数

在我们的项目中,代码是混搭了C和C++。这样,对于某个帶测试类所依赖的C函数,如果要创建Stub或者Mock就会要重新更改该函数。例如有个帶测试method中调用了read(2)系统函数,一般有两种做法:

  1. read的对象(某个fd)stub out
  2. read函数重定义(记得函数声明前后加上extern C

其中的第二种方法会导致一些问题。例如我们项目中使用了Bullseye,一款统计UT覆盖率的工具。它会在UT代码编译的过程中加入一个中间层,获取并统计当前待测文件的函数的信息,记录在某个.cov文件中。之后,在执行UT可执行文件的时候,会将覆盖情况记录到刚才的文件中(调用open, read, write等函数)。如果此时这些系统函数被重写了,那么就会出错。

因此,比较好的做法是将C的函数以C++类的形式做一层单纯的封装,也即在内部仅仅是简单的接收参数,调用函数,传回返回值和参数,而没有任何逻辑。因此,对于这些封装类是不需要做UT的。这样做的好处在于,任何对这些C函数有依赖的待测类可以通过以上提到的各种insert stub/mock的方式(),在不更改系统函数的前提下,使用fake的对象。

5. 写更好的UT测试代码

对于UT代码,通过良好的组织和设计,可以变得更易读,易写,易维护。其中主要包括不仅限于:

  1. 在UT代码中使用继承重用代码,引导其他人写UT等
  2. 撰写UT实用类和方法
  3. Make your API known to developers

5.1 在UT中使用继承模式

以下介绍三种基本的模式:

同时,会介绍以下几种重构方式服务于以上几种模式:

5.1.1 抽象测试类的重用部分放入父类中

假设有以下这个简单的test case,其中有两个待测类: CFooCBar. 由于它们在代码中都会要打log,而为了去除log这层依赖,我们在每一个test case的setup函数中向logger的factory(LoggerFacility)类中传入stub logger(StubLogger)。

class ILogger
{
    public:
        virtual void log(const char*) = 0;
};

class StubLogger: public ILogger
{
    public:
        void log(const char *str)
        {
            return;
        }
};

class LoggerFacility
{
    private:
        static ILogger *m_logger;

    public:
        static void log(const char *str)
        {
            m_logger->log(str);
        }

        static void setLogger(ILogger *logger)
        {
            m_logger = logger;
        }
};

ILogger *LoggerFacility::m_logger = (ILogger*)0;

/* Class under test */

class CFoo
{
    public:
        void doThis(void)
        {
            LoggerFacility::log("CFoo::doThis: Begin");
            /* rest of code */
            LoggerFacility::log("CFoo::doThis: End");
        }
};

class CBar
{
    public:
        void doThat(void)
        {
            LoggerFacility::log("CBar::doThat: Begin");
            /* rest of code */
            LoggerFacility::log("CBar::doThat: End");
        }
};

/* Test Code */

class Test_CFoo
{
    public:
        /* Fixture (! duplicated )*/
        void setup()
        {
            ILogger *stubLogger = new StubLogger();
            LoggerFacility::setLogger(stubLogger);
        }

        /* Test Case */
        void Test_doThis()
        {
            CFoo *obj = new CFoo();
            obj->doThis();
            /* rest of test */
        }
};

class Test_CBar
{
    public:
        /* Fixture (! duplicated )*/
        void setup()
        {
            ILogger *stubLogger = new StubLogger();
            LoggerFacility::setLogger(stubLogger);
        }

        /* Test Case */
        void Test_doThat()
        {
            CBar *obj = new CBar();
            obj->doThat();
            /* rest of test */
        }
};

int main()
{
    {
        Test_CFoo *test_case = new Test_CFoo;
        test_case->setup();
        test_case->Test_doThis();
        delete test_case;
    }

    {
        Test_CBar *test_case = new Test_CBar;
        test_case->setup();
        test_case->Test_doThat();
        delete test_case;
    }
}

如果我们有比2个多的多的类似的要用到log的待测类中要做这个操作,我们是否需要对每一个待测类的setup中都做类似的操作呢?答案是否。

事实上,我们可以创建一个Test_Base类,在这个类中定义这个setup函数,其他的实际test类只要继承该父类即可。并且,这样也允许其他test子类重载该函数,加入额外的准备项。如下:

/* Test Code */

class Test_Base
{
    public:
        void setup()
        {
            ILogger *stubLogger = new StubLogger();
            LoggerFacility::setLogger(stubLogger);
        }
};

class Test_CFoo: public Test_Base
{
    public:
        /* Fixture */
        void setup()
        {
            Test_Base::setup(); // indicate that `setup` has been defined by base
        }
    ...
};


class Test_CBar: public Test_Base
{
    public:
        /* Fixture (! duplicated )*/
        void setup()
        {
            Test_Base::setup(); // indicate that `setup` has been defined by base
            /* addtionally, we can add some other special setups */
        }
    ...
};

这样子,可以增加代码的复用率。

5.1.2 使用测试接口基类将测试模板化

在实际项目中,一个接口类往往会对应多个实现。接口类中定义的方法所对应的测试case在不同的实现中有很大程度上是相近的。因此,可以对一个接口类定义一个对应的测试接口类,该测试类中的每一个测试method都是抽象函数,只定义了有哪些case,而将具体的测试实现交给不同的子类去实现。

这种方法的好处在于防止开发者在这个接口类的实现代码的UT时忘记某些case。

举个例子:有一个解析器的接口类,由这个类继承的实现类中所做的解析动作是一致的,只是接收来自不同的输入(例如:XML, IISLog, StandardString…)

5.1.3 使用测试抽象基类

这是对上面的方法的延伸,在接口类对应的测试类中把部分函数抽象化,另一些则有具体的实现。其中: