前阵子线上出了个糟心事:刚上线的商品查询接口,平时测试都好好的,大促一到直接超时,用户刷不出商品列表,订单量掉了一大半。排查后才发现,是没做高并发压测,数据库连接池满了都没察觉 —— 这事儿让我明白,接口稳定性不是 “功能跑通” 就够的,得靠一套完整的测试体系保驾护航。
这篇文章就把我踩过坑后总结的 8 种 Spring Boot 接口测试方法分享出来,从基础的单元测试到极端场景的混沌测试,每一步都附上手把手的实战思路,新手也能跟着做,帮你把接口稳定性从 “靠运气” 变成 “有保障”。
一、单元测试:把接口逻辑的 “小 bug” 掐死在摇篮里
单元测试是最基础也最关键的一步,重点验证接口里的 “核心逻辑”—— 比如参数校验、业务规则判断、异常处理这些细节。很多人觉得 “写单元测试浪费时间”,但其实它能帮你提前发现比如 “库存扣成负数”“价格传成字符串” 这类低级但致命的问题。
怎么落地?
用 JUnit 5+Mockito 就能搞定,不用复杂配置。以 “商品库存扣减” 接口为例,核心是测 3 种场景:库存够、库存刚好、库存不够。
// 用Mockito模拟依赖,不用连真实数据库
@ExtendWith(MockitoExtension.class)
public class ProductStockServiceTest {
// 模拟库存DAO(不用真的操作数据库)
@Mock
private ProductStockDao stockDao;
// 把模拟的DAO注入到要测试的服务里
@InjectMocks
private ProductStockService stockService;
// 场景1:库存充足,扣减成功
@Test
void deductStock_success() {
// 1. 假设查出来库存有100
Long productId = 1001L;
int deductNum = 20;
when(stockDao.getStockByProductId(productId)).thenReturn(100);
// 2. 调用扣减方法
boolean result = stockService.deductStock(productId, deductNum);
// 3. 验证结果:扣减成功,且库存更新成80
assertThat(result).isTrue();
verify(stockDao, times(1)).updateStock(productId, 80);
}
// 场景2:库存不够,扣减失败
@Test
void deductStock_insufficient() {
Long productId = 1001L;
int deductNum = 30;
// 查出来库存只有20,不够扣
when(stockDao.getStockByProductId(productId)).thenReturn(20);
boolean result = stockService.deductStock(productId, deductNum);
// 验证:扣减失败,且没调用更新库存的方法
assertThat(result).isFalse();
verify(stockDao, never()).updateStock(anyLong(), anyInt());
}
}
新手小贴士:
别测私有方法:单元测试只测对外的 public 方法,私有方法通过调用 public 方法间接覆盖;
不用连真实依赖:数据库、Redis 这些都用 Mockito 模拟,不然测试会受环境影响,时好时坏;
覆盖率不用追求 100%:核心业务逻辑(比如支付、库存)要到 80% 以上,非核心的工具类不用强求。
二、API 测试:确保前后端 / 服务间 “说同一种话”
API 测试主要验证 “接口契约”—— 比如前端传什么参数、后端返回什么格式、状态码对不对。之前跟前端合作时,就因为没做 API 测试,后端把 “productId” 改成了 “id”,前端没改,上线后直接报错,排查半天才发现是字段名不匹配。
怎么落地?
用 Spring Boot 自带的 TestRestTemplate,不用启动 Postman,直接在代码里发 HTTP 请求。以 “商品查询” 接口为例,重点测 “查得到”“查不到”“参数错” 三种情况。
// 启动Spring上下文,模拟Web环境(用随机端口,不占用真实端口)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ProductControllerApiTest {
// 注入Spring内置的测试工具,用来发HTTP请求
@Autowired
private TestRestTemplate restTemplate;
// 场景1:查存在的商品,返回200和正确数据
@Test
void getProductById_success() {
Long productId = 1001L;
// 发GET请求:http://localhost:随机端口/api/product/1001
ResponseEntity<ProductDTO> response = restTemplate.getForEntity(
"/api/product/" + productId,
ProductDTO.class
);
// 验证:状态码是200,返回的商品名和价格对得上
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ProductDTO product = response.getBody();
assertThat(product.getProductName()).isEqualTo("华为Mate 60 Pro");
assertThat(product.getPrice()).isBetween(6000.0f, 7000.0f);
}
// 场景2:查不存在的商品,返回404
@Test
void getProductById_notFound() {
Long productId = 9999L; // 不存在的ID
ResponseEntity<ErrorDTO> response = restTemplate.getForEntity(
"/api/product/" + productId,
ErrorDTO.class
);
// 验证:状态码404,错误提示正确
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody().getMessage()).contains("商品不存在");
}
}
新手小贴士:
统一错误格式:所有接口的错误响应都用同一格式(比如{code: "", message: ""}),测试断言更方便;
状态码要对应:成功用 200,参数错用 400,没权限用 401,找不到资源用 404,别乱返回;
加进 CI 流水线:每次代码提交自动跑 API 测试,不然改了代码没人测,很容易出问题。
三、参数校验测试:别让 “脏数据” 溜进数据库
参数校验没做好,后果能有多严重?之前有个同事写订单接口时,没校验 “购买数量” 不能为负,结果有人恶意传了 “-10”,导致库存变成负数,后续发货时全乱了。参数校验测试就是把这些 “非法参数” 挡在外面。
怎么落地?
用 Spring Validation 的注解(比如@NotNull、@Min)先定义规则,再测 “参数缺了”“参数超范围” 这些场景。
先定义订单创建的 DTO,加校验注解:
// 订单创建参数DTO
public class OrderCreateDTO {
@NotNull(message = "用户ID不能为空") // 必传
private Long userId;
@NotNull(message = "商品ID不能为空")
private Long productId;
@Min(value = 1, message = "购买数量不能小于1") // 至少买1件
@Max(value = 10, message = "购买数量不能超过10") // 最多买10件
private Integer quantity;
// getter/setter省略
}
再写测试,故意传非法参数:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class OrderControllerValidationTest {
@Autowired
private TestRestTemplate restTemplate;
// 场景1:购买数量为0,校验失败
@Test
void createOrder_invalidQuantity() {
// 1. 构造请求:购买数量传0,违反@Min(1)
OrderCreateDTO createDTO = new OrderCreateDTO();
createDTO.setUserId(2001L);
createDTO.setProductId(1001L);
createDTO.setQuantity(0); // 非法参数
// 2. 设置请求头为JSON格式
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<OrderCreateDTO> request = new HttpEntity<>(createDTO, headers);
// 3. 发POST请求
ResponseEntity<ErrorDTO> response = restTemplate.postForEntity(
"/api/order",
request,
ErrorDTO.class
);
// 4. 验证:返回400,提示“购买数量不能小于1”
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody().getMessage()).contains("购买数量不能小于1");
}
}
新手小贴士:
自定义校验规则:比如手机号、身份证号,用@Pattern或者自定义注解,别在业务代码里写校验逻辑;
全局处理校验异常:用@ControllerAdvice统一捕获校验失败的异常,返回标准化的错误响应,不用每个接口都写一遍;
所有参数都要测:别只测必填项,可选参数、边界值(比如数量传 10000)也要测。
四、Mock 测试:不用等依赖服务,自己就能测
做支付接口时最头疼的就是 —— 支付网关还没开发完,我们的回调接口没法测。这时候 Mock 测试就派上用场了:模拟支付网关的响应,不管对方有没有开发好,我们都能正常测自己的接口。
怎么落地?
用 WireMock 这个工具,模拟第三方服务的接口。比如测 “支付回调” 接口,我们需要模拟支付网关返回 “支付成功” 和 “支付失败” 两种响应。
先加依赖:
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-spring-boot-starter</artifactId>
<version>2.35.0</version>
<scope>test</scope>
</dependency>
再写测试:
// 启用WireMock,模拟第三方服务
@WireMockTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PaymentCallbackControllerMockTest {
@Autowired
private TestRestTemplate restTemplate;
// 场景1:模拟支付网关返回“支付成功”
@Test
void handlePaymentCallback_success() {
// 1. 告诉WireMock:如果收到/post/gateway/callback的请求,就返回成功的JSON
String mockSuccessResponse = "{\n" +
" \"orderId\": \"20240610001\",\n" +
" \"payStatus\": \"SUCCESS\",\n" +
" \"tradeNo\": \"PAY202406100001\"\n" +
"}";
WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/pay/gateway/callback"))
.willReturn(WireMock.aResponse()
.withStatus(200) // 返回200状态码
.withHeader("Content-Type", "application/json")
.withBody(mockSuccessResponse)));
// 2. 构造我们自己的回调请求(模拟支付网关调用我们)
PaymentCallbackDTO callbackDTO = new PaymentCallbackDTO();
callbackDTO.setOrderId("20240610001");
callbackDTO.setPayStatus("SUCCESS");
// 3. 调用我们的回调接口
ResponseEntity<CallbackResultDTO> response = restTemplate.postForEntity(
"/api/payment/callback",
callbackDTO,
CallbackResultDTO.class
);
// 4. 验证:我们的接口返回“处理成功”
assertThat(response.getBody().getCode()).isEqualTo("SUCCESS");
}
}
新手小贴士:
模拟异常场景:除了正常响应,还要模拟第三方服务超时(用withFixedDelay(3000)加 3 秒延迟)、返回 500 错误,看我们的接口能不能处理;
别在生产用 Mock:Mock 只在测试环境用,生产环境一定要连真实的第三方服务;
复用 Mock 配置:把常用的 Mock 规则(比如支付网关、短信服务)抽成公共类,不用每个测试都写一遍。
五、压测:提前暴露高并发下的 “隐形坑”
之前做秒杀活动,接口在测试环境好好的,一上线就崩了 —— 后来才知道,是没做压测,QPS 一上来,数据库连接池就满了。压测的作用就是模拟大流量,看接口在 “忙的时候” 能不能顶住。
怎么落地?
用 JMeter 做压测,Spring Boot 加 Actuator 暴露监控指标,方便看性能数据。
第一步:加监控依赖
<!-- 暴露性能指标 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 支持Prometheus(可选,用来可视化监控) -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
配置 application.yml,开放监控端点:
management:
endpoints:
web:
exposure:
include: prometheus,health # 开放prometheus和健康检查端点
metrics:
tags:
application: product-service # 标记服务名,方便区分
第二步:用 JMeter 压测
新建 “线程组”:比如设置 1000 个线程(模拟 1000 个并发用户),循环 10 次,10 秒内逐步启动(避免瞬间打垮服务);
加 “HTTP 请求”:填接口地址(比如/api/product/list?page=1&size=20),选 GET 方法;
加 “监听器”:比如 “聚合报告”,看平均响应时间、错误率这些关键指标。
关键指标怎么看?
指标合格标准出问题了怎么办?
平均响应时间小于 300 毫秒可能是 SQL 没加索引,或者没缓存
90% 响应时间小于 500 毫秒有长尾请求,比如个别用户查大列表
错误率小于 0.1%可能是参数错,或者服务限流了
QPS满足业务峰值不够就加机器,或者做服务拆分
新手小贴士:
压测环境要像生产:数据库数据量至少 10 万级,硬件配置(CPU、内存)跟生产差不多,不然结果不准;
逐步加压:别一上来就开 1 万线程,从 100、500、1000 逐步加,看什么时候性能开始下降;
压测时看监控:重点看 CPU、内存、数据库连接池、Redis 命中率,定位瓶颈在哪。
六、容错测试:依赖挂了,接口也不能崩
微服务里,一个服务依赖的 Redis、数据库或者其他服务挂了,很容易 “连锁反应” 导致整个接口崩掉。容错测试就是验证:当依赖出问题时,我们的接口能不能 “优雅降级”,而不是直接报错。
怎么落地?
用 Resilience4j 做熔断降级,Chaos Monkey 模拟依赖故障。以 “商品详情” 接口为例,假设它依赖 Redis,Redis 挂了就返回缓存的旧数据。
第一步:加依赖
<!-- 熔断降级框架 -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.1.0</version>
</dependency>
<!-- 模拟故障的工具 -->
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>chaos-monkey-spring-boot</artifactId>
<version>2.6.2</version>
<scope>test</scope>
</dependency>
第二步:接口加容错注解
@RestController
@RequestMapping("/api/product")
public class ProductController {
@Autowired
private ProductService productService;
// 配置熔断:错误率超过50%就熔断,熔断后5秒再试
@CircuitBreaker(name = "productDetail", fallbackMethod = "getProductDetailFallback")
@GetMapping("/{id}")
public ResponseEntity<ProductDTO> getProductDetail(@PathVariable Long id) {
ProductDTO product = productService.getProductById(id); // 依赖Redis
return ResponseEntity.ok(product);
}
// 降级方法:Redis挂了就走这里,返回旧数据
public ResponseEntity<ProductDTO> getProductDetailFallback(Long id, Exception e) {
log.error</doubaocanvas>