共计 2795 个字符,预计需要花费 7 分钟才能阅读完成。
从线上故障说起
去年我们遇到一个典型的 skill 执行超时问题:当第三方语音识别服务响应缓慢时,系统没有触发熔断机制,导致用户请求堆积,最终引发整个 skill 服务雪崩。事后分析发现,测试用例仅覆盖了正常响应场景,而超时和异常状态的处理逻辑完全没有被验证。

这个案例让我深刻意识到:在微服务架构下,skill 模块的测试必须像瑞士军刀一样多维度覆盖。下面分享我们打磨出的分层测试策略。
测试方案选型对比
纯单元测试的局限性
CalculatorTest.java 展示了传统单元测试的典型写法:
@Test
void shouldReturnSumWhenInputValid() {
// Given
SkillCalculator calculator = new SkillCalculator();
// When
int result = calculator.add(2, 3);
// Then
assertEquals(5, result); // 仅验证 happy path
}
这种方式的缺陷很明显:
- 无法模拟分布式环境下的网络抖动
- 难以验证服务熔断等边界条件
- 对 I / O 密集型操作覆盖不足
SpringBootTest 的全容器之痛
虽然可以通过 @SpringBootTest 启动完整容器:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class SkillServiceIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldReturnSkillWhenServiceAvailable() {
// 实际调用 MySQL+Redis+ 第三方 API
ResponseEntity<Skill> response = restTemplate.getForEntity("/skills/1");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
但存在三大问题:
- 单用例执行时间从毫秒级暴增到秒级
- 需要维护复杂的测试数据库状态
- 第三方服务不可控导致测试不稳定
契约测试的价值主张
通过 Pact 等契约测试工具,可以建立消费者与提供者之间的接口约定:
@Pact(consumer="skill-service")
public RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("skill exists")
.uponReceiving("get skill request")
.path("/skills/1")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.integerType("id", 1)
.stringType("name", "weather"))
.toPact();}
这种方式的独特优势:
- 接口变更时能立即发现契约破坏
- 无需启动真实提供方服务
- 生成可共享的契约文档
实战测试框架搭建
边界值测试的艺术
使用 JUnit 5 的参数化测试覆盖各种边界条件:
@ParameterizedTest
@CsvSource({
"0, 0, 0", // 零值边界
"1, 1, 2", // 正常值
"-1, 1, 0", // 负值处理
"999, 1, 1000" // 上限校验
})
void testAddWithBoundaryValues(int a, int b, int expected) {assertThat(calculator.add(a, b)).isEqualTo(expected);
}
用 WireMock 模拟混沌
配置第三方 REST 服务的超时场景:
@Rule
public WireMockRule wireMock = new WireMockRule(8089);
@Test
void shouldTimeoutWhenRemoteServiceSlow() {
// 模拟 500ms 延迟响应
stubFor(get(urlEqualTo("/api/recognize"))
.willReturn(aResponse()
.withFixedDelay(500)
.withStatus(200)));
assertThatThrownBy(() -> skillClient.recognize("hello"))
.isInstanceOf(TimeoutException.class);
}
Testcontainers 集成测试
真实数据库交互的测试方案:
@Testcontainers
class SkillRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13");
@Test
void shouldSaveSkill() {Skill skill = new Skill("translate");
repository.save(skill);
assertThat(repository.findById(skill.getId()))
.isPresent()
.get().extracting(Skill::getName)
.isEqualTo("translate");
}
}
性能与质量的平衡术
CI 流水线优化策略
我们采用的梯度测试方案:
- 提交阶段(<3 分钟)
- 纯单元测试
-
静态代码分析
-
合并前阶段(<10 分钟)
- 契约测试
-
关键路径集成测试
-
每日夜间构建
- 全量集成测试
- 性能基准测试
内存泄漏检测
在集成测试中注入 LeakCanary:
@AfterEach
void checkMemoryLeak() {RefWatcher refWatcher = new RefWatcher(new AndroidDebugger(), GcTrigger.DEFAULT);
refWatcher.watch(systemUnderTest);
}
避坑指南
- 外部依赖 Mock 原则
- ✅ 可模拟:固定响应的认证服务
-
❌ 禁止模拟:银行支付网关(必须用沙箱环境)
-
测试数据管理
@Sql(scripts = "/cleanup.sql", executionPhase = AFTER_TEST_METHOD) @Test void testWithTempData() {// 测试结束后自动清理数据}
延伸思考
- 如何用 JaCoCo 的
<exclusion>标签过滤生成的异常类? - 在 TDD 的红 - 绿 - 重构循环中,契约测试应该在哪一步引入?
通过这套组合拳,我们的 skill 测试覆盖率从 65% 提升到 96%,关键路径异常场景覆盖率达到 100%。记住:好的测试套件应该像安全网,既能抓住 bug,又不妨碍开发者的空中动作。
正文完
