TDD:测试驱动开发(Test Driven Development),是通过测试定义所要开发的功能的接口,然后实现功能的开发过程。
TDD的基本思路通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析、设计、质量控制量化的过程。
TDD的原理在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。
TDD的目的不仅是测试软件,测试工作保证代码质量仅仅是其中一部分,而且是在开发过程中帮助客户和程序员去除模棱两可的需求。
测试驱动开发的基本过程1、 明确当前要完成的功能。可以记录成一个 TODO 列表。2、 快速完成针对此功能的测试用例编写。3、 测试代码编译不通过。4、 编写对应的功能代码。5、 测试通过。6、 对代码进行重构,并保证测试通过。7、 循环完成所有功能的开发。
测试驱动开发的原则
测试隔离。不同代码的测试应该相互隔离。对一块代码的测试只考虑此代码的测试,不要考虑其实现细节(比如它使用了其他类的边界条件)。
一顶帽子。开发人员开发过程中要做不同的工作,比如:编写测试代码、开发功能代码、对代码重构等。做不同的事,承担不同的角色。开发人员完成对应的工作时应该保持注意力集中在当前工作上,而不要过多的考虑其他方面的细节,保证头上只有一顶帽子。避免考虑无关细节过多,无谓地增加复杂度。
重点内容测试列表。需要测试的功能点很多。应该在任何阶段想添加功能需求问题时,把相关功能点加到测试列表中,然后继续手头工作。然后不断的完成对应的测试用例、功能代码、重构。一是避免疏漏,也避免干扰当前进行的工作。
测试驱动。这个比较核心。完成某个功能,某个类,首先编写测试代码,考虑其如何使用、如何测试。然后在对其进行设计、编码。先写断言。测试代码编写时,应该首先编写对功能代码的判断用的断言语句,然后编写相应的辅助语句。
可测试性。功能代码设计、开发时应该具有较强的可测试性。其实遵循比较好的设计原则的代码都具备较好的测试性。比如比较高的内聚性,尽量依赖于接口等。
及时重构。无论是功能代码还是测试代码,对结构不合理,重复的代码等情况,在测试通过后,及时进行重构。关于重构,我会另撰文详细分析。
小步前进。软件开发是个复杂性非常高的工作,开发过程中要考虑很多东西,包括代码的正确性、可扩展性、性能等等,很多问题都是因为复杂性太大导致的。
TDD首先考虑使用需求(对象、功能、过程、接口等),主要是编写测试用例框架对功能的过程和接口进行设计,而测试框架可以持续进行验证。
优点在任意一个开发节点都可以拿出一个可以使用,含有少量bug并具有一定功能的产品。
缺点增加代码量,测试代码是系统代码的两倍或更多。
01、前言
很早之前,曾在网络上见到过 TDD 这 3 个大写的英文字母,它是 Test Driven Development 这三个单词的缩写,也就是“测试驱动开发”的意思——听起来很不错的一种理念。
其理念主要是确保两件事:
但后来很长一段时间里,都没再听过 TDD 的消息。有人说,TDD 已经死了,给出的意见如下:
1)通常来说,开发人员不应该在没有失败的测试用例下编写代码——这似乎是合理的,但是它可能导致过度测试。例如,为了保证一行生产代码的正确性,你不由得写了 4 行测试代码,这意味着一旦这一行生产代码需要修改,你也得修改那 4 行测试代码。
2)为了遵循 TDD 而写的代码,容易进入一个误区:代码是为了满足测试用的,而忽略了实际需求。
02、TDD 到底是什么?
不管 TDD 到底死了没有,先让我们来回顾一下 TDD 到底是什么。
TDD 的基本思想就是在开发功能代码之前,先编写测试代码。也就是说在明确要开发某个功能后,首先思考如何对这个功能进行测试,并完成测试代码的编写,然后编写相关的代码满足这些测试用例。然后循环进行添加其他功能,直到完成全部功能的开发。
TDD 的基本过程可以拆解为以下 6 个步骤:
1) 分析需求,把需求拆分为具体的任务。
2) 从任务列表中取出一个任务,并对其编写测试用例。
3) 由于没有实际的功能代码,测试代码不大可能会通过(红)。
4) 编写对应的功能代码,尽快让测试代码通过(绿)。
5) 对代码进行重构,并保证测试通过(重构)。
6) 重复以上步骤。
可以用下图来表示上述过程。

03、TDD 的实践过程
通常情况下,我们都习惯在需求分析完成之后,尽快地投入功能代码的编写工作中,之后再去调用和测试。
而 TDD 则不同,它假设我们已经有了一个“测试用户”了,它是功能代码的第一个使用者,尽管功能代码还不太完善。
当我们站在“测试用户”的角度去写测试代码的时候,我们要考虑的是,这个“测试用户”该如何使用功能代码呢?是通过一个类直接调用方法呢(静态方法),还是构建类的实例去调用方法呢(实例方法)?这个方法如何传参呢?方法如何命名呢?方法有返回值吗?
有了测试代码后,我们开始编写功能代码,并且要以最快地速度让测试由“红”变为“绿”,可能此时的功能代码很不优雅,不过没关系。
当测试通过以后,我们就可以放心大胆的对功能代码进行“重构”了——优化原来比较丑陋、臃肿、性能偏差的代码。
接下来,假设我们接到了一个开发需求:
汪汪队要到小镇冒险岛进行表演,门票为 99 元,冒险岛上唯一的一个程序员王二需要开发一款可以计算门票收入的小程序。
按照 TDD 的流程,王二需要先使用 Junit 编写一个简单的测试用例,测试预期是:销售一张门票的收入是 99 元。
public class TicketTest {
private Ticket ticket;
@Before
public void setUp() throws Exception {
ticket = new Ticket();
}
@Test
public void test() {
BigDecimal total = new BigDecimal("99");
assertEquals(total, ticket.sale(1));
}
}为了便于编译能够顺利通过,王二需要一个简单的 Ticket 类:
public class Ticket {
public BigDecimal sale(int count) {
return BigDecimal.ZERO;
}
}测试用例运行结果如下图所示,红色表示测试没有通过:预期结果是 99,实际结果是 0。

那接下来,王二需要快速让测试通过,Ticket.sale() 方法修改后的结果如下:
public class Ticket {
public BigDecimal sale(int count) {
if (count == 1) {
return new BigDecimal("99");
}
return BigDecimal.ZERO;
}
}再运行一下测试用例,结果如下图所示,绿色表示测试通过了:预期结果是 99,实际结果是 99。

绿了,绿了,测试通过了,到了该重构功能代码的时候了。99 元是个魔法数字,至少应该声明成常量,对吧?
public class Ticket {
private final static int PRICE = 99;
public BigDecimal sale(int count) {
if (count == 1) {
return new BigDecimal(PRICE);
}
return BigDecimal.ZERO;
}
}重构完后再运行一下测试用例,确保测试通过的情况下,再增加几个测试用例,比如说门票销量为负数、零甚至一千的情况。
public class TicketTest {
private Ticket ticket;
@Before
public void setUp() throws Exception {
ticket = new Ticket();
}
@Test
public void testOne() {
BigDecimal total = new BigDecimal("99");
assertEquals(total, ticket.sale(1));
}
@Test(expected=IllegalArgumentException.class)
public void testNegative() {
ticket.sale(-1);
}
@Test
public void testZero() {
assertEquals(BigDecimal.ZERO, ticket.sale(0));
}
@Test
public void test1000() {
assertEquals(new BigDecimal(99000), ticket.sale(1000));
}
}销量为负数的时候,王二希望功能代码能够抛出异常;销量为零的时候,功能代码的计算结果应该为零;销量为一千的时候,计算结果应该为 99000。
重新运行一下测试用例,结果如下图所示:

有两个测试用例没有通过,那么王二需要继续修改功能代码,调整如下:
public class Ticket {
private final static int PRICE = 99;
public BigDecimal sale(int count) {
if (count < 0) {
throw new IllegalArgumentException("销量不能为负数");
}
if (count == 0) {
return BigDecimal.ZERO;
}
if (count == 1) {
return new BigDecimal(PRICE);
}
return new BigDecimal(PRICE * count);
}
}再运行一下测试用例,发现都通过了。又到了重构的时候了,销量为零、或者大于等于一的时候,代码可以合并,于是重构结果如下:
public class Ticket {
private final static int PRICE = 99;
public BigDecimal sale(int count) {
if (count < 0) {
throw new IllegalArgumentException("销量不能为负数");
}
return new BigDecimal(PRICE * count);
}
}重构结束后,再运行测试用例,确保重构后的代码依然可用。
04、最后
从上面的实践过程可以得出如下结论:
TDD 想要做的就是让我们对自己的代码充满信心,因为我们可以通过测试代码来判断这段代码是否正确无误。
也就是说,TDD 流程比较关键的一环在于如何写出有效的测试代码,这里有 4 个原则可以参考:
1)测试过程应该尽量模拟正常使用的过程。
2)应该尽量做到分支覆盖。
3)测试数据应该尽量包括真实数据,以及边界数据。
4)测试语句和测试数据应该尽量简单,容易理解。
注意,这 4 个原则不仅适用于 TDD,同样适用于任何流程下的单元测试。
最后,我想说的是,不管 TDD 有没有死,TDD 都不是银弹,不可能适合所有的场景,但这不应该成为我们拒绝它的理由。