"我本地单元测试都过了啊"——这句话我在故障复盘会上听过太多次。单体时代,单元测试覆盖核心逻辑确实能挡住大部分问题;但微服务拆开之后,一个服务的"对",可能正好是另一个服务的"错"。这篇文章聊我们踩坑后建立的 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 写测试的团队,真正的核心分支反而没覆盖。覆盖率要盯"关键路径"而不是总数。

补一句:测试代码的维护成本和业务代码一样,会随着重构腐烂。我们每季度做一次"测试体检",删掉那些永远绿色却不再覆盖任何变更的无用用例,避免测试套件变成谁都不敢动的古董。测试不是写完就完事,它和监控系统一样,需要持续投入才不会被悄悄架空。

思考题

你们团队现在卡在测试金字塔的哪一层?有没有因为"漏了契约测试"而背过锅?欢迎聊聊你踩过的测试坑。

更多推荐