原文:Introduction in Java TDD – part 1
翻译:get-set
欢迎来到关于测试驱动开发(TDD)的简介。我们会谈到关于Java和JUnit在TDD中的应用,不过这些都只是工具而已。这篇文章的主要目的是能给您一个关于TDD的深入的理解,而不管什么编程语言和测试框架。
如果你不在你的项目中使用TDD,那么说明你要么懒,要么就是不懂TDD,缺少时间这种理由是站不住脚的。
关于这篇文章
在这篇文章中,我会解释什么是TDD,以及如何在Java中使用;在TDD的什么地方会用到单元测试;在单元测试中你需要做哪些事情;最后,你应该遵循什么原则来编写完美有效的单元测试代码。
如果你已经知道关于Java中的TDD,但是又对例子和教程比较感兴趣,建议你跳过本节直接阅读下一节。
什么是TDD?
如果有人让我简单解释一下什么是TDD,我会说TDD就是在你还没有实现一项功能之前就进行的测试。你可能会质疑:如果还没有实现那如何测试呢?估计肯特·贝克(估计你也猜到了他就是TDD的创始人)会赏你一巴掌。
那么具体如何来做呢?大概有如下几步:
1. 阅读并理解功能需求;
2. 开发一系列的测试来检查这项功能。因为还没有实现功能,因此所有的测试都是红色的;
3. 开发这项功能,直到所有的测试都变成绿色的;
4. 重构代码。
TDD需要的是不同的思路,所以为了基于TDD的理念开始开发,你需要首先忘掉之前的开发思路。这个过程非常痛苦,而且如果你不懂如何编写单元测试的话会更加痛苦,但却是值得的。
基于TDD开发有很大的优势:
1. 你对要实现的功能会有一个更好的理解;
2. 你有明确的“功能是否完成”的指标;
3. 基于测试开发的代码通常之后不需要更多的修改和完善。
得到这一优势的代价也很大——调整到一个新的开发模式的时候的阵痛,以及你在开发每一个新功能的时候可能会花费更多的时间。不过这是时间换质量的代价。
总之这就是TDD的工作原理——编写红色的单元测试,实现相关功能,让所有的测试变绿,重构。
TDD中单元测试的时机
单元测试是自动化测试金字塔中最小的元素,TDD也是基于此。单元测试可以帮助我们检查业务逻辑,编写单元测试代码也很容易。那么你如何使用单元测试呢?我会尽量通过简单的方式解释给你。
单元测试应该越小越好。但是不要认为一个方法对应一个测试,不过也有可能存在这种情况。真正的规则在于一个单元测试对应一组多个方法的调用,这叫做“基于行为的测试”(testing of behaviour)。
我们来看一下Account
这个类:
public class Account {
private String id = RandomStringUtils.randomAlphanumeric(6);
private boolean status;
private String zone;
private BigDecimal amount;
public Account() {
status = true;
zone = Zone.ZONE_1.name();
amount = createBigDecimal(0.00);
}
public Account(boolean status, Zone zone, double amount) {
this.status = status;
this.zone = zone.name();
this.amount = createBigDecimal(amount);
}
public enum Zone {
ZONE_1, ZONE_2, ZONE_3
}
public static BigDecimal createBigDecimal(double total) {
return new BigDecimal(total).setScale(2, BigDecimal.ROUND_HALF_UP);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("id: ").append(getId())
.append("\nstatus: ")
.append(getStatus())
.append("\nzone: ")
.append(getZone())
.append("\namount: ")
.append(getAmount());
return sb.toString();
}
public String getId() {
return id;
}
public boolean getStatus() {
return status;
}
public void setStatus(boolean status) {
this.status = status;
}
public String getZone() {
return zone;
}
public void setZone(String zone) {
this.zone = zone;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
if (amount.signum() < 0)
throw new IllegalArgumentException("The amount does not accept negative values");
this.amount = amount;
}
}
注意,这个类中有4个getter方法。如果我们为每个getter方法创建一个单独的单元测试,代码就会有很多冗余行。这种情况下基于行为的测试就比较有用了。试想我们现在需要测试某一个构造器在创建对象时是否正确,如何检查创建的对象是否如预期呢?我们需要检查每一个属性的值,getter方法就可以用于这样的场景。
创建小而精的单元测试,因为它们需要在每次提交到git或编译之前都应该执行。为了理解单元测试的速度的重要性,试想一个项目有1000个单元测试,每个测试花费100ms,那么跑完所有的测试将花费1分40秒的时间。
实际上对于单元测试来说,100ms的时间太长了,所以你应当通过采用不同的规则和技术来降低测试时间,比如,不要在单元测试中进行数据库连接的测试,也不要在@Before
注解的地方运行大型对象的初始化。
要给单元测试起好名字。测试的名字可以很长,主要是它能够表达清楚测试什么。例如我需要测试Account
类的一个构造方法,我会命名为defaultConstructorTest
。另一方面,建议在命名之前先把测试逻辑写出来,这样可能会更容易命名。
单元测试应该是可预测的。这是最明显不过的了。例如,为了检查转账(5%手续费)操作,你需要知道转账的金额和作为输出的到账金额。比如转账100块而收到95块。
最后,单元测试结果是容易获得的。当你给每一个业务逻辑场景都构建一个测试的时候,可以得到很多测试结果反馈,从而能够精确定位到测试失败的场景。
所有的这些建议目的在于提高单元测试的设计水平,但是还有一点——基本的测试设计原理。
基本的测试设计原理
如果没有测试数据,那么测试也无从谈起。例如,当你测试转账业务逻辑时,你需要设置一些数字作为转账发起金额。这些金额就是测试数据。那么问题来了,你应该如何选择测试数据呢?为了回答这个问题,我们需要过一遍最主流的测试设计原理。其主要目的就是构建测试数据。
首先,我们假设转账金额只能为正整数,另外上限是1000元。那么可以表示为:
0 < amount <= 1000; amount in integer
我们所有的测试场景可以被分为两组:正确 & 错误。第一组测试数据是允许的,应该得到正确的结果;第二组就是错误场景。
依据同类数据原理(classes of equivalence technic),我们随机选取(0, 1000]范围内的数字进行测试,比如500,如果500成功的话,我们认为所有的位于该范围内的数字也是成功的。我们也可以选择区域内的其他类型数据,比如浮点型数字123.45。
然后我们依据边界测试原理(boundary testing technic),选择2个有效值,分别位于数字范围的两头,这里我们选择1作为最小值,1000作为最大值。
下一步就是选择2个无效值,比如0和1001.
最终我们有6个测试数据:
(1, 500, 1000)——作为正确场景
(0, 125.50, 1001)——作为错误场景
总结
在这篇文章中,我解释了TDD以及单元测试在TDD中的重要性。我希望在如此啰嗦的解释后,大家能够在实际的开发过程中进行应用。下一节我将会说明如何在功能代码开发前编写测试代码。我们会一步一步来,以一个文件分析的例子开始,直到最终完成代码重构。
一定让所有的测试都变成绿色的哟 : )