4.2 集成、契约、端到端与持续反馈
单元测试能证明核心逻辑在受控条件下成立,却不能证明 SQL 与目标数据库兼容、JSON 契约没有漂移、浏览器能真正完成登录和报名。跨边界测试的价值,就在于覆盖这些自建代码无法独自决定的连接处。
集成测试:使用有代表性的真实依赖
Repository 测试应验证实际数据库行为,例如:
- 方言、约束和索引定义能否执行;
- 时间、精度、枚举和 JSON 的映射是否正确;
- 事务提交、回滚与隔离是否符合设计;
- 唯一约束在并发请求下是否真正生效。
如果生产使用 PostgreSQL,内存数据库只能覆盖部分反馈,不能替代 PostgreSQL 集成测试。下面的 Java 21/JUnit 5 示例使用 Testcontainers 1.21.4 和 PostgreSQL 16,需要可访问的 Docker 兼容运行时。在 1.1 POM 的 dependencies 元素内加入这些测试依赖;练习固定使用 1.x 包名,Testcontainers 2.x 的模块名和数据库类包名已有调整,不能混用。JDBC 驱动也需要单独引入。
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.21.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.8</version>
<scope>test</scope>
</dependency>将完整示例保存为 src/test/java/JdbcRegistrationTest.java,运行 mvn -Dtest=JdbcRegistrationTest test。临时表属于当前连接,连接关闭后消失。
import static org.junit.jupiter.api.Assertions.*;
import java.sql.DriverManager;
import java.sql.SQLException;
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 JdbcRegistrationTest {
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void uniqueRegistrationRejectsDuplicatesAndRollbackRemovesUncommittedRows()
throws SQLException {
try (var connection = DriverManager.getConnection(postgres.getJdbcUrl(),
postgres.getUsername(), postgres.getPassword());
var sql = connection.createStatement()) {
sql.execute("""
CREATE TEMP TABLE registration (
id text PRIMARY KEY,
tournament text NOT NULL,
player text NOT NULL,
UNIQUE (tournament, player)
)
""");
sql.executeUpdate("INSERT INTO registration VALUES ('r-1', 't-1', 'A')");
SQLException duplicate = assertThrows(SQLException.class, () ->
sql.executeUpdate("INSERT INTO registration VALUES ('r-2', 't-1', 'A')"));
assertEquals("23505", duplicate.getSQLState());
connection.setAutoCommit(false);
sql.executeUpdate("INSERT INTO registration VALUES ('r-3', 't-1', 'B')");
connection.rollback();
try (var rows = sql.executeQuery("SELECT id, player FROM registration")) {
assertTrue(rows.next());
assertEquals("r-1", rows.getString("id"));
assertEquals("A", rows.getString("player"));
assertFalse(rows.next());
}
}
}
}首行由 JDBC 自动提交;同一赛事与参赛者的重复行必须返回 SQLSTATE 23505。随后显式事务中的第二位参赛者被回滚,查询只剩 r-1。它验证这些 SQL 行为,还没有验证 Repository 映射、生产迁移或并发隔离;这些要围绕真实适配器和迁移文件另行测试。static 容器在测试类内共享,不会替每个方法重置数据。此例顺序执行,新增测试需用 schema、清理或独立容器隔离。
契约测试:验证服务边界的共同承诺
服务 A 的客户端测试使用 stub 返回某段 JSON,只能证明 A 能处理这段 JSON,不能证明服务 B 真的会提供它。消费者驱动契约测试把消费者依赖的具体交互保存为契约,再在提供者构建中回放验证。
消费者测试
-> 生成“我依赖这些请求/响应”的契约
-> 提供者构建验证契约
-> 发布验证结果并决定版本能否安全部署契约测试适合验证请求方法、路径、字段、状态码和兼容规则。它不替代:
- 提供者内部业务规则测试;
- 多服务共同运行时的少量集成或 E2E 测试;
- 延迟、容量、安全与故障恢复验证。
例如消费者依赖非空字符串 registrationId,提供者把它改名为 id,即使 JSON 仍合法也应使该交互契约失败。验证结果还要对应准备部署的消费者与提供者版本,不能拿“某次提供者构建通过”替代版本兼容判断。事件契约应检查消息名、schema、必填字段与兼容策略;这类测试通常绕过消息代理,因此重复和乱序处理还需消费者行为测试,投递与确认还需代理集成测试。
端到端测试只守关键过程
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) 等价(d != 0 且 k != 0)对 2.1 的 BigInteger 有理数,应生成大小受限的非零分母与非零缩放因子,并用 BigInteger 完成乘法后再调用 of。先做 long 乘法可能在进入 ADT 前就溢出。保留失败种子与收缩后的案例,便于复现;生成测试覆盖采样输入,不是对所有有理数的证明。
性质必须真的属于领域契约。浮点运算一般不满足精确结合律;把数学实数规律直接套到 IEEE 754 浮点数上会制造错误测试。
性质测试失败后应能收缩(shrink)为较小反例,便于定位。它补充人工挑选的边界案例,不替代规格思考。
TDD 是短反馈循环,不是测试分类
Red–Green–Refactor 的基本节奏是:
- 写一个因缺少行为而失败的测试;
- 用最小改动让它通过;
- 在测试保持绿色时重构;
- 重复一个小步。
先确认测试以预期原因失败,否则一个从未失败过的测试可能根本没有验证新行为。TDD 对探索 API 和细化规则很有帮助,但数据库迁移、并发故障和视觉体验仍需要相应层次的设计与验证。
覆盖率、变异测试与测试有效性
覆盖率回答“哪些代码被执行过”,不回答“结果是否被正确断言”。它适合寻找完全未触达的区域,不适合作为唯一质量目标。
变异测试会对代码做小修改,例如把 > 变成 >=,再检查测试是否失败:
- 测试失败:该变异被杀死;
- 测试仍通过:可能缺少断言、缺少案例,或变异与原代码等价。
变异分数也需要解释,不能机械追到 100%。对高风险纯逻辑模块,它比单纯行覆盖更能暴露“测试运行了但没检查”的问题。
在 CI 中按反馈速度分层
一个常见流水线是:
提交前/PR 快速门禁
编译 + 静态检查 + 单元测试 + 快速契约测试
PR 或合并门禁
数据库集成测试 + 提供者契约验证 + 少量关键 E2E
定时/发布前
完整 E2E + 性能 + 安全 + 恢复演练具体分层应依据项目耗时和风险。不要把不稳定测试默默重跑到绿色:重跑可以收集诊断证据,但 flaky test 应有负责人、修复期限,并在不掩盖风险的前提下隔离。
测试策略检查表
- 每个关键风险是否有对应的验证层?
- 是否用真实目标数据库验证迁移和持久化?
- 服务边界是否有版本兼容或契约验证?
- 端到端测试是否只覆盖不可替代的关键用户过程?
- 时间、随机数和并发是否可控制或可观测?
- 失败是否提供足够诊断信息?
- CI 是否先返回最便宜、最确定的反馈?
- 覆盖率和变异结果是否回到风险而非单一数字?