什么是测试驱动开发
测试驱动开发是指在编写实现代码之前先写测试代码的开发方式。JUnit的作者Kent Beck说过:编写测试驱动代码的重要原因是消除开发中的恐惧和不确定性,因为编写代码时的恐惧会让你小心试探,让你回避沟通,让你羞于得到反馈,让你变得焦躁不安,而TDD是消除恐惧、让Java开发者更加自信更加乐于沟通的重要手段。TDD会带来的好处可能不会马上呈现,但是你在某个时候一定会发现,这些好处包括:
- 更清晰的代码 — 只写需要的代码
- 更好的设计
- 更出色的灵活性 — 鼓励程序员面向接口编程
- 更快速的反馈 — 不会到系统上线时才知道bug的存在
TDD可以在多个测试级别上使用,如下表所示:
测试级别 | 描述 |
单元测试 | 测试类中的代码 |
集成测试 | 测试类之间的交互 |
系统测试 | 测试运行中的系统 |
系统集成测试 | 测试运行中的系统包括第三方组件 |
测试驱动开发的例子
现在我们需要一段代码来计算某个电影放映厅的门票收入,当前的业务规则非常简单,包括:
- 每张票售价(单价)¥30
- 收入=门票销售数量*单价
- 放映厅最多容纳100人
这里还有一个假设:目前因为没有专业的设备或系统来统计门票销售的数量,在计算门票收入时,门票销售数量是由使用者手动录入的。
TDD的基本步骤是:红色-绿色-重构。
- 红色 - 编写无法通过的测试
- 绿色 - 编写实现代码并尽快让测试可以通过
- 重构 - 重构代码并再次让测试通过
接下来我们按照上述步骤完成门票收入计算的功能。
package com.lovo;
import java.math.BigDecimal;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class TicketRevenueTest {
private TicketRevenue ticketRevenue;
private BigDecimal expectedRevenue;
@Before
public void setUp() {
ticketRevenue= new TicketRevenue();
}
@Test
public void oneTicketSoldIsThirtyInRevenue() {
expectedRevenue = new BigDecimal("30");
Assert.assertEquals(expectedRevenue, ticketRevenue.estimateTotalRevenue(1));
}
}
上述测试代码不仅不能通过测试,甚至连编译都无法通过,因为TicketRevenue类还不存在呢。接下来我们可以利用IDE的代码修复功能(Eclipse和IntelliJ都有这样的功能)创建出TicketRevenue类以及该类中计算门票收入的estimateTotalRevenue方法。
package com.lovo;
import java.math.BigDecimal;
public class TicketRevenue {
public BigDecimal estimateTotalRevenue(int i) {
return BigDecimal.ZERO;
}
}
现在可以运行你的单元测试用例了,但是由于我们还没有实现真正的业务逻辑,这个测试是不可能通过的,如下图所示。
但是,迄今为止我们已经完成了“红色“这个步骤。接下来我们修改TicketRevenue类的estimateTotalRevenue方法来让测试通过。
package com.lovo;
import java.math.BigDecimal;
public class TicketRevenue {
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) {
BigDecimal totalRevenue = BigDecimal.ZERO;
if(numberOfTicketsSold == 1) {
totalRevenue = new BigDecimal(30);
}
return totalRevenue;
}
}
再次运行单元测试,结果如下图所示。
到这里,第二个步骤”绿色“就完成了。
接下来我们开始重构TicketRevenue类的代码。
package com.lovo;
import java.math.BigDecimal;
public class TicketRevenue {
private final static int TICKET_PRICE = 30;
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) {
BigDecimal totalRevenue = null;
totalRevenue = new BigDecimal(TICKET_PRICE * numberOfTicketsSold);
return totalRevenue;
}
}
重构后的代码可以根据输入的门票销售数量计算出对应的收入,较之之前的硬代码(hard code)它已经前进了一大步,但是很明显它没有考虑到输入小于0或者大于100的情况。因此我们需要更多的测试例来模拟实际工作环境中可能的输入,我们对刚才的测试代码进行了如下改进。
package com.lovo;
import java.math.BigDecimal;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class TicketRevenueTest {
private TicketRevenue ticketRevenue;
private BigDecimal expectedRevenue;
@Before
public void setUp() {
ticketRevenue = new TicketRevenue();
}
@Test(expected = IllegalArgumentException.class)
public void failIfLessThanZeroTicketsAreSold() {
ticketRevenue.estimateTotalRevenue(-1);
}
@Test
public void zeroSalesEqualsZeroRevenue() {
Assert.assertEquals(BigDecimal.ZERO, ticketRevenue.estimateTotalRevenue(0));
}
@Test
public void oneTicketSoldIsThirtyInRevenue() {
expectedRevenue = new BigDecimal("30");
Assert.assertEquals(expectedRevenue, ticketRevenue.estimateTotalRevenue(1));
}
@Test
public void tenTicketsSoldIsThreeHundredInRevenue() {
expectedRevenue = new BigDecimal("300");
Assert.assertEquals(expectedRevenue, ticketRevenue.estimateTotalRevenue(10));
}
@Test(expected = IllegalArgumentException.class)
public void failIfMoreThanOneHundredTicketsAreSold() {
ticketRevenue.estimateTotalRevenue(101);
}
}
再次运行测试会发现5个测试中有两个无效输入的测试没有通过(销售数量为-1和101的测试),原因很简单,我们的代码中还没有处理无效输入的代码。接下来继续重构我们的代码。
package com.lovo;
import java.math.BigDecimal;
public class TicketRevenue {
private final static int TICKET_PRICE = 30;
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold)
throws IllegalArgumentException {
BigDecimal totalRevenue = null;
if(numberOfTicketsSold < 0) {
throw new IllegalArgumentException("门票销售数量必须大于等于0");
}
else if(numberOfTicketsSold > 100) {
throw new IllegalArgumentException("门票销售数量必须小于等于100");
}
else {
totalRevenue = new BigDecimal(TICKET_PRICE * numberOfTicketsSold);
}
return totalRevenue;
}
}
再次运行刚才的测试代码,检查一下你的bar是不是绿色的(JUnit的名言是:“Keep your bar green”)。当然,对于有代码洁癖的人来说,上述代码仍然稍显臃肿,没关系,再来一次重构吧。
package com.lovo;
import java.math.BigDecimal;
public class TicketRevenue {
private final static int TICKET_PRICE = 30;
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold)
throws IllegalArgumentException {
if(numberOfTicketsSold < 0 || numberOfTicketsSold > 100) {
throw new IllegalArgumentException("门票销售数量必须在0到100之间");
}
return new BigDecimal(TICKET_PRICE * numberOfTicketsSold);
}
}
当你完成对代码的修改后,永远都不要忘记再来一次刚才的测试,仍然需要Keep your bar green。
如果我们使用面向对象的编程范式,那么对代码的重构应当遵循面向对象的设计原则。大神Robert Matin将这些原则总结为SOLID原则。
原则 | 英文 | 描述 |
单一职责原则(S) | Single Responsibility Principle | 每个对象只做自己该做的事情 |
开闭原则(O) | Open-Closed Principle | 接受扩展但不接受修改 |
里氏替换原则(L) | Liskov Substitution Principle | 可以用子类型替换父类型 |
接口隔离原则(I) | Interface Segregation Principle | 接口要小而专 |
依赖倒转原则(D) | Dependency Inversion Principle | 依赖接口而不依赖实现 |
说明:上面的例子来自The Well-Grounded Java Developer一书(中文名《Java程序员修炼之道》),这本书覆盖了Java开发中很多实用的技术以及Java新的语言特性,有兴趣的可以阅读此书,相信你会从中得到很多收获。