TDD实战指南:红-绿-重构循环与Mock/Stub使用

引言:为什么TDD值得坚持?

测试驱动开发(Test-Driven Development, TDD)并非新鲜概念,但在实际项目中真正坚持下来的团队并不多。很多人认为写测试会拖慢进度,实则TDD通过“先写测试、再写实现、最后重构”的节奏,能够显著减少调试时间、提升代码可维护性,并迫使开发者思考接口设计。本文将带你深入TDD的核心实践:红-绿-重构循环、测试金字塔,以及Mock与Stub的使用,并用JUnit + Mockito给出可运行的代码示例。

1. 红-绿-重构循环:TDD的心脏

TDD的每个最小单元都遵循三步:

  1. 红(Red):先写一个会失败的测试。这意味着你尚未实现功能,测试明确表达了期望的行为。
  2. 绿(Green):编写最简代码让测试通过。不追求完美,只求通过。
  3. 重构(Refactor):在测试的保护下,清理代码设计,消除重复,提升可读性。

这三个步骤循环往复,每次只解决一个小问题。下面通过一个简单的Calculator类演示。

示例:开始“红”阶段

// 测试类 CalculatorTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class CalculatorTest {
    @Test
    void shouldAddTwoNumbers() {
        Calculator calc = new Calculator();
        int result = calc.add(2, 3);
        assertEquals(5, result);
    }
}

这时还没有Calculator类,编译失败——这是“红”状态。

进入“绿”阶段

编写最简实现:

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

测试通过,进入“绿”。

重构阶段

当前代码足够简单,无需重构。如果后续发现重复逻辑或设计问题,就在测试通过后立即修改代码,再跑一次测试确保依然通过。

2. 测试金字塔:分层策略

为了保持测试高效且易维护,Mike Cohn提出的测试金字塔建议:

  • 上端:端到端测试(E2E) 数量最少,模拟真实用户操作,覆盖关键场景但运行慢、脆皮。
  • 中部:集成测试(Integration) 验证模块间协作,如数据库交互、外部服务调用。
  • 底部:单元测试(Unit Test) 数量最多,运行快,隔离性强,是TDD的主力。

不要在单元测试中过度依赖真实数据库或远程服务,否则测试会变慢且不可控。这正是Mock和Stub的用武之地。

3. Mock与Stub:单元测试的隔离利器

在单元测试中,我们只关注被测类(SUT)的逻辑,而不关心其依赖的真实行为。Stub为测试提供预设的返回值,Mock则进一步验证交互(比如某个方法是否被调用、调用次数、参数等)。推荐使用Mockito框架简化Mock和Stub的创建。

场景:用户注册服务

假设有一个UserService,它依赖UserRepository(保存用户到数据库)和EmailService(发送欢迎邮件)。我们想测试UserService.register()的逻辑——检查用户名是否已存在,若不存在则保存并发送邮件。

不使用Mock的痛点

  • 需要真实的数据库和邮件服务器,测试慢且不可重复。
  • 数据库状态可能干扰断言。

使用Mockito重构

首先添加依赖(Maven):

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

实现被测类 UserService

public class UserService {
    private UserRepository userRepo;
    private EmailService emailService;

    public UserService(UserRepository userRepo, EmailService emailService) {
        this.userRepo = userRepo;
        this.emailService = emailService;
    }

    public void register(String username, String email) {
        if (userRepo.existsByName(username)) {
            throw new IllegalArgumentException("Username already taken");
        }
        User user = new User(username, email);
        userRepo.save(user);
        emailService.sendWelcomeEmail(email);
    }
}

编写Mockito测试:验证交互

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

class UserServiceTest {

    @Mock
    private UserRepository userRepo;

    @Mock
    private EmailService emailService;

    private UserService userService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        userService = new UserService(userRepo, emailService);
    }

    @Test
    void shouldRegisterNewUser() {
        // Given: 用户名不存在
        when(userRepo.existsByName("alice")).thenReturn(false);

        // When: 注册
        userService.register("alice", "alice@example.com");

        // Then: 验证交互 —— 这是Mock的核心
        verify(userRepo).save(any(User.class));
        verify(emailService).sendWelcomeEmail("alice@example.com");
    }

    @Test
    void shouldThrowExceptionWhenUsernameExists() {
        // Given: 用户名已存在(Stub返回true)
        when(userRepo.existsByName("bob")).thenReturn(true);

        // When & Then: 期望抛出异常
        assertThrows(IllegalArgumentException.class,
                () -> userService.register("bob", "bob@example.com"));

        // 验证:save和sendWelcomeEmail不会被调用
        verify(userRepo, never()).save(any());
        verify(emailService, never()).sendWelcomeEmail(anyString());
    }
}

关键点解释:

  • @Mock 创建Mock对象,不需要真实实现。
  • when(...).thenReturn(...)Stub:当调用existsByName时,返回指定值,控制依赖的行为。
  • verify(...)Mock验证:检查方法是否被调用、调用次数、参数。never() 确保未调用。
  • 测试完全在内存中运行,毫秒级完成,且不依赖任何外部资源。

Stub vs Mock 的速记:Stub提供数据,Mock验证行为。一个Mock通常同时起到Stub的作用(提供预设返回值),但验证交互是它独有的能力。

4. 在TDD循环中融入Mock

回到红-绿-重构循环,写单元测试时优先使用Mock隔离依赖,让测试专注被测类的逻辑。例如:

  1. :编写UserServiceTest,使用Mock的whenverify定义期望行为。
  2. 绿:在产品代码中直接实现UserService,通过测试。
  3. 重构:优化产品代码(如提取方法、增加校验),重新运行测试确认。

注意:Mockito不能也不应被滥用。过度验证内部交互会导致测试脆弱(重构时容易因内部实现变化而失败)。遵从原则:只Mock属于你自己的接口或抽象,不要Mock你无法控制的外部库。优先测试输出结果,其次验证协作行为。

总结

TDD的“红-绿-重构”循环通过极短的反馈周期,迫使开发者写出可测试、接口清晰的代码。测试金字塔提醒我们单元测试应占据主流,而Mock与Stub解决了依赖隔离问题,让单元测试跑得又快又稳定。结合JUnit 5和Mockito,你可以轻松写出高质量的测试套件,真正用测试驱动出健壮的设计。

下一步行动建议:从你当前项目中选取一个业务类,尝试对它的核心方法应用TDD:先写测试(使用Mock),再写实现,最后重构。坚持几个循环后,你会感受到代码质量与开发效率的双重提升。

posted @ 2026-06-23 11:32  XYu1230  阅读(13)  评论(0)    收藏  举报