4.2 集成、契约、端到端与持续反馈
单元测试能证明核心逻辑在受控条件下成立,却不能证明 SQL 与目标数据库兼容、JSON 契约没有漂移、浏览器能真正完成登录和报名。跨边界测试的价值,就在于覆盖这些自建代码无法独自决定的连接处。
集成测试:使用有代表性的真实依赖
Repository 测试应验证实际数据库行为,例如:
- 方言、约束和索引定义能否执行;
- 时间、精度、枚举和 JSON 的映射是否正确;
- 事务提交、回滚与隔离是否符合设计;
- 唯一约束在并发请求下是否真正生效。
如果生产使用 PostgreSQL,内存数据库只能覆盖部分反馈,不能替代 PostgreSQL 集成测试。Testcontainers 可以为测试启动短生命周期容器:
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class JdbcRegistrationRepositoryTest {
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void storesAndLoadsRegistration() {
DataSource dataSource = DataSources.postgres(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword());
Migrations.apply(dataSource);
JdbcRegistrationRepository repository =
new JdbcRegistrationRepository(dataSource);
Registration saved = Registration.confirmed("t-1", "player-A");
repository.save(saved);
assertEquals(saved, repository.findById(saved.id()).orElseThrow());
}
}示例省略了项目特定的数据源与迁移实现。专业项目还应验证迁移脚本,并让每个测试获得隔离数据:事务回滚、独立 schema、清理脚本或独立容器都可以,选择取决于并行度和速度。
契约测试:验证服务边界的共同承诺
服务 A 的客户端测试使用 stub 返回某段 JSON,只能证明 A 能处理这段 JSON,不能证明服务 B 真的会提供它。消费者驱动契约测试把消费者依赖的具体交互保存为契约,再在提供者构建中回放验证。
消费者测试
-> 生成“我依赖这些请求/响应”的契约
-> 提供者构建验证契约
-> 发布验证结果并决定版本能否安全部署契约测试适合验证请求方法、路径、字段、状态码和兼容规则。它不替代:
- 提供者内部业务规则测试;
- 多服务共同运行时的少量集成或 E2E 测试;
- 延迟、容量、安全与故障恢复验证。
对于事件,还要验证事件名、schema、必填字段、兼容策略以及重复/乱序语义,而不只是 JSON 能反序列化。
端到端测试只守关键旅程
E2E 测试经过真实入口和主要依赖,最接近用户旅程,也最容易受环境、数据和异步时序影响。适合保留的场景通常是:
- 用户能登录并完成一次核心交易;
- 关键权限边界确实生效;
- 部署后的主要组件能够连通;
- 少数无法在低层验证的浏览器或移动端行为。
不要在 E2E 层枚举所有积分边界。算法边界交给单元测试,SQL 约束交给集成测试,E2E 只确认它们连起来后的关键路径。
失败时应保存截图、控制台日志、网络跟踪、服务日志和关联 ID。只有“第 17 步超时”的 E2E 测试,维护价值很低。
性质测试:从案例扩展到规律
示例测试检查几个已知点,性质测试生成大量输入验证一般规律。有理数 ADT 可以验证:
x + 0 = x
x + y = y + x
(x + y) + z = x + (y + z)
of(n, d) 与 of(k*n, k*d) 等价(k != 0)性质必须真的属于领域契约。浮点运算一般不满足精确结合律;把数学实数规律直接套到 IEEE 754 浮点数上会制造错误测试。
性质测试失败后应能收缩(shrink)为较小反例,便于定位。它补充人工挑选的边界案例,不替代规格思考。
TDD 是短反馈循环,不是测试分类
Red–Green–Refactor 的基本节奏是:
- 写一个因缺少行为而失败的测试;
- 用最小改动让它通过;
- 在测试保持绿色时重构;
- 重复一个小步。
先确认测试以预期原因失败,否则一个从未失败过的测试可能根本没有验证新行为。TDD 对探索 API 和细化规则很有帮助,但数据库迁移、并发故障和视觉体验仍需要相应层次的设计与验证。
覆盖率、变异测试与测试有效性
覆盖率回答“哪些代码被执行过”,不回答“结果是否被正确断言”。它适合寻找完全未触达的区域,不适合作为唯一质量目标。
变异测试会对代码做小修改,例如把 > 变成 >=,再检查测试是否失败:
- 测试失败:该变异被杀死;
- 测试仍通过:可能缺少断言、缺少案例,或变异与原代码等价。
变异分数也需要解释,不能机械追到 100%。对高风险纯逻辑模块,它比单纯行覆盖更能暴露“测试运行了但没检查”的问题。
在 CI 中按反馈速度分层
一个常见流水线是:
提交前/PR 快速门禁
编译 + 静态检查 + 单元测试 + 快速契约测试
PR 或合并门禁
数据库集成测试 + 提供者契约验证 + 少量关键 E2E
定时/发布前
完整 E2E + 性能 + 安全 + 恢复演练具体分层应依据项目耗时和风险。不要把不稳定测试默默重跑到绿色:重跑可以收集诊断证据,但 flaky test 应有负责人、修复期限,并在不掩盖风险的前提下隔离。
测试策略检查表
- 每个关键风险是否有对应的验证层?
- 是否用真实目标数据库验证迁移和持久化?
- 服务边界是否有版本兼容或契约验证?
- E2E 是否只覆盖不可替代的关键旅程?
- 时间、随机数和并发是否可控制或可观测?
- 失败是否提供足够诊断信息?
- CI 是否先返回最便宜、最确定的反馈?
- 覆盖率和变异结果是否回到风险而非单一数字?