ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

补上契约测试之前,我们平均每月被上游改挂 2.3 次:四层测试的投入产出账

补上契约测试之前,我们平均每月被上游改挂 2.3 次:四层测试的投入产出账 title: 补上契约测试之前我们平均每月被上游改挂 2.3 次四层测试的投入产出账tags: [微服务, 单元测试, 契约测试, Testcontainers, Pact]category: 后端那次故障的直接原因是用户服务把UserDTO里的status字段从Integer改成了String。他们那边加了枚举觉得更规范改完跑了全套单元测试绿的集成测试也过了因为集成测试只测他们自己的库。我们订单服务这边接收到status: ACTIVE的时候Jackson 反序列化直接抛InvalidFormatException下单接口 100% 失败持续 18 分钟。复盘会上有人问我们的测试覆盖率不是 78% 吗覆盖率确实是 78%但这 78% 里没有任何一行代码在验证上游给我的字段类型是不是我期待的。单元测试的 mock 是我们自己写的我们 mock 出来的UserDTO永远是对的。这篇写清楚我们后来怎么把四层测试补齐每一层拦住了什么以及哪些层其实不值得投入。先说结论四层各自能拦住什么层级拦截的问题类型单次运行耗时我们的用例数维护成本单元测试业务逻辑分支错误、边界值90 秒2140低集成测试SQL 写错、事务边界、中间件配置6 分钟310中契约测试上下游接口不兼容40 秒86中E2E端到端流程串不起来22 分钟18很高这张表里最值得注意的是最后一列。我们曾经写过 140 多个 E2E 用例跑一次要一小时四十分钟其中约三成会随机失败网络抖动、数据没清干净、异步任务没跑完。后来砍到 18 个只覆盖核心交易主链路反而更有人愿意看失败结果了。一个经常红但没人修的测试套件价值是负的——它会训练团队养成忽略失败的习惯。第一层单元测试真正的难点是把依赖切干净单元测试写起来简单写得好不容易。最常见的问题是把它写成了小型集成测试——方法里 new 了一个RestTemplate或者直接注入了真实的 Mapper。我们现在的规范是单元测试不允许启动 Spring 容器不允许有任何 IO。ExtendWith(MockitoExtension.class) class OrderPriceCalculatorTest { Mock private CouponClient couponClient; Mock private MemberLevelClient memberLevelClient; InjectMocks private OrderPriceCalculator calculator; Test DisplayName(满减券与会员折扣叠加时先算满减再算折扣) void shouldApplyCouponBeforeDiscount() { // 原价 200满 100 减 30 的券会员 9 折 // 正确顺序(200 - 30) * 0.9 153 // 错误顺序200 * 0.9 - 30 150差 3 块 when(couponClient.getCoupon(1001L)) .thenReturn(new CouponDTO(1001L, FULL_CUT, new BigDecimal(100), new BigDecimal(30))); when(memberLevelClient.getDiscount(2001L)) .thenReturn(new BigDecimal(0.9)); BigDecimal result calculator.calculate(new BigDecimal(200), 1001L, 2001L); // 用 compareTo 而不是 equalsBigDecimal 的 equals 会比较 scale // new BigDecimal(153).equals(new BigDecimal(153.0)) 返回 false assertThat(result).usingComparator(BigDecimal::compareTo) .isEqualTo(new BigDecimal(153)); } }BigDecimal那个断言的注释是我们真实踩过的。第一版用assertEquals(new BigDecimal(153), result)测试一直红排查了二十分钟才发现是 scale 不同——153和153.0在equals语义下不相等。金额相关的断言全部要用compareTo这条我们写进了代码规范。ExtendWith(MockitoExtension.class)相比老写法MockitoAnnotations.openMocks(this)的好处是它会在测试结束后自动校验是否有多余的 stub。我们打开了Strictness.STRICT_STUBS这是 JUnit 5 扩展的默认值如果你when(...)了一个从来没被调用的方法测试会直接失败。这个特性帮我们清掉了 300 多行早已废弃的 stub 代码。关于覆盖率我的态度是不设强制门槛但设强制审查点。我们不要求 80% 覆盖率但要求所有if/else分支超过 3 个的方法必须有对应测试所有涉及金额计算的方法必须有边界值测试0、负数、超大值。覆盖率数字很容易被 getter/setter 和自动生成代码刷上去看着好看实际什么也没保证。第二层集成测试Testcontainers 解决了环境不一致集成测试的老问题是本地跑用 H2线上是 MySQL 8.0H2 能过的 SQL 到 MySQL 上未必能过。我们踩过的具体案例是GROUP BY语义——MySQL 8.0 默认开启ONLY_FULL_GROUP_BYH2 不管一条在 H2 上跑得好好的统计 SQL 上线后直接报错。换成 Testcontainers 之后这类问题消失了SpringBootTest Testcontainers class OrderRepositoryIT { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0.32) .withDatabaseName(order_test) // 这里挂载的初始化脚本要和生产的 DDL 保持同源 // 我们直接指向 flyway 的迁移目录避免两份 schema 漂移 .withInitScript(db/init-schema.sql) .withReuse(true); DynamicPropertySource static void props(DynamicPropertyRegistry registry) { // 容器端口是随机分配的必须动态注入到 Spring 上下文 registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); } Resource private OrderMapper orderMapper; Test void shouldRejectDuplicateOrderNo() { orderMapper.insert(Order.of(NO-20260809-001, 100L)); // 唯一索引冲突应该抛 DuplicateKeyException而不是被静默吞掉 assertThatThrownBy(() - orderMapper.insert(Order.of(NO-20260809-001, 100L))) .isInstanceOf(DuplicateKeyException.class); } }withReuse(true)这行值得说。默认情况下每个测试类都会启一个新容器我们有 40 多个集成测试类光启容器就要八分钟。开启复用后还需要在~/.testcontainers.properties里写testcontainers.reuse.enabletrue容器在测试之间保持存活总耗时降到 6 分钟。代价是测试之间的数据隔离要自己保证——我们的做法是每个测试方法用Transactional包起来自动回滚涉及多线程的用例则手动清表。withInitScript指向 flyway 迁移目录这个细节是我们吃过亏之后加的。原来测试用一份单独维护的schema.sql生产用 flyway 迁移脚本两份东西慢慢就不一致了。有一次生产加了个NOT NULL字段测试库里那张表还是可空的所有插入测试都通过上线后批量报错。第三层契约测试开头那个事故就是它该拦的契约测试的核心思路是消费方我们声明我需要什么样的响应提供方用户服务在 CI 里验证我确实提供了这样的响应。任何一方破坏契约CI 直接红改不动。我们用的是 Pact。消费方这边先写期望ExtendWith(PactConsumerTestExt.class) PactTestFor(providerName user-service) class UserServiceContractTest { Pact(consumer order-service) public RequestResponsePact userDetailPact(PactDslWithProvider builder) { return builder .given(用户 10086 存在且状态正常) .uponReceiving(查询用户详情) .path(/api/users/10086) .method(GET) .willRespondWith() .status(200) .body(new PactDslJsonBody() .numberType(userId, 10086L) .stringType(nickname, 张三) // 关键这里声明 status 是 integer 类型 // 上游一旦改成 string他们那边的 provider 验证就会失败 .integerType(status, 1) .stringMatcher(phone, \\d{11}, 13800138000)) .toPact(); } Test PactTestFor(pactMethod userDetailPact) void shouldParseUserDetail(MockServer mockServer) { UserClient client new UserClient(mockServer.getUrl()); UserDTO dto client.getUser(10086L); assertThat(dto.getStatus()).isEqualTo(1); } }这段代码里.integerType(status, 1)就是拦住开头那个事故的那一行。它声明的不是值必须等于 1而是这个字段必须是整型。用户服务改成String之后他们 CI 里跑 provider 验证时会直接失败在合并前就被拦下。stringMatcher(phone, \\d{11}, ...)用的是正则匹配而不是固定值。契约测试要验证的是结构和格式不是具体数据写死具体值会导致上游改一条测试数据就把我们的契约打红。落地这套东西最大的阻力不是技术是流程。契约测试要求上游在 CI 里加一个验证步骤这需要跨团队推动。我们的推进方式是先只对三个最不稳定的上游接口做契约跑了两个月期间拦下了 4 次不兼容改动其中一次是把分页参数从page/size改成pageNum/pageSize拿着这个数据去说服其他团队就容易多了。拦截时机修复成本我们的统计契约测试CI 阶段15 分钟改回去或者协商新版本联调阶段发现半天两边排期对齐生产事故18 分钟故障 3 小时复盘 数据修复第四层E2E越少越好E2E 我们现在只保留 18 个用例全部围绕交易主链路注册 → 浏览 → 加购 → 下单 → 支付 → 发货 → 确认收货 → 退款。每个用例都是一条完整链路不做分支覆盖。为什么砍这么狠几个原因一是不稳定。E2E 依赖全套环境任何一个服务重启、任何一次网络抖动都会让它红。我们统计过140 个用例时代的失败中只有 11% 是真正的代码问题其余 89% 是环境问题。二是定位成本高。一个 E2E 失败了你需要看 6 个服务的日志才能知道哪一环出了问题。而同样的问题如果被集成测试或契约测试拦住失败信息直接指向那一行代码。三是运行慢。22 分钟已经是砍到 18 个用例之后的数字这意味着开发提交代码后要等半小时才知道结果这个反馈周期太长了。我们的做法是 E2E 不进 PR 流水线只在每天凌晨定时跑一次全量以及发布前手动触发一次。我不建议用 E2E 去追求覆盖率。它的定位应该是冒烟——确认主链路没断而不是验证——确认每个分支都对。分支覆盖是单元测试的活。复盘一年的真实数据我们从去年 Q2 开始补测试到今年 Q2 的对比指标改造前改造后因上游接口变更导致的故障2.3 次/月0.2 次/月PR 流水线耗时3 分钟8 分钟线上 P1/P2 故障5.1 次/月1.8 次/月E2E 用例数14018E2E 单次耗时100 分钟22 分钟测试代码占比12%34%PR 流水线从 3 分钟涨到 8 分钟是必须付出的代价团队一开始有抱怨。我们做的妥协是把集成测试从 PR 阶段挪到了合并到 develop 分支后执行PR 阶段只跑单元测试 契约测试压到 2 分 20 秒。我的取舍判断如果你的团队只有精力补一层补契约测试。单元测试大多数团队多少都有一些E2E 投入产出比很低而契约测试是微服务架构下独有的、其他层完全无法替代的一层。上面那张成本表已经说明问题了15 分钟 vs 一次生产事故。集成测试不要覆盖业务逻辑只覆盖和外部系统交互的部分。具体说就是SQL 是否正确、事务边界是否符合预期、MQ 消息是否能正常收发、Redis 的 Lua 脚本是否能执行。业务分支交给单元测试它快得多。Mock 的边界要划在进程边界上不要划在类边界上。我见过给同一个包内的两个类互相 mock 的写法测试是绿的但两个类真正组合起来时会挂。Mock 只应该出现在你不控制的东西上数据库、外部服务、时间、随机数。测试代码也是代码也需要重构。我们那 140 个 E2E 用例里有大量复制粘贴出来的重复初始化逻辑。砍到 18 个的过程中顺便抽了一套TestDataBuilder新增一个用例的成本从 200 行降到 30 行。测试写起来太麻烦人自然就不写了。最后留个问题假设你们团队接入了一个第三方支付网关对方不可能配合你们做契约测试人家不会为你改 CI。你会怎么保证他们接口变更时你能及时发现是定期跑一套针对沙箱环境的探测用例还是在生产流量里做旁路校验或者在反序列化层加宽松模式 告警三种方案在及时性、成本、误报率上分别是什么表现评论区说说你们怎么处理这类上游不受控的依赖。
返回列表