元数据卡
- 前置知识:第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 类的判定逻辑:
// 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 不存在)单独测。
参数化测试——适用于同一个逻辑有多个输入-输出对:
// 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 模拟这个依赖:
// 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/falsewhen(...).thenReturn(...):告诉假对象"如果被问到 id 为 t-1 的比赛,返回这个 Tournament"verify(...):检查假对象的send方法是否被调用,且参数正确
你永远不会用 Mockito 去模拟 List、Map 等标准库类——难度高收益低,直接用真实实现就行。Mockito 用来模拟外部边界:数据库、HTTP 客户端、消息队列。
Python 中的等效做法是 unittest.mock:
# 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:
// 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 测试模拟用户的完整操作流程。最外层的"这功能能用吗"验证:
// 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 循环:
红——先写测试:
@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 设计。
绿——写最少代码让测试通过:
public boolean isFull() {
return participants.size() >= maxParticipants;
}重构——改进代码质量(不改变行为):
// 如果 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 验证行为,不关心内部怎么实现。
// 坏:测试依赖了实现细节
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 测试确保核心流程不走偏——你的测试套件不再是负担,而是安全网。
下一站预告
测试保住了你的代码不出错。但那些已经写好的、功能正确却难以理解的代码怎么办?下一章——重构与代码质量。