如何更好的实施单元测试的策略

发表于:2012-12-14来源:51CTO作者:李云点击数: 标签:单元测试
我在《单元测试实施解惑(一)》中指出,使用象Cmockery这样的测试框架,将所需测试的模块通过打桩的方法实施单元测试并不是最有效的方法。在这篇文章中,让我们一同来探索更好的方法。在继续探索之前,让我从传统单元测试开始引入所主张的方法。

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

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

  mpool.c void* mpool_buffer_alloc (mpool_handle_t _handle) { interrupt_level_t level; mpool_node_t *p_node; level = global_interrupt_disable (); if (is_invalid_handle (_handle)) { global_interrupt_enable (level); return null; } p_node = (mpool_node_t *)dll_pop_head (&_handle->free_buffer_); if (0 == p_node) { _handle->stats_nobuf_ ++; global_interrupt_enable (level); return null; } global_interrupt_enable (level); p_node->in_use_ = true; return (void *)p_node->addr_; } dll.c dll_node_t *dll_pop_head (dll_t *_p_dll) { dll_node_t *p_node = _p_dll->head_; if (p_node != 0) { _p_dll->count_--; _p_dll->head_ = p_node->next_; if (0 == _p_dll->head_) { _p_dll->tail_ = 0; } else { p_node->next_->prev_ = 0; } p_node->next_ = 0; p_node->prev_ = 0; } return p_node; }

  图1

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

  stub_dll.c dll_node_t *g_p_node; dll_node_t *dll_pop_head (dll_t *_p_dll) { return g_p_node; } test_mpool.c void test_mpool_buffer_alloc () { mpool_node_t mnode; // set up test environment mnode.addr_ = 0x5A5A5A5A; mnode.in_use_ = false; // do test g_p_node = &mnode.node_; UNITEST_EQUALS (mpool_buffer_alloc (handle), 0x5A5A5A5A); g_p_node = 0; UNITEST_EQUALS (mpool_buffer_alloc (handle), 0); }

  图2

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

  stub_dll.c dll_node_t *dll_pop_head (dll_t *_p_dll) { return (dll_node_t *)mock (); } test_mpool.c void test_mpool_buffer_alloc () { mpool_node_t mnode; // set up test environment mnode.addr_ = 0x5A5A5A5A; mnode.in_use_ = false; // do test will_return (dll_pop_head, &mnode.node_); assert_int_equal (mpool_buffer_alloc (handler), 0x5A5A5A5A); will_return (dll_pop_head, 0); assert_int_equal (mpool_buffer_alloc (handler), 0); }

  图3

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

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

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

  dll.c dll_node_t *dll_pop_head (dll_t *_p_dll) { dll_node_t *p_node = _p_dll->head_; #ifdef UNIT_TESTING { dll_node_t *p_node; error_t ecode = injected_error_get ( INJECTION_POINT_DLL_POP_HEAD, &p_node); if (ecode != 0) { return p_node; } } #endif if (p_node != 0) { _p_dll->count_--; _p_dll->head_ = p_node->next_; if (0 == _p_dll->head_) { _p_dll->tail_ = 0; } else { p_node->next_->prev_ = 0; } p_node->next_ = 0; p_node->prev_ = 0; } return p_node; }

  图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示例了新的单元测试程序是如何制造一个错误的。

原文转自:http://www.ltesting.net