Skip to content

元数据卡

  • 前置知识:第1章(测试优先);会写基本的 JUnit 测试
  • 预计时间:50 分钟
  • 核心难度:进阶
  • 完成标志:能设计测试金字塔并覆盖单元测试、集成测试、E2E 测试

你的进度

你的锻造流程越来越规范了——每件作品出炉前都经过检验,每道工序都有标准,零件接口也用断言锁死了公差范围。

但问题来了:检验工序比锻造工序还多,每一件成品要过二十道质检。学徒抱怨:“检验太多了,浪费时间。”你减了几道——结果出货的弩机有 30% 拉不开弦。

工匠之都的质检局里,挂着一张图:测试金字塔。 你的任务

测试不是越多越好,也不是越少越好。你需要一个系统化的策略来回答三个问题:测什么(范围)、怎么测(层次)、测多少(覆盖)。测试金字塔给了你一个直观的框架——底层是又快又多的单元测试,中间是集成测试,顶层是又慢又贵的端到端测试。这一章带你搭建自己的测试体系。

本章分层

  • 必读:测试金字塔、JUnit 5 核心、Mockito 模拟依赖、TDD 红绿重构
  • 选读:参数化测试、自定义扩展
  • 进阶:基于属性的测试(QuickCheck 思想)

本章不会要求你掌握

  • 性能测试(JMeter)
  • 安全测试工具

破局 · 溯源

你写了一个报名函数。手动测试的时候,从头走到尾——注册、登录、报名、确认——至少一分钟。你觉得太麻烦,于是写了几个 JUnit 测试,跑完不到一秒。你很满意。

两个月后,系统有 30 个模块,每个模块的测试加起来超过 500 个。你加了一个新功能,跑测试花了 15 分钟——而且最后挂了 47 个测试。你完全不知道哪些是真正的问题、哪些是相关影响。

你对测试失去了信心。

问题的根源不是你测试写得不好,是你的测试策略是平的——所有测试都在同一个层次、用同样的方式运行。你需要把测试分层。

第一层:测试金字塔——让测试有层次

测试金字塔长这样:

          /\
         /E2E\
        /------\
       /集成测试 \
      /----------\
     /  单元测试   \
    /--------------\
   /   (量最大)    \
  • 底层(单元测试):测一个类、一个方法。零外部依赖(数据库、网络、文件系统全部 mock)。运行速度快到毫秒级。数量最多。
  • 中层(集成测试):测一组类、一个模块。外部依赖用真实实例(内嵌数据库、测试容器)。运行速度秒级。数量中等。
  • 顶层(E2E 测试):测整个系统。真实数据库、真实 API、真实浏览器。运行速度分钟级。数量最少。

一个好的测试套件,单元测试占 70%、集成测试占 20%、E2E 占 10%。

第二层:单元测试——JUnit 5 实战

从你的擂台系统中最小的单元开始。Match 类的判定逻辑:

java
// ch04/unit/MatchTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

class MatchTest {

    private Match match;

    @BeforeEach
    void setUp() {
        match = new Match("match-1", "player-A", "player-B");
    }

    @Test
    void matchStartsInReadyStatus() {
        assertEquals(MatchStatus.READY, match.getStatus());
    }

    @Test
    void startMatchChangesStatus() {
        match.start();
        assertEquals(MatchStatus.IN_PROGRESS, match.getStatus());
    }

    @Test
    void cannotStartAlreadyStartedMatch() {
        match.start();
        assertThrows(IllegalStateException.class, () -> match.start());
    }

    @Test
    void recordWinMakesMatchCompleted() {
        match.start();
        match.recordWin("player-A");
        assertEquals(MatchStatus.COMPLETED, match.getStatus());
    }

    @Test
    void winnerIsSetCorrectly() {
        match.start();
        match.recordWin("player-B");
        assertEquals("player-B", match.getWinner());
    }

    @Test
    void cannotRecordWinBeforeStart() {
        assertThrows(IllegalStateException.class,
            () -> match.recordWin("player-A"));
    }

    @Test
    void cannotRecordWinForNonParticipant() {
        match.start();
        assertThrows(IllegalArgumentException.class,
            () -> match.recordWin("player-C"));
    }
}

你观察几个特征:每个测试只 assert 一个逻辑点,测试之间有完全的独立性(@BeforeEach 重建对象),边界条件(player-C 不存在)单独测。

参数化测试——适用于同一个逻辑有多个输入-输出对:

java
// ch04/unit/ScoreCalculatorTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class ScoreCalculatorTest {

    @ParameterizedTest
    @CsvSource({
        "10,  5, 15",   // 胜利加分
        " 5, 10,  3",   // 失败扣分,扣到最低 0
        " 2, 10,  0",   // 分太低且失败,归零
        " 0, 10,  0"    // 零分不会变负
    })
    void calculateScoreAfterMatch(int currentScore, int opponentScore, int expected) {
        assertEquals(expected,
            ScoreCalculator.calculate(currentScore, opponentScore, false));
    }
}

第三层:模拟外部依赖——Mockito

你的 TournamentService 需要调用一个外部 NotificationService(发邮件/发消息)。在单元测试里你不想真的发消息——那是一个外部依赖,不可控。

Mockito 模拟这个依赖:

java
// ch04/unit/TournamentServiceTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
class TournamentServiceTest {

    @Mock
    private NotificationService notificationService;

    @Mock
    private TournamentRepository tournamentRepository;

    @InjectMocks
    private TournamentService tournamentService;

    @Test
    void registerSendsNotification() {
        // 准备
        Adventurer player = new Adventurer("steven", 25);
        Tournament tournament = new Tournament("t-1", 16);

        when(tournamentRepository.findById("t-1"))
            .thenReturn(Optional.of(tournament));

        // 执行
        boolean result = tournamentService.register("t-1", player);

        // 断言
        assertTrue(result);
        verify(notificationService)
            .send(player.getId(), "报名成功");
    }
}

上面的测试关键部分:

  • @Mock:创建一个假对象,方法调用默认返回 null/0/false
  • when(...).thenReturn(...):告诉假对象"如果被问到 id 为 t-1 的比赛,返回这个 Tournament"
  • verify(...):检查假对象的 send 方法是否被调用,且参数正确

你永远不会用 Mockito 去模拟 ListMap 等标准库类——难度高收益低,直接用真实实现就行。Mockito 用来模拟外部边界:数据库、HTTP 客户端、消息队列。

Python 中的等效做法是 unittest.mock

python
# ch04/unit/test_tournament_service.py
from unittest.mock import Mock, patch
import pytest

def test_register_sends_notification():
    notification_service = Mock()
    tournament_repo = Mock()
    
    tournament = Tournament("t-1", 16)
    tournament_repo.find_by_id.return_value = tournament
    
    service = TournamentService(tournament_repo, notification_service)
    player = Adventurer("steven", 25)
    
    result = service.register("t-1", player)
    
    assert result is True
    notification_service.send.assert_called_once_with(
        player.id, "报名成功"
    )

第四层:集成测试——真实依赖的测试

当你需要验证代码和数据库的交互时,Mock 不够了——你要确认 SQL 语法对不对、ORM 映射正不正确。这时用 Testcontainers(Java)或 pytest-postgresql:

java
// ch04/integration/TournamentRepositoryIT.java
import org.junit.jupiter.api.*;
import org.testcontainers.junit.jupiter.*;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.*;

@Testcontainers
class TournamentRepositoryIT {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    private TournamentRepository repository;

    @BeforeEach
    void setUp() throws SQLException {
        DataSource ds = createDataSource(postgres);
        repository = new TournamentRepository(ds);
        // 建表
        try (Connection conn = ds.getConnection();
             Statement st = conn.createStatement()) {
            st.execute("""
                CREATE TABLE IF NOT EXISTS tournaments (
                    id VARCHAR PRIMARY KEY,
                    status VARCHAR NOT NULL,
                    max_participants INT NOT NULL,
                    created_at TIMESTAMP DEFAULT NOW()
                )
            """);
        }
    }

    @Test
    void saveAndFindById() {
        Tournament t = new Tournament("t-1", 16);
        repository.save(t);

        Optional<Tournament> found = repository.findById("t-1");
        assertTrue(found.isPresent());
        assertEquals("t-1", found.get().getId());
    }
}

集成测试会启动一个真正的 PostgreSQL 容器(用 Docker),运行测试代码,验证 CRUD 操作的正确性。测试后自动销毁容器。速度比单元测试慢(数秒),但准确度比 Mock 高。

第五层:E2E 测试——从用户视角走完全程

E2E 测试模拟用户的完整操作流程。最外层的"这功能能用吗"验证:

java
// ch04/e2e/RegistrationFlowTest.java
import org.junit.jupiter.api.*;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RegistrationFlowTest {

    @Autowired
    private WebTestClient webClient;

    @Test
    void fullRegistrationFlow() {
        // POST /api/tournaments/{id}/register
        webClient.post()
            .uri("/api/tournaments/t-1/register")
            .bodyValue("""
                {"playerId": "steven", "level": 25}
            """)
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.success").isEqualTo(true);

        // GET /api/tournaments/t-1/participants
        webClient.get()
            .uri("/api/tournaments/t-1/participants")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.length()").isEqualTo(1)
            .jsonPath("$[0].id").isEqualTo("steven");
    }
}

第六层:TDD——红-绿-重构循环

TDD(Test-Driven Development)把"先写测试再写实现"推到了极致——每次只写一点点,循环三分钟:

1. 写一个会失败的测试(红)
2. 写最少量的代码让测试通过(绿)
3. 重构优化代码(重构)
4. 回到步骤 1

一个真实的 TDD 循环:

红——先写测试:

java
@Test
void tournamentWith16ParticipantsIsFullAt16() {
    Tournament t = new Tournament("t-1", 16);
    for (int i = 0; i < 16; i++) {
        t.register(new Adventurer("p" + i, 20 + i));
    }
    assertTrue(t.isFull());
}

编译都通不过——Tournament 还没有 isFull() 方法。这是好事:测试驱动了 API 设计

绿——写最少代码让测试通过:

java
public boolean isFull() {
    return participants.size() >= maxParticipants;
}

重构——改进代码质量(不改变行为):

java
// 如果 isFull 有用处,可能需要在 register 中引用它
public boolean register(Adventurer a) {
    if (isFull()) return false;
    return participants.add(a);
}

TDD 的真正价值不在于"多了一个测试",而在于写代码之前你先想清楚了接口长什么样。测试是第一个调用者——它的体验就是你 API 的体验。


常见陷阱

陷阱一:测试覆盖所有方法等于测试覆盖所有行为。 一个方法有 10 条分支,你只测了主分支就收了 100% 方法覆盖——这是覆盖率数字的假象。你应该用分支覆盖(branch coverage)来衡量,不是行覆盖。

陷阱二:E2E 测试太多。 每个用例都写 E2E 测试的团队,测试跑一次要一小时。E2E 只覆盖核心流程(登录、报名、结算),边缘场景用单元测试覆盖。

陷阱三:Mock 了太多东西。 当你的测试里有 5 个以上的 @Mock 变量时,你不再测你的代码——你在测你的配置。考虑重构:把复杂的类拆小。

陷阱四:测试与实现强耦合。 测试依赖了内部实现细节(私有方法、特定调用顺序)。重构时测试碎一地。好的测试只通过公有 API 验证行为,不关心内部怎么实现。

java
// 坏:测试依赖了实现细节
public void test() {
    service.processOrder();
    verify(repo, times(1)).save(any());
    // 如果改了内部,比如加了缓存,测试就崩了
}

// 好:测试只验证行为
public void test() {
    boolean result = service.register("t-1", player);
    assertTrue(result);
    assertEquals(1, tournament.getParticipantCount());
}

通关挑战

  • 热身:为你上一章写的 Range 类写至少 8 个 JUnit 测试,覆盖全是正整数、含负数、边界值、空区间。
  • 挑战:在你的项目里选一个带有外部依赖(数据库、HTTP 调用)的类,用 Mockito 模拟外部依赖,写一个完整的单元测试。
  • 挑战(进阶):选一个简单算法(比如二分搜索),用 TDD 三分钟循环实现它。从测试开始。

旅人笔记

测试金字塔让你在正确的地方放正确的测试——单元测试又快又准覆盖逻辑,集成测试验证组件协作,E2E 测试确保核心流程不走偏——你的测试套件不再是负担,而是安全网。


下一站预告

测试保住了你的代码不出错。但那些已经写好的、功能正确却难以理解的代码怎么办?下一章——重构与代码质量。

Built with VitePress | Software Systems Atlas