《Spring Boot 实战》4.3 契约测试与数据隔离

本节区分契约测试与集成测试的边界,说明消费者驱动契约在 Spring Cloud Contract 与 Pact 之间的选型,并给出数据隔离三策略(事务回滚、@Sql、每测试独立 schema/容器)的对比、测试数据构造规范,以及 @DirtiesContext 的真实代价。

本节目标:划清契约测试与集成测试的边界,给出消费者驱动契约的选型与写法,并把数据隔离三策略的代价讲透,让共享容器的集成测试不再互相污染。
适用版本:Spring Boot 4.1.x(Java 21)

4.3 契约测试与数据隔离

前两节把测试分层和真实依赖接上了。这一节处理两个「多」带来的问题:多个消费者带来的接口兼容性,和多个测试带来的数据污染。图书借阅服务的 GET /api/books/{isbn} 现在不只被自家前端调用,还有一个移动端和一个内部报表系统在消费——接口字段改一下,谁先炸、谁该负责,需要一套机制来约束。

4.3.1 契约测试解决什么问题

契约测试的核心是消费者驱动契约(Consumer-Driven Contract,CDC):由消费者声明「我需要这个接口提供哪些字段、什么状态码」,这份声明被固化成一个契约;提供方在构建时用契约生成测试,验证自己的实现仍然满足所有消费者。一旦提供方改坏了消费者依赖的字段,提供方自己的构建就会红,而不是等消费者上线才发现。

它和集成测试解决的是不同的问题:

维度契约测试集成测试
验证对象接口的形状与兼容性系统的端到端行为
数据用契约里约定的桩数据用真实数据
失败含义「你破坏了某个消费者的约定」「这条链路行为不对」
依赖方向消费者驱动提供方测试驱动自身
是否替代集成测试否否

一句话:契约测试防的是「接口悄悄变形」,集成测试防的是「业务逻辑写错」。两者不能互相替代。一个团队最容易犯的错,是用契约测试的通过来替代集成测试,结果接口形状对了、业务算错了。

4.3.2 Spring Cloud Contract 与 Pact 的定位

主流工具是 Spring Cloud Contract 与 Pact,选型取决于你的技术栈是否以 Spring 为主:

维度Spring Cloud ContractPact
契约语言Groovy / YAML DSLJSON(Pact 规范)
生态Spring 优先,和 JUnit / MockMvc 集成深语言中立,多语言消费者友好
提供方验证由契约生成测试(自动生成)用 provider verifier 回放契约
契约存储文件 / 仓库 / 契约 brokerPact Broker / PactFlow
适用场景单体拆微服务、全 Spring 团队多语言、消费者分散

注意一个边界:Spring Cloud Contract 属于 Spring Cloud,版本由 Spring Cloud 的 BOM 管理,不在 Spring Boot 4.1 的依赖管理里。把它接进项目时,坐标与版本以 Spring Cloud 的对应发布为准,不要指望 spring-boot-starter-parent 帮你定版本。

4.3.3 提供方契约长什么样

以借书接口为例,提供方写一份契约,声明「给定一本书有库存,POST /api/loans 应返回 201 和这些字段」:

Contract.make {
    description "借书成功时返回 201 与借阅信息"
    request {
        method POST()
        url "/api/loans"
        headers { contentType(applicationJson()) }
        body([bookId: 1, memberId: 7])
    }
    response {
        status CREATED()
        headers { header("Location", "/api/loans/100") }
        body([
            loanId: 100,
            bookId: 1,
            memberId: 7,
            dueAt: $(anyIso8601WithOffset())
        ])
    }
}

这份契约在提供方构建时会生成一个测试,自动验证 LoanController 是否满足它;同时它还能生成一份 stub,供消费者在自己的测试里当假服务用。消费者因此不需要真的启动提供方,就能验证「我按契约解析字段的代码没写错」。

值得上契约的判断标准很具体:接口有多个消费者、跨团队或跨语言、且变更频繁到靠人工沟通容易漏。单体内部、只有一个前端消费者的场景,上契约测试的收益往往抵不过维护成本,老老实实写集成测试更划算。

消费者那一侧拿到的是契约生成的 stub,用它替代真实服务,验证自己的解析代码:

@SpringBootTest
@AutoConfigureMockMvc
class LoanConsumerContractTest {

    @Test
    void client_parsesLoanResponse() throws Exception {
        // stub 由契约生成,返回 loanId / bookId / memberId / dueAt
        stubFor(post("/api/loans")
                .willReturn(aResponse()
                        .withStatus(201)
                        .withHeader("Content-Type", "application/json")
                        .withBody("{\"loanId\":100,\"bookId\":1,\"memberId\":7,"
                                + "\"dueAt\":\"2026-09-15T02:00:00Z\"}")));

        LoanClient client = new LoanClient("http://localhost:" + wireMockPort);
        LoanView view = client.borrow(1L, 7L);

        assertThat(view.loanId()).isEqualTo(100L);
    }
}

这段代码验证的是消费者自己的解析逻辑是否符合契约,而不是提供方的实现。提供方是否满足契约,由提供方构建时那份「契约生成的测试」来保证——两边各管一半,契约是唯一的连接点。

4.3.4 数据隔离三策略

共享容器解决了启动成本,但把数据也共享了。三条主流隔离路线,代价依次升高:

策略一:@Transactional 回滚。 在测试类上标 @Transactional,Spring Test 会在每个测试方法结束后回滚。@DataJpaTest 默认就是事务性的。它零额外成本、无需清理脚本,但有两条硬限制:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Transactional
class LoanControllerTxTest { /* 同线程,回滚有效 */ }
  • 只对同线程有效。MockMvc 不另起线程,回滚有效;一旦换成 RANDOM_PORT + RestTestClient / TestRestTemplate,请求在服务器线程里跑,不在测试事务内,回滚无效。
  • 测不了「提交后」的行为。要验证提交、触发器、外键级联,回滚路线天然不适用。

策略二:@Sql 脚本。 用 SQL 脚本显式准备与清理数据,适合真实端口、真实事务的集成测试:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Sql(scripts = "/sql/loan-fixtures.sql",
     executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/sql/loan-cleanup.sql",
     executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class LoanApiIT { /* ... */ }

loan-fixtures.sql 只准备这条测试真正需要的数据:

-- 一本可借的书 + 一名正常会员
INSERT INTO book (id, isbn, title, total_copies, available_copies)
VALUES (1, '978-7-111-40701-0', 'Effective Java', 3, 3);

INSERT INTO member (id, name, email, status)
VALUES (7, 'Zhang San', 'zhangsan@example.com', 'ACTIVE');
-- loan-cleanup.sql:按外键逆序清理
DELETE FROM loan WHERE book_id = 1 OR member_id = 7;
DELETE FROM book WHERE id = 1;
DELETE FROM member WHERE id = 7;

代价是脚本要自己维护,且必须按外键依赖的逆序清理(先删 loan,再删 book / member),否则清理会失败并污染后续测试。脚本里也不要写死会漂移的自增值,能显式指定 id 就显式指定,让数据可预测。

一个和 @Sql 配合的细节值得单独提醒:@Transactional 与 @Sql 可以共存,但要清楚 BEFORE_TEST_METHOD 阶段的脚本是跑在测试事务之内还是之外——默认在事务内,脚本插入的数据会随回滚一起消失;若脚本需要独立提交(例如建表、建 schema),要用 TransactionMode.ISOLATED 显式脱离测试事务。搞混这一点,会出现「脚本明明执行了、数据却查不到」的怪象。

策略三:每测试独立 schema 或容器。 隔离最彻底,也最贵。可以在测试开始时建一个独立 schema、把连接指过去,结束后删掉:

@BeforeEach
void useFreshSchema() {
    String schema = "t_" + UUID.randomUUID().toString().replace("-", "");
    jdbcTemplate.execute("CREATE SCHEMA " + schema);
    jdbcTemplate.execute("SET search_path TO " + schema);
}

但这里有个真实陷阱:连接池会复用连接,SET search_path 只对当前连接生效。测试线程拿到的连接和业务代码后来拿到的可能不是同一条,search_path 就丢了。生产上更稳的做法是每测试一个独立 database(用管理连接 CREATE DATABASE 再改数据源),或者干脆用 Testcontainers 为不隔离不可的用例起独立实例。这条路线留给「必须验证真实提交、并发、DDL」的少数测试,别当默认。

4.3.5 三策略对比

维度@Transactional 回滚@Sql 脚本独立 schema / 容器
隔离强度中(同线程)中高
支持真实端口否是是
支持验证提交否是是
额外维护无脚本建库/建 schema 逻辑
执行开销极低低高
适用层切片、MockMvc 集成集成、端到端并发、DDL、提交语义

实践中的组合是:切片测试默认走事务回滚;真实端口的集成测试用 @Sql;只有并发借书、唯一约束、级联删除这类用例才升级到独立 schema/容器。

选型时可以照着这份清单快速定位:

  • 测试不碰真实端口、也不需要验证提交 → 用 @Transactional 回滚,成本最低。
  • 测试走真实端口(RANDOM_PORT)或要验证提交/触发器 → 用 @Sql 准备与清理。
  • 测试涉及并发、唯一约束冲突、级联删除、DDL → 用独立 schema 或独立容器。
  • 测试里出现「偶发失败」但代码没改 → 先怀疑隔离没做到位,而不是先加 @DirtiesContext。

4.3.6 测试数据构造规范

数据构造是测试可维护性的分水岭。几条能直接落地的规则:

  • 显式构造最小数据集。需要一本书就只造一本书,不要从一份「全量种子数据」里挑,那样任何字段变化都会牵连一片测试。
  • 用 builder / fixture 方法收口。aBook().withAvailableCopies(0).build() 比散落的 new Book(1L, "...", 3, 0) 更抗字段变更。
  • 不要断言自增 id 的绝对值。换库、换清理策略、并行执行都会让 id 漂移;断言「返回的 id 能查回同一实体」而不是「id 等于 100」。
  • 时间必须可注入。用 Clock 代替 Instant.now(),否则逾期判断类测试在跨天、跨时区时会间歇性失败。
  • 唯一字段加随机后缀。未做隔离的测试里,固定 ISBN 会撞唯一约束;用 "978-" + UUID.randomUUID() 之类的可复现随机值。
  • 清理顺序按外键逆序,并在 @AfterEach 里做,不要依赖测试执行顺序。

4.3.7 并行执行与隔离的配合

回到 4.1.8 留下的问题:并行能压榨多核,但它和隔离是一对强耦合。没有隔离就开并行,等于把「偶发失败」批量制造出来。判断能不能开并行,看三件事:

  • 数据是否可隔离。测试之间不能共享可变行。事务回滚在并行下要小心:不同线程各持自己的事务,只要不写同一行就没问题;一旦两个测试都改「id=1 的书」,回滚也救不了,因为它们在各自的连接上互相覆盖。
  • 共享容器是否线程安全。Testcontainers 的容器对象本身被多线程读取连接信息是安全的,但容器里的数据不是——这才是要隔离的对象。
  • 是否存在静态可变状态。内存缓存、static 计数器、被 @DirtiesContext 重置的配置,在并行下都会产生非确定行为。

务实的推进顺序是:先让测试在串行下完全确定性通过(数据隔离到位),再打开类级并行,观察一段时间有没有 flake,最后才考虑方法级并行。跳过第一步直接开并行,只会把隔离问题伪装成「随机失败」。

4.3.8 @DirtiesContext 的代价

@DirtiesContext 会在测试后把应用上下文标记为脏,强制下一次测试重建。它常被当成「测试之间有状态残留」的万能药,代价却很实在:

  • 每次重建都要重新启动上下文。4.1.1 实测纯应用启动约 0.8–1.1 秒,加上数据源初始化与容器,单次重建往往数秒;一个类里每条测试都 dirties,就是「测试条数 × 数秒」。
  • 它直接击穿 TestContext 缓存,让本该被复用的上下文失效,整个测试套件的耗时被放大。
  • 它掩盖了真正的问题:状态残留多半来自没做数据隔离或静态可变状态,dirties 只是让症状消失,根因还在。

正确的处理顺序是:先做数据隔离(4.3.4),再考虑 @MockitoBean / @TestPropertySource 调整行为,最后才在确实无法避免(例如测试改了 JVM 级静态状态、改了系统属性)时,用最小粒度:

@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)

AFTER_CLASS 比 AFTER_METHOD 便宜得多:一个类只重建一次,而不是每条测试重建一次。

4.3.9 常见坑

  • 用 @Transactional 回滚却跑了 RANDOM_PORT:请求在服务器线程里提交,回滚根本没生效,测试之间互相污染。
  • @Sql 清理脚本没按外键逆序,清理失败被当成「偶发失败」,越积越多。
  • 契约测试通过就以为集成测试可以省了:契约只管接口形状,业务逻辑算错它照样绿。
  • 在契约里写死具体 id 与时间:消费者 stub 与提供方实现稍有不同就红,契约变成易碎品。
  • 到处 @DirtiesContext:测试套件从几十秒涨到几分钟,还没找到真正原因。

4.3.10 小结

  • 契约测试与集成测试边界清晰:前者管接口形状与兼容性,后者管端到端行为,互不替代。
  • 消费者驱动契约适合多消费者、跨团队、跨语言;Spring Cloud Contract 版本由 Spring Cloud 管理,不在 Spring Boot 4.1 的依赖管理内。
  • 数据隔离三策略按代价排序:事务回滚(同线程、不验证提交)、@Sql 脚本(支持真实端口)、独立 schema/容器(隔离最强、开销最高)。
  • 真实端口下事务回滚失效,这是最容易踩的一条。
  • 测试数据显式构造、时间用 Clock、不断言绝对 id、唯一字段加随机后缀。
  • @DirtiesContext 会重建上下文并击穿缓存,先解决隔离根因,必要时用 AFTER_CLASS。

测试体系到此收口:分层决定「在哪测」,Testcontainers 决定「拿什么测」,契约与数据隔离决定「测出来的结果可不可信」。下一章进入接口设计,从资源建模开始把借阅服务的 API 定下来。

阅读导航:上一节:4.2 Testcontainers 真实依赖 · 下一节:5.1 资源建模 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计