测试驱动开发模式(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整体脉络的理解。你可以把它当作一张「地图」,随时回来对照。

TDD 知识体系总览 绿 重构 写最少代码 优化结构 加新功能 F.I.R.S.T 原则 • Fast 快速 • Independent 独立 • Repeatable 可重复 • Self-validating 自验证 • Timely 及时 工具栈 • JUnit 5 • Mockito • AssertJ • Hamcrest • JaCoCo (覆盖率) 最佳实践 小步提交 · 只Mock外部 · 测试即文档 · 红绿重构循环

实战中的几个关键点

光说不练假把式。我分享几个实战中总结出来的要点:

  1. 测试命名要像讲故事shouldReturnTrueWhenOrderIsValidtestOrder1 好一万倍。你想想看,三个月后你回来看代码,哪个能看懂?
  2. 一个测试只测一件事:如果一个测试里有三个断言,那它其实在测三件事。拆开!
  3. 不要测试私有方法:私有方法是实现细节,测试应该关注公开接口。如果私有方法太复杂,说明它应该被提取成一个新类。
  4. 覆盖率不是目标:我见过有人为了100%覆盖率,写了大量无意义的测试。80%的有意义覆盖,好过100%的垃圾覆盖。

我的小技巧:写测试时,我会先写一个「会失败」的测试,然后看着它变红。这个红色其实是个信号——它告诉我「需求已经明确了,接下来就是实现」。等测试变绿的那一刻,那种成就感,真的很爽。

好了,关于TDD的核心内容就这些。记住:红绿重构不是教条,而是一种节奏。找到适合自己的节奏,让测试成为你的安全网,而不是负担。


公众号:蓝海资料掘金营,微信deep3321