跳到内容

4.2 集成、契约、端到端与持续反馈

单元测试能证明核心逻辑在受控条件下成立,却不能证明 SQL 与目标数据库兼容、JSON 契约没有漂移、浏览器能真正完成登录和报名。跨边界测试的价值,就在于覆盖这些自建代码无法独自决定的连接处。

集成测试:使用有代表性的真实依赖

Repository 测试应验证实际数据库行为,例如:

  • 方言、约束和索引定义能否执行;
  • 时间、精度、枚举和 JSON 的映射是否正确;
  • 事务提交、回滚与隔离是否符合设计;
  • 唯一约束在并发请求下是否真正生效。

如果生产使用 PostgreSQL,内存数据库只能覆盖部分反馈,不能替代 PostgreSQL 集成测试。Testcontainers 可以为测试启动短生命周期容器:

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

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

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

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

对于事件,还要验证事件名、schema、必填字段、兼容策略以及重复/乱序语义,而不只是 JSON 能反序列化。

端到端测试只守关键旅程

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) 等价(k != 0)

性质必须真的属于领域契约。浮点运算一般不满足精确结合律;把数学实数规律直接套到 IEEE 754 浮点数上会制造错误测试。

性质测试失败后应能收缩(shrink)为较小反例,便于定位。它补充人工挑选的边界案例,不替代规格思考。

TDD 是短反馈循环,不是测试分类

Red–Green–Refactor 的基本节奏是:

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

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

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

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

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

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

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

在 CI 中按反馈速度分层

一个常见流水线是:

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

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

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

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

测试策略检查表

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

参考资料

Built with VitePress | Software Systems Atlas