跳到内容

第 6 卷 / 第 4 章 / 第 2 课

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 驱动也需要单独引入。

xml
<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。临时表属于当前连接,连接关闭后消失。

java
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 真的会提供它。消费者驱动契约测试把消费者依赖的具体交互保存为契约,再在提供者构建中回放验证。

text
消费者测试
  -> 生成“我依赖这些请求/响应”的契约
    -> 提供者构建验证契约
      -> 发布验证结果并决定版本能否安全部署

契约测试适合验证请求方法、路径、字段、状态码和兼容规则。它不替代:

  • 提供者内部业务规则测试;
  • 多服务共同运行时的少量集成或 E2E 测试;
  • 延迟、容量、安全与故障恢复验证。

例如消费者依赖非空字符串 registrationId,提供者把它改名为 id,即使 JSON 仍合法也应使该交互契约失败。验证结果还要对应准备部署的消费者与提供者版本,不能拿“某次提供者构建通过”替代版本兼容判断。事件契约应检查消息名、schema、必填字段与兼容策略;这类测试通常绕过消息代理,因此重复和乱序处理还需消费者行为测试,投递与确认还需代理集成测试。

端到端测试只守关键过程 ​

E2E 测试经过真实入口和主要依赖,最接近用户过程,也最容易受环境、数据和异步时序影响。适合保留的场景通常是:

  • 用户能登录并完成一次核心交易;
  • 关键权限边界确实生效;
  • 部署后的主要组件能够连通;
  • 少数无法在低层验证的浏览器或移动端行为。

不要在 E2E 层枚举所有积分边界。算法边界交给单元测试,SQL 约束交给集成测试,E2E 只确认它们连起来后的关键路径。

失败时应保存截图、控制台日志、网络跟踪、服务日志和关联 ID。只有“第 17 步超时”的 E2E 测试,维护价值很低。

性质测试:从案例扩展到规律 ​

示例测试检查几个已知点,性质测试生成大量输入验证一般规律。有理数 ADT 可以验证:

text
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 的基本节奏是:

  1. 写一个因缺少行为而失败的测试;
  2. 用最小改动让它通过;
  3. 在测试保持绿色时重构;
  4. 重复一个小步。

先确认测试以预期原因失败,否则一个从未失败过的测试可能根本没有验证新行为。TDD 对探索 API 和细化规则很有帮助,但数据库迁移、并发故障和视觉体验仍需要相应层次的设计与验证。

覆盖率、变异测试与测试有效性 ​

覆盖率回答“哪些代码被执行过”,不回答“结果是否被正确断言”。它适合寻找完全未触达的区域,不适合作为唯一质量目标。

变异测试会对代码做小修改,例如把 > 变成 >=,再检查测试是否失败:

  • 测试失败:该变异被杀死;
  • 测试仍通过:可能缺少断言、缺少案例,或变异与原代码等价。

变异分数也需要解释,不能机械追到 100%。对高风险纯逻辑模块,它比单纯行覆盖更能暴露“测试运行了但没检查”的问题。

在 CI 中按反馈速度分层 ​

一个常见流水线是:

text
提交前/PR 快速门禁
  编译 + 静态检查 + 单元测试 + 快速契约测试

PR 或合并门禁
  数据库集成测试 + 提供者契约验证 + 少量关键 E2E

定时/发布前
  完整 E2E + 性能 + 安全 + 恢复演练

具体分层应依据项目耗时和风险。不要把不稳定测试默默重跑到绿色:重跑可以收集诊断证据,但 flaky test 应有负责人、修复期限,并在不掩盖风险的前提下隔离。

测试策略检查表 ​

  • 每个关键风险是否有对应的验证层?
  • 是否用真实目标数据库验证迁移和持久化?
  • 服务边界是否有版本兼容或契约验证?
  • 端到端测试是否只覆盖不可替代的关键用户过程?
  • 时间、随机数和并发是否可控制或可观测?
  • 失败是否提供足够诊断信息?
  • CI 是否先返回最便宜、最确定的反馈?
  • 覆盖率和变异结果是否回到风险而非单一数字?

参考资料 ​

Built with VitePress | Software Systems Atlas