测试驱动开发模式(TDD):红绿重构、测试先行、单元测试,以及JUnit与Mockito实践
说实话,TDD(测试驱动开发)这个概念,我最早接触的时候是有点抵触的。你想啊,代码还没写,先写测试?这不是本末倒置吗?直到有一次,我在一个金融项目里被线上bug追着跑,改一个地方崩三个地方,才真正体会到什么叫「欠的债迟早要还」。从那以后,TDD就成了我写代码的标配。
说白了,TDD就是一套「先写测试,再写代码,最后重构」的循环。它的核心就三个字:红、绿、重构。
红绿重构:TDD的黄金三步
我习惯把TDD比作开车。测试是导航,代码是油门,重构是方向盘。没有导航,你开得再快也可能跑偏。
- 红(Red):先写一个会失败的测试。这时候代码还没实现,测试跑不过,显示红色。嗯,这很正常。
- 绿(Green):写最少的代码让测试通过。别想太多,能跑就行。这时候测试变绿。
- 重构(Refactor):在测试保护下,优化代码结构。去掉重复,提高可读性。
核心要点:红绿重构不是一次性的,而是一个持续循环。每次只加一个小功能,每次只改一点点。
我在项目中遇到过最典型的反例:有人一口气写了500行代码,然后才开始写测试。结果呢?测试跑不过,bug找不到,最后全删了重写。你想想看,这多浪费时间。
测试先行:为什么先写测试?
很多人问我:「先写测试,我怎么知道要测什么?」我的回答是:先写测试,其实是在帮你理清需求。
举个例子。假设你要写一个计算器,功能是「两数相加」。如果你先写测试:
// 测试代码
@Test
public void testAdd() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result);
}
写这个测试的时候,你其实已经在思考:
- 类名叫什么?Calculator
- 方法签名是什么?add(int, int)
- 返回值是什么?int
- 边界情况呢?负数?大数?
这些思考,就是设计。说白了,测试先行就是设计先行。
我的习惯:写测试时,我会先写最核心的「快乐路径」(happy path),再写边界情况,最后写异常情况。这样思路最清晰。
单元测试:小步快跑的艺术
单元测试,测的是「单元」——一个类、一个方法、一个函数。它的特点是:快、小、独立。
我曾经见过一个项目,单元测试跑一次要40分钟。为什么?因为每个测试都去连数据库、调外部API。这哪是单元测试?这明明是集成测试。
好的单元测试应该满足F.I.R.S.T原则:
| 原则 | 含义 | 我的经验 |
|---|---|---|
| Fast(快速) | 毫秒级执行 | 超过100ms的测试,我会怀疑它是不是太「重」了 |
| Independent(独立) | 不依赖其他测试 | 测试之间不要共享状态,否则你会被「幽灵失败」折磨 |
| Repeatable(可重复) | 任何环境都能跑 | 不要依赖时间、随机数、外部服务 |
| Self-validating(自验证) | 自动判断通过/失败 | 不要人工去看日志,断言才是王道 |
| Timely(及时) | 在代码之前写 | 这就是TDD的精髓 |
JUnit与Mockito实践
Java生态里,JUnit是单元测试的基石,Mockito是模拟对象的利器。两者配合,几乎能搞定所有场景。
JUnit 5 基础用法
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
void testAdd() {
assertEquals(5, calculator.add(2, 3));
}
@Test
void testDivideByZero() {
assertThrows(ArithmeticException.class,
() -> calculator.divide(1, 0));
}
@AfterEach
void tearDown() {
calculator = null;
}
}
这里要注意:@BeforeEach和@AfterEach是每个测试方法前后都会执行。我一般用@BeforeEach做初始化,@AfterEach做清理。
Mockito:搞定外部依赖
真实项目中,一个类往往依赖数据库、网络、文件系统。单元测试不能真的去调这些,否则就变成集成测试了。这时候就需要Mockito。
import static org.mockito.Mockito.*;
public class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderService orderService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void testPlaceOrder() {
// 模拟支付网关返回成功
when(paymentGateway.charge(anyDouble()))
.thenReturn(true);
Order order = new Order(100.0);
boolean result = orderService.placeOrder(order);
assertTrue(result);
// 验证支付网关被调用了一次
verify(paymentGateway, times(1)).charge(100.0);
}
}
避坑指南:我曾经犯过一个错误——过度使用Mock。把所有依赖都Mock掉,结果测试全绿,上线全红。记住:只Mock外部系统,不要Mock你自己的业务逻辑。
知识体系总览
下面这张图,是我对TDD整体脉络的理解。你可以把它当作一张「地图」,随时回来对照。
实战中的几个关键点
光说不练假把式。我分享几个实战中总结出来的要点:
- 测试命名要像讲故事:
shouldReturnTrueWhenOrderIsValid比testOrder1好一万倍。你想想看,三个月后你回来看代码,哪个能看懂? - 一个测试只测一件事:如果一个测试里有三个断言,那它其实在测三件事。拆开!
- 不要测试私有方法:私有方法是实现细节,测试应该关注公开接口。如果私有方法太复杂,说明它应该被提取成一个新类。
- 覆盖率不是目标:我见过有人为了100%覆盖率,写了大量无意义的测试。80%的有意义覆盖,好过100%的垃圾覆盖。
我的小技巧:写测试时,我会先写一个「会失败」的测试,然后看着它变红。这个红色其实是个信号——它告诉我「需求已经明确了,接下来就是实现」。等测试变绿的那一刻,那种成就感,真的很爽。
好了,关于TDD的核心内容就这些。记住:红绿重构不是教条,而是一种节奏。找到适合自己的节奏,让测试成为你的安全网,而不是负担。
公众号:蓝海资料掘金营,微信deep3321