我在《单元测试实施解惑(一)》中指出,使用象Cmockery这样的测试框架,将所需测试的模块通过打桩的方法实施单元测试并不是最有效的方法。在这篇文章中,让我们一同来探索更好的方法。在继续探索之前,让我从传统单元测试开始引入所主张的方法。

图1中所示的分别是某内存池模块(mpool.c)和双向链表模块(dll.c)的代码片断,现在让我们聚焦于为内存池模块的mpool_buffer_alloc函数实施单元测试。由于该函数使用到了双向链表模块的dll_pop_head函数,因此,我们需要对dll_pop_head函数进行打桩。(注:实际上还得对global_interrupt_disable和global_interrupt_enable两函数打桩,但为了简化我们只以dll_pop_head为例)

  1. mpool.c

  2. void* mpool_buffer_alloc (mpool_handle_t _handle)

  3. {

  4.    interrupt_level_t level;

  5.    mpool_node_t *p_node;

  6.    level = global_interrupt_disable ();

  7.    if (is_invalid_handle (_handle)) {

  8.        global_interrupt_enable (level);

  9.        return null;

  10.    }

  11.    p_node = (mpool_node_t *)dll_pop_head (&_handle->free_buffer_);

  12.    if (0 == p_node) {

  13.        _handle->stats_nobuf_ ++;

  14.        global_interrupt_enable (level);

  15.        return null;

  16.    }

  17.    global_interrupt_enable (level);

  18.    p_node->in_use_ = true;

  19.    return (void *)p_node->addr_;

  20. }


  21. dll.c

  22. dll_node_t *dll_pop_head (dll_t *_p_dll)

  23. {

  24.    dll_node_t *p_node = _p_dll->head_;

  25.    if (p_node != 0) {

  26.        _p_dll->count_--;

  27.        _p_dll->head_ = p_node->next_;

  28.        if (0 == _p_dll->head_) {

  29.            _p_dll->tail_ = 0;

  30.        }

  31.        else {

  32.            p_node->next_->prev_ = 0;

  33.        }

  34.        p_node->next_ = 0;

  35.        p_node->prev_ = 0;

  36.    }

  37.    return p_node;

  38. }

图1

为了便于理解,图2示例了一个简化了的桩和mpool_buffer_alloc函数的测试用例。请注意,测试用例中的handle实参假设之前通过mpool_init函数所获得,图中同样为了简化并未列出。

  1. stub_dll.c

  2. dll_node_t *g_p_node;

  3. dll_node_t *dll_pop_head (dll_t *_p_dll)

  4. {

  5. return g_p_node;

  6. }

  7. test_mpool.c

  8. void test_mpool_buffer_alloc ()

  9. {

  10.    mpool_node_t mnode;

  11.    // set up test environment

  12.    mnode.addr_ = 0x5A5A5A5A;

  13.    mnode.in_use_ = false;

  14.    // do test

  15.    g_p_node = &mnode.node_;

  16.    UNITEST_EQUALS (mpool_buffer_alloc (handle), 0x5A5A5A5A);

  17.    g_p_node = 0;

  18.    UNITEST_EQUALS (mpool_buffer_alloc (handle), 0);

  19. }

图2

对于熟悉Cmockery的读者,图3所示的桩函数和测试用例或许看起来更有感觉。

  1. stub_dll.c

  2. dll_node_t *dll_pop_head (dll_t *_p_dll)

  3. {

  4.    return (dll_node_t *)mock ();

  5. }

  6. test_mpool.c

  7. void test_mpool_buffer_alloc ()

  8. {

  9.    mpool_node_t mnode;

  10.    // set up test environment

  11.    mnode.addr_ = 0x5A5A5A5A;

  12.    mnode.in_use_ = false;

  13.    // do test

  14.    will_return (dll_pop_head, &mnode.node_);

  15.    assert_int_equal (mpool_buffer_alloc (handler), 0x5A5A5A5A);

  16.    will_return (dll_pop_head, 0);

  17.    assert_int_equal (mpool_buffer_alloc (handler), 0);

  18. }

图3

需要指出的是,通过打桩的方式,既可以完成状态检验(State Verification),也可以完成行为检验(Behavior Verification),这完全取决于桩函数的实现(本文的示例是状态检验)。关于状态检验与行为检验更为详细的内容,请参见Martin Fowler的《Mocks aren’t Stubs》。

对于没有单元测试经验的读者来说,这里的示例会让你对单元测试有一定的了解。而对于有单元测试经验的读者来说,一定会想到采用打桩的方式所带来的实施困境。第一,桩函数对被替换函数的行为模拟越接近,单元测试的效果就越好,但所花费的成本开销也越大。极端情况下,会发现桩代码与桩所替换的代码在规模上是相当的。在产品的按时交付压力之下,实施单元测试所造成的软件规模增大很难让团队做到真心拥抱单元测试。第二,当项目规模增大以后,维护单元测试的桩函数并不是一件简单的事情。项目规模的增大,易造成各个子团队维护重复的桩代码。即使整个项目有着很好的规划,将所有的桩都以库的形式进行集中维护,但单元测试代码的编译、桩代码与项目代码的同步维护仍需相当可观的工作量。要走出这两大困境,需要我们就单元测试做一点小小的观念转变 — 放弃打桩。

想一想,为什么不将桩与其所替代的项目代码整合在一起,从而省去打桩呢?此时,单元测试的实施需要用到我在《专业嵌入式软件开发》一书中所提出的错误注入的方法。大体上,错误注入的思想与前面图2中实现单元测试的方法几乎一样,但是将桩函数的代码与所替换的产品代码进行了合并。图4是引入错误注入概念之后dll_pop_head函数的实现。

  1. dll.c

  2. dll_node_t *dll_pop_head (dll_t *_p_dll)

  3. {

  4.    dll_node_t *p_node = _p_dll->head_;

  5. #ifdef UNIT_TESTING

  6.    {

  7.        dll_node_t *p_node;

  8.        error_t ecode = injected_error_get (

  9.            INJECTION_POINT_DLL_POP_HEAD, &p_node);

  10.        if (ecode != 0) {

  11.            return p_node;

  12.        }

  13.    }

  14. #endif

  15.    if (p_node != 0) {

  16.        _p_dll->count_--;

  17.        _p_dll->head_ = p_node->next_;

  18.        if (0 == _p_dll->head_) {

  19.            _p_dll->tail_ = 0;

  20.        }

  21.        else {

  22.            p_node->next_->prev_ = 0;

  23.        }

  24.        p_node->next_ = 0;

  25.        p_node->prev_ = 0;

  26.    }

  27.    return p_node;

  28. }

图4

从图中可以看出,在产品代码中我们嵌入了一段用于单元测试的代码,且通过UNIT_TESTING宏对这段代码的存在与否进行控制。读者可以认为这段代码与桩函数中的代码功能相似,但最终达到的效果却有很大的不同。

首先,UNIT_TESTING所控制的这段代码存在一个错误注入点,这个点以INJECTION_ POINT_DLL_POP_HEAD加以标识。从代码可以看出,该段代码先调用injected_error_get函数获取外部所注入的错误及数据。当外部没有错误注入时, dll_pop_head函数的功能与真正的产品代码是没有任何区别的(但多了一次对injected_error_get函数的调用),这相当于省去了我们在桩函数中编写dll_pop_head函数返回不为null的代码。

单元测试最难的部分是制造异常情形,比如让dll_pop_head函数返回null就是我们测试mpool_buffer_alloc函数所需人为制造的。图5示例了新的单元测试程序是如何制造一个错误的。

  1. test_mpool.c

  2. void test_mpool_buffer_alloc ()

  3. {

  4.    UNITEST_DIFFERS (mpool_buffer_alloc (handle), 0);

  5.    error_inject (INJECTION_POINT_DLL_POP_HEAD,

  6.        ERROR_T (ERROR_DLL_OUT_OF_NODE), null);

  7.    UNITEST_EQUALS (mpool_buffer_alloc (handle), 0);

  8.    error_inject (INJECTION_POINT_DLL_POP_HEAD, 0, null);

  9. }

图5

在这个新的单元测试程序中,我们不需为正常情形的测试做什么工作(这里做了一定形式的简化,实际的单元测试仍需要我们就正常情形进行更为细致的检验),只是让dll_pop_head函数正常工作,返回不为null的节点就行了。要让dll_pop_head函数返回null的话,人为地向INJECTION_POINT_DLL_POP_HEAD注入点通过error_inject函数注入错误。error_inject函数的第一个参数是注入点;第二个参数是所注入的错误码(我们约定0表示没有错误),该码作为调用injected_error_get函数的返回值;第三个参数是所需注入的数据,该数据通过injected_error_get函数的第二个参数返回。

其次,在产品代码中由UNIT_TESTING宏所控制的代码并不需要有固定的格式,可以根据需要对错误点的行为进行定义。比如,可能对链表模块进行错误注入时,我们只希望影响链表1的行为,而不想对链表2有任何影响,这样的话可以将希望影响链表的指针注入到错误点中(此时需要定义一个数据结构,以便error_inject函数能传递多个参数),当然dll_pop_head函数中由UNIT_TESTING所控制的代码也得做相应的调整,使其在返回null前检查当前的链表指针(即_p_dll参数)与所注入的指针是否相同。

为了能对象malloc这样的函数也增加错误注入点,我们需要对之进行封装。比如,提供osal_malloc函数,并在其中增加错误注入点所需的代码。采用这种方法的结果是,我们需要很薄的一个平台层。是否是跨平台完全取决于项目的特点。打个比方,对于基于VxWorks实时操作系统的项目,如果平台的编译开发是在Linux主机上的话,则所提供的平台层应实现跨VxWorks和Linux操作系统。千万不要忘了,此时单元测试应在Linux主机上完成,而非VxWorks上。而对于一个构建Linux应用软件的项目来说,如果开发也是在Linux主机上完成的话,平台层就根本不需要考虑跨平台问题。

引入平台层之后,读者或许会有两个担心:一是,跨平台库的开发需要大量的时间;二是,跨平台的代码又如何做单元测试?对于第一个担心,我的解释是:项目所使用的操作系统或C库的系统函数具有极强的收敛性,数目很有限,与传统单元测试所采用打桩的方式相比,我相信构建这样的平台层的工作量要小很多。至于第二个问题,我的建议是:由于平台层做得很薄,功能简单,我们可以放弃对之做单元测试。此时,单元测试的焦点应放在构建于平台之上的软件模块上。

至此,错误注入方式的单元测试方法介绍完了。或许还有读者会问,这又不打桩,也将错误点处理代码嵌入在产品代码中,这还是单元测试吗?实际上,单元测试的目的是为了让被测代码的各行和各分支能运行到以确保质量,只要达到这一目的,是否打桩或在产品代码中嵌入测试代码并非介定是不是单元测试的关键条件。我们应时刻记住,开发活动中的行为应是价值驱动的,而非形式驱动的。一个没有价值的行式,注定是浪费,也一定会束缚思想让我们在困境中难以找到突破的途径。价值驱动的观点往往会让我们抛开束缚,获得意想不到的效果。


最后,整理一下以错误注入这一方法实施单元测试的特点:
1)引入很薄的平台层。在平台层中增加错误注入点处理代码,以便更好地对构建于之上的软件模块实施单元测试。时否跨平台取决于项目,但放弃对这一平台层进行单元测试。
2)构建于平台之上的各软件模块,在合适的地方也增加错误注入点处理代码,以方便对其他依赖于它的模块实施单元测试。
3)由于不需要额外的桩代码,所以代码的维护开销更低,且天然具备复用的特点。
4)由于产品代码与测试代码进行了一定的合并,更容易做到“无缝整合”(参见《单元测试实施解惑(一)》)。

单元测试实施解惑(二)— 别“桩”了!_错误注入