我本地单元测试都过了啊——这句话我在故障复盘会上听过太多次。单体时代单元测试覆盖核心逻辑确实能挡住大部分问题但微服务拆开之后一个服务的对可能正好是另一个服务的错。这篇文章聊我们踩坑后建立的 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 和 RedisSpringBootTest 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() { ListOrder 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 写测试的团队真正的核心分支反而没覆盖。覆盖率要盯关键路径而不是总数。补一句测试代码的维护成本和业务代码一样会随着重构腐烂。我们每季度做一次测试体检删掉那些永远绿色却不再覆盖任何变更的无用用例避免测试套件变成谁都不敢动的古董。测试不是写完就完事它和监控系统一样需要持续投入才不会被悄悄架空。思考题你们团队现在卡在测试金字塔的哪一层有没有因为漏了契约测试而背过锅欢迎聊聊你踩过的测试坑。