单元测试全绿却线上报错:微服务 4 层测试我们漏了契约,那次接口变更背了锅
"我本地单元测试都过了啊"——这句话我在故障复盘会上听过太多次。单体时代,单元测试覆盖核心逻辑确实能挡住大部分问题;但微服务拆开之后,一个服务的"对",可能正好是另一个服务的"错"。这篇文章聊我们踩坑后建立的 4 层测试体系:单元、集成、契约、E2E,以及最容易被忽视、却最致命的契约测试。我的核心观点先放在前面:微服务测试的重点不是"测得多",而是"在正确的边界上测"——测错了边界,单测再绿也挡不住跨服务的事故。
第一层:单元测试,只测自己的逻辑
单元测试的目标是快、隔离、可重复。我们用 JUnit 5 + Mockito 把依赖 mock 掉,只验证当前类的分支:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderMapper mapper; // ① 把数据库依赖 mock 掉
@Mock StockClient stockClient; // ② 把远程调用 mock 掉
@InjectMocks OrderService service; // ③ 注入被测试对象
@Test
void should_reject_when_stock_insufficient() {
when(stockClient.query(anyLong())).thenReturn(Stock.of(0)); // ④ 桩:库存为 0
BizException ex = assertThrows(BizException.class,
() -> service.create(new OrderReq(1L, 100))); // ⑤ 断言抛异常
assertEquals("库存不足", ex.getMessage());
verify(mapper, never()).save(any()); // ⑥ 验证没落库
}
}
关键点:④ 用 when 桩定依赖行为,保证测试不依赖外部;⑤ assertThrows 断言异常分支;⑥ verify(mapper, never()) 验证副作用确实没发生。这类测试跑一次几十毫秒,我们 CI 里 1200 个用例 3 分钟跑完。但问题也正出在这里——它只保证"我的代码对输入的处理是对的",完全不知道下游接口长什么样,甚至不知道下游还存不存在。
第二层:集成测试,拉起真实依赖
单元测试 mock 了数据库和中间件,但 ORM 映射、事务传播、SQL 正确性它测不到。我们用 TestContainers 起一个真实 MySQL 和 Redis:
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {
@Container // ① 起一个真实 MySQL 容器
static MySQLContainer<?> db = new MySQLContainer<>("mysql:8.0.32");
@Autowired OrderMapper mapper;
@Test
void should_persist_and_read_back() {
Order o = new Order(1L, "u1", new BigDecimal("9.9"));
mapper.save(o); // ② 真实落库
Order got = mapper.selectById(1L); // ③ 真实读回,验证映射正确
assertEquals("u1", got.getUserId());
}
}
mysql:8.0.32 这个版本号我们是锁死的,曾经因为 CI 镜像漂到 8.1 导致一个 datetime 默认行为变化,集成测试莫名失败,三个人排查了半天。集成测试的价值是兜住"代码和存储之间的契约",但它仍然测的是单个服务内部,不碰服务间。我们约定:集成测试只覆盖"和存储/中间件交互"的那一层,不向上蔓延到业务编排,否则会变得既慢又脆。
第三层:契约测试,我们漏掉的那一层
事故就出在这。订单服务(消费方)调用支付服务(提供方)的 /pay 接口,订单侧用 PayResponse 反序列化,里面有个字段 payNo。后来支付服务做了一次"无害"的重构,把 payNo 改名成 paymentNo,并没删功能,单元测试、集成测试全绿。但订单服务反序列化时 payNo 拿不到,默默成了 null,导致对账时几千笔订单对不上。我们排查了 3 小时才发现是字段改名。
契约测试就是用来拦这种事的。用 Spring Cloud Contract,由消费方定义期望、提供方校验:
// 提供方侧契约:定义"支付接口返回里必须包含 payNo"
Contract.make {
request { // ① 消费方发起的请求形态
method POST()
url("/pay")
body([orderId: 1])
}
response { // ② 提供方承诺的响应形态
status 200
body([payNo: "P123", status: "PAID"]) // ③ 这个字段一旦改名,提供方 CI 直接红
}
}
提供方在 CI 里跑这个契约,如果响应里少了 payNo,构建直接失败,根本发不到线上。我们后来规定:所有跨服务接口必须有契约测试,否则合并请求不通过。这一层救回的不只是一次字段改名,还有一次"响应里 amount 从 String 变成 BigDecimal"的隐式类型变更——单测和集成测都发现不了,因为服务提供方自己的测试用的是新类型,只有消费方的契约在断言旧形态。
第四层:E2E,端到端串真实链路
前面三层都不碰"用户视角的完整链路"。E2E 我们用 RestAssured 串起从网关到各服务的真实调用:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class CheckoutE2E {
@Test
void full_checkout_flow() {
given().contentType(JSON).body("{\"sku\":1,\"qty\":1}")
.when().post("/api/checkout") // ① 走真实网关入口
.then().statusCode(200)
.body("orderId", notNullValue()) // ② 校验端到端返回值
.body("paid", equalTo(true)); // ③ 校验支付确实完成
}
}
E2E 最慢也最脆(依赖所有服务都起得来),我们只在 nightly 跑,不进每次提交的 CI。它的作用是兜底"集成都没问题,但拼起来就错"的系统性问题,比如网关鉴权头没透传、链路超时配置不一致。我们曾经有一个 bug:单个服务测试都过,但网关把 X-User-Id 头改成 X-UserId,下游所有服务拿不到用户身份,E2E 一跑就红,单测和集成测全程绿灯——这就是 E2E 不可替代的地方。
测试数据:被忽略的第五件事
四层测试的底座是"测试数据怎么造、怎么清"。我们早期踩过坑:集成测试之间共享数据,一个测试插入的订单没清,下一个测试断言数量时莫名多一条,CI 偶发红。后来统一用 @Sql 在每个测试前后做数据准备和清理,并保证测试之间零依赖:
@Sql(scripts = "/clean.sql", executionPhase = BEFORE_TEST_METHOD) // ① 测试前清空
@Sql(scripts = "/seed-order.sql", executionPhase = BEFORE_TEST_METHOD) // ② 注入基准数据
@Test
void should_query_order_by_user() {
List<Order> list = mapper.findByUser("u1");
assertEquals(1, list.size()); // ③ 依赖确定的数据环境
}
① 和 ② 保证每个测试跑在一个已知、干净的数据环境里;③ 的断言才可靠。这条纪律比任何测试框架都重要——测试不可重复,就等于没有测试。
我的取舍
四层测试不是越多越好,是按"反馈速度"和"覆盖范围"做权衡。我的建议很明确:单元测试必须快且全,是你改代码时的安全带,覆盖率目标我们定在 70% 以上但绝不为了数字写 meaningless 测试;集成测试覆盖存储映射,数量不用多但要真实;契约测试在微服务里是刚需,漏掉它的代价我们是用一次线上事故买的,强烈建议从第一天就上;E2E 贵且慢,留少量核心链路即可,别指望靠它代替前面三层。测试金字塔如果倒过来(E2E 写得比单测多),CI 时间会拖到没人愿意跑,最后大家都不跑测试——那比没有测试更糟。最后一句大实话:测试的价值不在于"写了多少",而在于"能在你犯错的那一刻拦住你",把资源投在跨服务边界和核心链路上,性价比最高。
我们的 CI 编排:让对的测试在对的时机跑
四层测试不能一股脑全进每次提交的 CI,否则开发体验会崩。我们的做法是分层调度:单元测试和集成测试在每次 git push 触发,3-5 分钟出结果,是开发者的安全网;契约测试跟着提供方的 CI 跑,且消费方把契约存进独立仓库,提供方改接口前先跑所有消费方的契约;E2E 只进 nightly(每晚一次)和发布前,约 20 分钟,失败不影响日常提交。这套编排我们调了半年才顺:一开始 E2E 进了每次提交,平均每次构建 25 分钟,没人愿意等,结果大家开始跳过 CI 直接合代码,反而更危险。结论是——测试策略里"什么时候跑"和"测什么"一样重要,跑得太频繁比不跑还伤团队。还有一个反直觉的点:测试覆盖率数字别当 KPI,我们见过为了冲 80% 覆盖率而给 getter/setter 写测试的团队,真正的核心分支反而没覆盖。覆盖率要盯"关键路径"而不是总数。
补一句:测试代码的维护成本和业务代码一样,会随着重构腐烂。我们每季度做一次"测试体检",删掉那些永远绿色却不再覆盖任何变更的无用用例,避免测试套件变成谁都不敢动的古董。测试不是写完就完事,它和监控系统一样,需要持续投入才不会被悄悄架空。
思考题
你们团队现在卡在测试金字塔的哪一层?有没有因为"漏了契约测试"而背过锅?欢迎聊聊你踩过的测试坑。
更多推荐

所有评论(0)