测试
这一课你将能做到
- 编写契约测试(Contract Testing),保证前后端接口约定不被破坏
- 使用 JMeter 或 Gatling 进行 API 负载测试,评估系统承载能力
- 用 Mock 和 WireMock 隔离外部依赖,编写可重复的集成测试
为什么要学
API 是多方协作的契约。前端按文档开发,后端改了字段没通知,上线就崩。契约测试自动检测这种破坏;负载测试告诉你系统能扛多少人;Mock 让测试不依赖外部服务,随时可跑。
先建立图像
测试金字塔(API 视角):
/\
/ \ E2E 测试(少量)
/____\ 模拟真实用户场景
/ \
/________\ 契约测试(适量)
/ \ 前后端约定校验
/____________\
/ \ 集成测试(多量)
/________________\ API + 数据库 + 外部服务
单元测试(基石)
Service/Repository 层逻辑
跟着做
步骤 1:单元测试——Service 层
@ExtendWith(MockitoExtension.class)
class LessonServiceTest {
@Mock
private LessonRepository lessonRepository;
@InjectMocks
private LessonService lessonService;
@Test
void shouldReturnLessonWhenIdExists() {
// given
Lesson lesson = Lesson.builder().id(1L).title("Spring Boot").build();
when(lessonRepository.findById(1L)).thenReturn(Optional.of(lesson));
// when
Lesson result = lessonService.findById(1L);
// then
assertThat(result.getTitle()).isEqualTo("Spring Boot");
}
@Test
void shouldThrowWhenLessonNotFound() {
when(lessonRepository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> lessonService.findById(999L))
.isInstanceOf(LessonNotFoundException.class);
}
}
步骤 2:集成测试——Controller + 数据库
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class LessonControllerIntegrationTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired
private MockMvc mockMvc;
@Autowired
private LessonRepository lessonRepository;
@BeforeEach
void setUp() {
lessonRepository.deleteAll();
}
@Test
void shouldCreateLesson() throws Exception {
mockMvc.perform(post("/api/lessons")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "Spring Boot 入门", "slug": "spring-boot-intro"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.title").value("Spring Boot 入门"));
assertThat(lessonRepository.findAll()).hasSize(1);
}
@Test
void shouldReturn404WhenLessonNotFound() throws Exception {
mockMvc.perform(get("/api/lessons/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(404))
.andExpect(jsonPath("$.message").value("课程不存在"));
}
}
步骤 3:契约测试——Spring Cloud Contract
// contracts/lesson/getLesson.groovy
Contract.make {
description "获取课程详情"
request {
method GET()
url "/api/lessons/1"
}
response {
status OK()
headers {
contentType(applicationJson())
}
body([
code: 200,
message: "success",
data: [
id: 1,
title: "Spring Boot 入门",
slug: "spring-boot-intro"
]
])
}
}
// contracts/lesson/createLesson.groovy
Contract.make {
description "创建课程"
request {
method POST()
url "/api/lessons"
headers {
contentType(applicationJson())
}
body([
title: "Spring Security",
slug: "spring-security"
])
}
response {
status CREATED()
headers {
contentType(applicationJson())
}
body([
code: 200,
message: "success",
data: [
id: regex(number()),
title: "Spring Security",
slug: "spring-security"
]
])
}
}
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.0.4</version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>com.codeforge.BaseContractTest</baseClassForTests>
</configuration>
</plugin>
// 基类
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
public abstract class BaseContractTest {
@Autowired
private MockMvc mockMvc;
@BeforeEach
void setup() {
RestAssuredMockMvc.mockMvc(mockMvc);
}
}
前端也可以用 Pact 做消费者驱动的契约测试(CDC)。
步骤 4:负载测试——JMeter
<!-- JMeter 测试计划关键配置 -->
<ThreadGroup>
<stringProp name="ThreadGroup.num_threads">100</stringProp> <!-- 100 并发用户 -->
<stringProp name="ThreadGroup.ramp_time">10</stringProp> <!-- 10 秒内启动 -->
<stringProp name="ThreadGroup.duration">60</stringProp> <!-- 持续 60 秒 -->
</ThreadGroup>
<HTTPSamplerProxy>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/api/lessons</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
</HTTPSamplerProxy>
jmeter -n -t lesson-load-test.jmx -l result.jtl -e -o report/
- 吞吐量(Throughput):每秒处理请求数
- 响应时间(Response Time):平均、P90、P95、P99
- 错误率(Error Rate):失败请求占比
- 并发用户数(Concurrent Users):同时在线用户数
步骤 5:Mock 外部服务——WireMock
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class ExternalServiceTest {
@Autowired
private WeatherService weatherService;
@Test
void shouldReturnWeather() {
// 模拟外部天气 API
stubFor(get(urlEqualTo("/api/weather?city=beijing"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"city": "beijing", "temperature": 25, "condition": "sunny"}
""")));
Weather weather = weatherService.getWeather("beijing");
assertThat(weather.getTemperature()).isEqualTo(25);
}
}
讲清楚要点
- 契约测试是前后端的「合同」:前端按契约开发,后端按契约实现,任何一方破坏契约,CI 立即失败。Spring Cloud Contract 适合 JVM 栈,Pact 适合多语言。
- 集成测试要用 Testcontainers:真实数据库(Docker 启动 MySQL),而不是 H2 内存库。H2 和 MySQL 行为有差异,测试通过不等于生产安全。
- 负载测试关注 P99,不是平均值:平均响应 100ms 看起来很好,但如果 P99 是 5s,说明有 1% 的用户体验极差。优化长尾比优化平均更重要。
- Mock 外部依赖,不是 Mock 自己的代码:测试
LessonService 时,Mock LessonRepository;测试 LessonController 时,Mock LessonService 或真实调用。不要 Mock 被测对象本身。
- 测试数据要隔离:每个测试方法独立数据,用
@Transactional 回滚或 @Sql 脚本清理。共享数据导致测试顺序依赖,极难排查。
你可能会懵的地方
-
单元测试和集成测试边界在哪?
单元测试:单类、单方法、全 Mock、毫秒级;集成测试:多类协作、真实数据库/HTTP、秒级。Controller 测试属于集成测试。
-
契约测试能替代集成测试吗?
不能。契约测试只验证「接口格式」,不验证「业务逻辑」。比如契约规定返回 {id, title},但 title 内容对不对,需要集成测试或单元测试。
-
负载测试和性能测试有什么区别?
负载测试:正常压力下系统的表现(如 100 并发);压力测试:极限压力(如 10000 并发,直到系统崩溃);稳定性测试:长时间运行(如 24 小时,看内存泄漏)。
-
WireMock 和 Mockito 有什么区别?
Mockito Mock 的是「Java 对象」(方法调用);WireMock Mock 的是「HTTP 服务」(网络请求)。测试外部 API 用 WireMock,测试内部依赖用 Mockito。
-
测试覆盖率多少合适?
核心业务(如订单、支付)80%+;工具类、Controller 60%+;Getter/Setter、配置类不强制。不要盲目追求 100%,测试也有维护成本。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 所有测试都走 Controller + 真实数据库 | 测试慢,几十分钟跑不完 | 金字塔:大量单元测试 + 适量集成测试 + 少量 E2E |
| 测试共享数据,一个失败全挂 | 顺序依赖,排查困难 | 每个测试独立数据,@Transactional 回滚 |
| Mock 所有外部依赖,不测真实调用 | 外部接口变了测试发现不了 | 契约测试 + 少量真实调用(如每天跑一次的冒烟) |
| 负载测试只看平均值 | 长尾问题被掩盖 | 关注 P90/P95/P99,优化慢查询 |
| 测试代码和生产代码一样复杂 | 维护困难,没人愿意改 | 测试代码要简单直接,允许一定的重复 |
卡点急救
- Testcontainers 启动慢:复用容器(
@Container 用 static),或本地安装数据库(CI 用容器,本地用已安装的)。
- 契约测试和实际实现不一致:契约是「理想状态」,实现是「实际状态」。以契约为准,实现向契约对齐,或更新契约(需前后端协商)。
- JMeter 压测时本机成瓶颈:JMeter 本身消耗资源。用分布式压测(多台 JMeter 机器),或换 Gatling(基于 Netty,性能更好)。
面试常问
-
怎么保证前后端接口不脱节?
契约测试(Spring Cloud Contract / Pact)+ OpenAPI 文档生成。CI 中自动校验,破坏契约则构建失败。
-
负载测试怎么做?关注哪些指标?
工具:JMeter、Gatling、k6。指标:吞吐量、响应时间(P90/P95/P99)、错误率、资源利用率(CPU/内存/磁盘/网络)。能讲出「先单接口压测,再全链路压测」的流程。
-
测试金字塔是什么?
底部大量单元测试(快、便宜),中部适量集成测试(中速),顶部少量 E2E 测试(慢、贵)。反例是「冰淇淋蛋筒」:大量 E2E,少量单元,维护噩梦。
自测三问
- 设计一个「课程搜索」接口的测试策略,需要哪些类型的测试?
- 如果负载测试发现 P99 响应时间从 100ms 涨到 2s,怎么排查?
- 为什么契约测试不能替代单元测试?
小结一句话
测试是 API 质量的保险:单元测试保逻辑,契约测试保约定,负载测试保容量,Mock 保隔离;金字塔结构让测试又快又稳。
延伸