4.1 风险驱动的测试组合与单元测试
测试策略先问:系统最可能以什么方式伤害用户或团队,哪一层测试能以合理成本发现这些错误。测试方法的数量可以晚一点数。
从风险清单开始
先列出失败模式,再选择测试手段:
| 风险 | 可能后果 | 更合适的验证 |
|---|---|---|
| 积分规则算错 | 排名不公 | 单元测试、参数化测试、性质测试 |
| ORM 映射错误 | 数据无法读写 | 数据库集成测试 |
| 消费者依赖的字段被删除 | 服务间调用失败 | 契约测试 |
| 登录后无法完成报名 | 核心流程中断 | 少量端到端测试 |
| 峰值流量下超时 | 大面积请求失败 | 负载与容量测试 |
| 越权查看他人记录 | 隐私泄露 | 授权规则测试、安全测试 |
覆盖率不能替代这张风险表。即使每行代码都执行过,也可能没有断言金额守恒、权限隔离或重试幂等。
测试金字塔是反馈成本模型
“大量快速测试、较少跨进程测试、少量端到端测试”是有用的方向,但不存在适用于所有系统的 70/20/10 标准比例。测试组合取决于架构、风险和工具成本。
一个纯算法库可能几乎都是进程内测试;一个数据库扩展需要大量真实数据库测试;一个薄 UI 的关键风险可能集中在浏览器交互。更稳妥的原则是:
- 尽量在最靠近错误来源的层验证;
- 仍要跨越真实边界,检查自建代码与外部系统的契合;
- 只保留能提供独特信心的高成本测试;
- 按反馈速度分组,而不是让所有测试阻塞每次编辑。
单元是一个行为边界
单元测试验证一个小范围行为,通常在单进程内运行。这个“单元”可以是函数、类或一组紧密协作的对象,不必机械等同于一个方法。
沿用 1.1 的 Java 21、Maven 与 JUnit 工程。Match.java 放入 src/main/java,MatchTest.java 放入 src/test/java。该对象限于单线程使用;状态检查不能让并发调用自动获得原子性。
下面的领域对象用状态转换表达比赛结果:
public final class Match {
public enum Status { READY, IN_PROGRESS, COMPLETED }
private final String playerA;
private final String playerB;
private Status status = Status.READY;
private String winner;
public Match(String playerA, String playerB) {
this.playerA = java.util.Objects.requireNonNull(playerA, "playerA");
this.playerB = java.util.Objects.requireNonNull(playerB, "playerB");
if (this.playerA.equals(this.playerB)) {
throw new IllegalArgumentException("players must differ");
}
}
public void start() {
if (status != Status.READY) {
throw new IllegalStateException("match is not ready");
}
status = Status.IN_PROGRESS;
}
public void recordWinner(String player) {
if (status != Status.IN_PROGRESS) {
throw new IllegalStateException("match is not in progress");
}
java.util.Objects.requireNonNull(player, "player");
if (!player.equals(playerA) && !player.equals(playerB)) {
throw new IllegalArgumentException("winner is not a participant");
}
winner = player;
status = Status.COMPLETED;
}
public Status status() { return status; }
public java.util.Optional<String> winner() {
return java.util.Optional.ofNullable(winner);
}
}测试围绕可观察行为组织:
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class MatchTest {
@Test
void recordsWinnerAfterMatchStarts() {
Match match = new Match("A", "B");
match.start();
match.recordWinner("B");
assertAll(
() -> assertEquals(Match.Status.COMPLETED, match.status()),
() -> assertEquals("B", match.winner().orElseThrow())
);
}
@Test
void rejectsWinnerBeforeStartWithoutChangingState() {
Match match = new Match("A", "B");
assertThrows(IllegalStateException.class,
() -> match.recordWinner("A"));
assertEquals(Match.Status.READY, match.status());
assertTrue(match.winner().isEmpty());
}
@Test
void rejectsInvalidWinnerWithoutChangingStartedMatch() {
Match match = new Match("A", "B");
match.start();
assertThrows(IllegalArgumentException.class,
() -> match.recordWinner("C"));
assertThrows(NullPointerException.class,
() -> match.recordWinner(null));
assertEquals(Match.Status.IN_PROGRESS, match.status());
assertTrue(match.winner().isEmpty());
}
@Test
void completedMatchCannotRestartOrChangeWinner() {
Match match = new Match("A", "B");
match.start();
match.recordWinner("A");
assertThrows(IllegalStateException.class, match::start);
assertThrows(IllegalStateException.class,
() -> match.recordWinner("B"));
assertEquals(Match.Status.COMPLETED, match.status());
assertEquals("A", match.winner().orElseThrow());
}
}一个测试可以有多个相关断言,只要它们共同描述同一行为。为了追求“一测试一断言”而拆散状态、返回值和副作用,反而会重复准备代码并降低可读性。
用等价类和边界值选案例
不要凭感觉枚举输入。以 level 允许范围 [18, 100] 为例,至少考虑:
- 合法等价类:18–100;
- 下边界:17、18、19;
- 上边界:99、100、101;
- 类型允许但领域不允许的极值。
将下面的 Eligibility.java 放入 src/main/java,EligibilityTest.java 放入 src/test/java。上下界都包含在本例契约中。运行 mvn -Dtest=MatchTest,EligibilityTest test,应执行 4 个比赛测试和 8 组参数化案例。
public final class Eligibility {
private Eligibility() {}
public static boolean acceptsLevel(int level) {
return level >= 18 && level <= 100;
}
}参数化测试适合表达同一规则的多组案例:
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class EligibilityTest {
@ParameterizedTest
@CsvSource({
"-2147483648, false",
"17, false",
"18, true",
"19, true",
"99, true",
"100, true",
"101, false",
"2147483647, false"
})
void acceptsOnlySupportedLevels(int level, boolean expected) {
assertEquals(expected, Eligibility.acceptsLevel(level));
}
}测试替身有不同职责
“Mock”不应泛指所有替代对象:
| 替身 | 用途 |
|---|---|
| Stub | 为测试提供预设返回值 |
| Fake | 用简化但可运行的实现替代真实组件,如内存仓库 |
| Spy | 记录调用,供测试查询 |
| Mock | 预设或验证交互协议 |
如果只需要一个 Map 就能实现稳定的内存仓库,Fake 往往比大量 stubbing 更接近真实语义。Fake 仍要符合被替代接口的契约:Map 不会复现 SQL 唯一约束或事务隔离。Mock 可以检查“save 返回后才发布”的进程内协议。下面的完整测试把简化类型嵌在测试类内,不代表实际报名规则。给 1.1 的 POM 添加 test scope 的 org.mockito:mockito-core:5.20.0,将 RegistrationServiceTest.java 放入 src/test/java,运行 mvn -Dtest=RegistrationServiceTest test。
Mockito 5 默认使用 inline mock maker。若测试 JVM 禁止动态附加 agent,按其 Java 21 配置说明 将 Mockito 配为启动时 Java agent;mock maker 初始化失败属于运行环境问题,不是业务断言失败。
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
class RegistrationServiceTest {
record Registration(String id, String tournament, String player) {}
record RegistrationConfirmed(String id) {}
interface RegistrationRepository { void save(Registration registration); }
interface EventPublisher { void publish(RegistrationConfirmed event); }
record RegistrationService(RegistrationRepository repository, EventPublisher events) {
Registration register(String id, String tournament, String player) {
Registration registration = new Registration(id, tournament, player);
repository.save(registration);
events.publish(new RegistrationConfirmed(id));
return registration;
}
}
@Test
void publishesEventAfterSaveReturns() {
RegistrationRepository repository = mock(RegistrationRepository.class);
EventPublisher events = mock(EventPublisher.class);
RegistrationService service = new RegistrationService(repository, events);
Registration result = service.register("r-1", "t-1", "player-A");
assertEquals(new Registration("r-1", "t-1", "player-A"), result);
var order = inOrder(repository, events);
order.verify(repository).save(result);
order.verify(events).publish(new RegistrationConfirmed(result.id()));
}
@Test
void doesNotPublishWhenSaveThrows() {
RegistrationRepository repository = mock(RegistrationRepository.class);
EventPublisher events = mock(EventPublisher.class);
doThrow(new IllegalStateException("write failed"))
.when(repository).save(any(Registration.class));
RegistrationService service = new RegistrationService(repository, events);
assertThrows(IllegalStateException.class,
() -> service.register("r-1", "t-1", "player-A"));
verifyNoInteractions(events);
}
}inOrder 验证相对顺序;分开写 verify 并不验证先后。这里也只证明 save 返回后才调用 publish:ORM 可能稍后才 flush 或 commit,进程也可能在两次调用间退出。可靠发布需要相应的事务与 Outbox 设计,见 15.2。
不要 mock 值对象、集合或自己并不拥有的复杂第三方 API。可先在自有端口后面封装第三方 API,再对端口使用替身,并用集成测试验证适配器。
避免把实现细节焊死
下面是 Mockito 方法体片段,只检查调用发生,不检查顺序;它们仍可能把缓存实现钉得过死:
verify(cache).get(key);
verify(repository).find(key);
verify(cache).put(key, value);若测试真正关心的是“缓存未命中仍返回报名记录”,就应优先断言结果;严格调用顺序会让合法重构也导致失败。交互验证用于协议,状态验证用于结果,不要无差别验证每次调用。
一个可靠单元测试的性质
- 可重复:同一代码和输入得到同一结果;
- 隔离外部不确定性:不依赖真实网络、当前时间或随机全局状态;
- 快速且易定位:失败能指向小范围行为;
- 只通过公开行为观察结果;
- 明确覆盖正常、边界、失败和状态不变性。
下一节跨出进程内边界,处理数据库、服务契约、端到端流程和测试在 CI 中的分层运行。