本文详细探索一下 TDD(test-driven) 开发模型.
单元测试的编写也不是一件容易的事情, 除非使用 TDD 方式,否则编写出容易测试的代码,
不但对开发人员的设计编码要求很高, 而且代码中的各种依赖也常常为单元测试带来无穷无尽的障碍.
测试驱动开发模式不是一种新技术, 而是一个趋势; 它强调生成源代码之前测试用例的设计(敏捷过程模型也这样强调).
Kent Beck
先生最早在其极限编程(XP)方法论中,向大家推荐“测试驱动”这一最佳实践,还专门撰写了《测试驱动开发》一书,详细说明如何实现。经过几年的迅猛发展,测试驱动开发已经成长为一门独立的软件开发技术,其名气甚至盖过了极限编程。
快速概览
TDD的流程非常简单, 每次在引入代码之前, 先设计测试用例, 然后写下代码顺应测试用例; 之后每次一小段的增量开发, 直到构建完成. 每次发现测试失败, 那么以前相关的旧代码都要在回归测试中重新测试(避免新代码在旧代码中产生副作用).
测试驱动是一种软件开发模式:
- 首先编写测试用例(快速新增一个测试)
- 除非存在相关的测试用例, 否则不编写代码
- 由测试内容决定编写代码内容
- 要求维护一套完备的测试用例(集)
大概的流程图如下:
简单来说, 就是 不可运行-可运行-重构, 或者红灯 -> 绿灯 -> 重构
, 这正是测试驱动开发的口号.
通过测试来推动整个开发的进行, 其中编码的时候, 只编写能通过当前测试的功能代码. 下面是其优点综述:
- TDD根据客户需求编写测试用例,对功能的过程和接口都进行了设计,而且这种从使用者角度对代码进行的设计通常更符合后期开发的需求。因为关注用户反馈,可以及时响应需求变更,同时因为从使用者角度出发的简单设计,也可以更快地适应变化。(从使用者角度)
- 出于易测试和测试独立性的要求,将促使我们实现松耦合的设计,并更多地依赖于接口而非具体的类,提高系统的可扩展性和抗变性。而且TDD明显地缩短了设计决策的反馈循环,使我们几秒或几分钟之内就能获得反馈。(促使我们松耦合涉及)
- 将测试工作提到编码之前,并频繁地运行所有测试,可以尽量地避免和尽早地发现错误,极大地降低了后续测试及修复的成本,提高了代码的质量。在测试的保护下,不断重构代码,以消除重复设计,优化设计结构,提高了代码的重用性,从而提高了软件产品的质量。(及早发现错误, 急躁更正)
- TDD提供了持续的回归测试,使我们拥有重构的勇气,因为代码的改动导致系统其他部分产生任何异常,测试都会立刻通知我们。完整的测试会帮助我们持续地跟踪整个系统的状态,因此我们就不需要担心会产生什么不可预知的副作用了。(持续全局测试)
- TDD所产生的单元测试代码就是最完美的开发者文档,它们展示了所有的API该如何使用以及是如何运作的,而且它们与工作代码保持同步,永远是最新的
- TDD可以减轻压力、降低忧虑、提高我们对代码的信心、使我们拥有重构的勇气,这些都是快乐工作的重要前提
- 这种开发流程有助于编写简洁可用和高质量的代码,并加速开发过程.(快速的提高了开发效率)
一个生动的比喻:
盖房子的时候,工人师傅砌墙,会先用桩子拉上线,以使砖能够垒的笔直,因为垒砖的时候都是以这根线为基准的。TDD就像这样,先写测试代码,就像工人师傅先用桩子拉上线,然后编码的时候以此为基准,
只编写符合这个测试的功能代码
。而一个新手或菜鸟级的小师傅,却可能不知道拉线,而是直接把砖往上垒,垒了一些之后再看是否笔直,这时候可能会用一根线,量一下砌好的墙是否笔直,如果不直再进行校正,敲敲打打。使用传统的软件开发过程就像这样,我们先编码,编码完成之后才写测试程序,以此检验已写的代码是否正确,如果有错误再一点点修改。
你是希望先砌墙再拉线,还是希望先拉线再砌墙呢?如果你喜欢前者,那就算了,而如果你喜欢后者,那就转入TDD阵营吧!
补充:
有些人认为TDD入门难, 原因是:在还没项目代码之前写测试用例,那就是等于我要凭空想象那些个抽象又晦涩难懂的功能点,还得要在心中勾勒出它们的轮廓以及细节. 但是问题是, 所有的用例都是根据需求设计而来的, 也就是说,前期设计还是要有的, 只是代码实现这个环节, 采用了测试用例优先的原则.
相关技术
TDD开发中, 根据业务的不同, 编程语言的不同, 所使用的相关技术可能也会有一些不同, 但是有几样是相同的.
工作流:
首先以一个未能通过的测试开始,随后编写足以通过该测试的代码,然后再重构代码。当然我们都不愿意看到不能通过的测试CASE,当你再继续编写项目代码,让原本不能通过的测试CASE通过的时候,你会感觉心里有一丝丝的惬意,然后再将代码优化重构,瞬间又有了些成就感。抿一口水,工作就这么快乐的完成了。
模拟对象:
先写测试用例, 给出接口调用流程, 那么在这里伪对象是必须的(特别是在模块间有依赖的时候, 比如分层模型); 根据具体的编程语言, 可能出现依赖注入框(DI/IOC)与模拟框架(Mock) 等技术.
Mock对象测试框架:
合作任务量一旦比较大的时候, 可能会频繁的需要模拟对象, 这个时候, 怎么办? 手工创建? 每个对象内部需要不一样, 哪有什么通用格式创建伪对象? 更麻烦的是伪对象之间有依赖关系怎么办?对象内部状态中没保存,怎么维护?
这个时候, 显然你需要一个框架, 单元测试框架, Mock框架.
单元&回归测试框架:
测试用例跑过了, 不代表代码就写好了; 有时候代码质量不高, 肯定会被上级要求重构的, 这个时候, 好不容易重构好了, 又担心引入了新问题, 这个时候又要重新跑测试用例, 并且不仅仅是跑当前的, 以前的要一起跑(整体测试), 这个时候就需要一个回归测试框架.
注: 有时候, 例如 Gtest 测试工具(框架)就包含了单元测试, 回归测试, Mock对象等功能.
入门实践
结合Gtest, 尝试一下 TDD 开发的妙处和代价.
TDD要保证代码刚刚好适量, 仅为当前测试用例编写代码, 也就是说, 先给出简明扼要的对象调用接口, 具体的实现放在后面, 等实现写出来再测试一下. 这样一来即使相互依赖的模块也不必再等对方完成之后, 在开始自己的模块开发, 因为可以直接在测试用例中使用Mock对象了(当然项目组成员之间使用IOC或者其他解耦合技术也是可以的).
本案例, 需要你有 Google Test 基础(没有的话, 直接看我怎么操作, 但实践不了) .
假如要写一个链式存储的栈, 简称链栈
(LinkStack), 采用TDD的形式;
既然是TDD, 那么肯定就不是先去写实现, 而是
根据功能设计并编写测试用例
- 使测试用例通过
- 再编写下一个测试用例
- 使原来所有的测试用例通过(没有通过则进行调整, 重构代码)
- 往返循环, 直至所有的功能都经过测试用例的测试.
由于是简单实践TDD, 我每一个功能点只写一个:
- 初始化(根据已经存在的数组, 进行初始化)
- 打印stack元素
- 获取stack元素个数
- 入栈新元素
- 出栈元素
- 获得栈顶元素(但是不出站)
- 清空栈元素
好了功能点大致概括了, 现在开始建立工程, 写测试用例, 注意是一个功能一个功能的写(而不是一股脑), 通过一个才进行下一个.
(方便起见, 我还是在Linux上写)
整个测试过程, 都需要完备的数组用于初始化, 所以先写TestSuite事件: (上来就初始化多个不同类型的数组, 并且所有测试都可以用)
linkstack_unittest.cpp
1 |
|
编译(生成可执行文件run_test)运行一下:
1 | $ ./run_test |
环境OK, 之后开始测试初始化, 在 linkstack_unittest.cpp
添加一个宏即可(但不要去编译, 因为一定通不过):
1 | TEST(LINK_STACK_TEST, INIT) |
(其实这里可以用Mock对象)
此时先去给出一个头文件(实现空方法), 为了下面编译:
1 | //link_stack.hpp |
然后包含该头文件, 测试一下:
1 | //linkstack_unittest.cpp |
运行一下:
1 | [==========] Running 1 test from 1 test case. |
然后把这个 构造函数, 写完在测试一下:
1 | LinkStack(const Type (&array)[3]) |
跑一下, 还是通过, 说明初始化的时候没有出错;
但是其实是看不出初始化是否正确的; 在测试一个打印函数吧:
1 | template<typename Type> |
由于当前功能实在太少, 所以当前测试改成这样:
1 | TEST_F(LINK_STACK_TEST, INIT_PRINT) |
编译运行之后:1
2
3
4
5
6
7
8
9
10
11
12
13
14[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from LINK_STACK_TEST
Test Suite Start
[ RUN ] LINK_STACK_TEST.INIT_PRINT
3--> 2--> 1;
c--> b--> a;
[ OK ] LINK_STACK_TEST.INIT_PRINT (0 ms)
Test Suite End
[----------] 1 test from LINK_STACK_TEST (0 ms total)
[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (0 ms total)
[ PASSED ] 1 test.
之后其他功能的添加, 也和该步骤类似, 下面就省略了.
给出一份完整参考代码:
1 | //link_stack.hpp |
参考资料
列举如下:
- 软件工程-实践者的研究方法 7e 第五部分, 有一小节专门讲这个
- Introduction to TDD 作为敏捷的一部分讲的, 排版很糟糕(写的很乱)
- 测试驱动开发实践 作者也是转载的, 还不错, 给出的案例也还行.
- Test-Driven-Development kent Beck 2003