1. 导语
前面几个阶段,你已经能写出「跑得通」的
Controller、Service、Mapper。但「能跑」和「能上线」之间,隔着一道分水岭:测试。
想象一个真实场景:你改了一个订单金额计算的逻辑,本地点了几下没问题,发到生产环境后,凌晨两点库存扣减出了一个负数,用户下单成功却永远收不到货。这时你才意识到——没有自动化测试兜底,每一次改动都是一次赌博。
本阶段要解决的就是这个问题。你会学到三层武器:
- JUnit 5 + Mockito:写又快又稳的单元测试,把 Service
里的业务逻辑锁死; - 切片测试 + 集成测试:用
@WebMvcTest、@DataJpaTest只启动需要的
Bean,再配合@SpringBootTest验证真实装配; - Testcontainers:在测试里拉起真实的 MySQL
容器,让「测试环境」和「生产环境」真正一致,彻底告别 H2 假象。
学完本阶段,你能为一个订单模块写出一套完整、可运行、覆盖正常与异常分支的测试套件,并且用
TDD(测试驱动开发)的思路实现一个库存扣减功能。这是「能写生产级代码」的最后一块拼图。
前置提示:本阶段大量依赖你在阶段 3(Web
层,ApiResult/ErrorCode/BizException)和阶段
4(数据访问,MyBatis-Plus)打下的基础。如果对这两块还生疏,建议先回去复习对应文章再继续。
2. 学习目标与前置要求
学完你能…
- 说出测试金字塔的三层结构,并判断「某个逻辑该写在哪一层测试」。
- 用 JUnit 5 写出带
@BeforeEach、参数化测试、assertThrows/assertAll
的单元测试。 - 用 Mockito 的
@Mock/@InjectMocks
隔离依赖,对 Service 的正常分支和异常分支都做断言。 - 区分
@SpringBootTest、@WebMvcTest、@DataJpaTest
各自加载什么,并写出 MockMvc 的 Controller 测试。 - 用 Testcontainers 拉起真实 MySQL 容器,跑通 Mapper
的集成测试,说清「H2 与生产库」的关键差异。 - 配置 JaCoCo 生成覆盖率报告,理解「有效断言比覆盖率数字更重要」,并用
TDD 走完一次「红 → 绿 → 重构」。
前置依赖
| 依赖阶段 | 需要掌握的内容 |
|---|---|
| 阶段 3(Web 开发) | ApiResult/ErrorCode/BizException/GlobalExceptionHandler公共类;REST Controller 写法 |
| 阶段 4(数据访问) | MyBatis-Plus 的BaseMapper、实体映射、@TableName/@TableId |
| 基础 | JDK 17 语法、Maven 基础、Lombok( @Data/@Slf4j/@RequiredArgsConstructor) |
若未掌握,建议先看对应阶段文章;本阶段不会重复讲解公共类的实现原理,只复用。
3. 环境准备
3.1 依赖版本
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 17 | 全系列统一 |
| Spring Boot | 3.2.5 | 本文统一版本 |
| MyBatis-Plus | 3.5.7 | mybatis-plus-spring-boot3-starter |
| MySQL | 8.0.36 | 本地开发 + Testcontainers 容器镜像 |
| Testcontainers | 1.19.x | 由 Spring Boot BOM 管理,无需手写版本 |
| JaCoCo | 0.8.12 | 覆盖率插件 |
| Docker | 20.10+ | Testcontainers 前置依赖 |
3.2 初始化项目
用 Spring Initializr 生成骨架后,pom.xml
关键依赖如下(spring-boot-starter-test 已包含 JUnit
5、Mockito、AssertJ、Hamcrest):
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<jacoco.version>0.8.12</jacoco.version>
</properties>
<dependencies>
<!-- Web + 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MyBatis-Plus(Spring Boot 3 专用 starter) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试基础:JUnit 5 + Mockito + AssertJ + Hamcrest -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers:JUnit 5 集成 + MySQL 模块 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
为什么
junit-jupiter和mysql
不用写版本:Spring Boot 3.2 的 BOM 里已经管理了 Testcontainers
的版本,直接引入即可,避免版本冲突。
3.3 启动
MySQL(本地开发用,第 4 章测试不依赖它)
docker run -d --name mysql-dev
-e MYSQL_ROOT_PASSWORD=root
-e MYSQL_DATABASE=demo
-p 3306:3306
mysql:8.0.36
建表语句(本地开发与第 4 章 Testcontainers 共用,保存为
schema.sql):
-- 订单表
CREATE TABLE IF NOT EXISTS t_order (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
user_id BIGINT NOT NULL COMMENT '下单用户',
product_id BIGINT NOT NULL COMMENT '商品ID',
quantity INT NOT NULL COMMENT '购买数量',
total_amount DECIMAL(10, 2) NOT NULL COMMENT '订单总金额',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=待支付 2=已支付 3=已取消',
created_at DATETIME NOT NULL COMMENT '创建时间'
);
-- 库存表
CREATE TABLE IF NOT EXISTS t_stock (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT NOT NULL COMMENT '商品ID',
stock INT NOT NULL COMMENT '可用库存'
);
-- 商品表
CREATE TABLE IF NOT EXISTS t_product (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_name VARCHAR(128) NOT NULL COMMENT '商品名',
price DECIMAL(10, 2) NOT NULL COMMENT '单价'
);
3.4 配置文件
application.yml
spring:
application:
name: demo
datasource:
url: jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
configuration:
map-underscore-to-camel-case: true # 下划线列名自动映射到驼峰字段
global-config:
banner: false
测试不依赖这份配置:单元测试和切片测试根本不启动
DataSource;需要真实库的集成测试会在第 4 章用 Testcontainers
动态覆盖数据源配置。
3.5
包结构(全文统一,复用前文公共类)
src
├── main
│ ├── java/com/example/demo
│ │ ├── DemoApplication.java
│ │ ├── common/ # ApiResult、ErrorCode(复用前文,逐字一致)
│ │ ├── exception/ # BizException、GlobalExceptionHandler(复用前文)
│ │ ├── controller/ # OrderController
│ │ ├── service/ # 接口 + impl(OrderService、StockService、ProductService)
│ │ ├── mapper/ # OrderMapper、StockMapper、ProductMapper
│ │ ├── entity/ # Order、Stock、Product
│ │ ├── dto/ # CreateOrderDTO
│ │ └── vo/ # OrderVO
│ └── resources/application.yml
└── test
├── java/com/example/demo
│ ├── service/ # OrderAmountCalculatorTest、OrderServiceImplTest、StockServiceImplTest
│ ├── controller/ # OrderControllerTest
│ └── mapper/ # OrderMapperMySQLTest、StockMapperMySQLTest
└── resources/schema.sql
3.6
复用前文公共类(定义逐字一致,不重写)
下面四个类是贯穿全系列的公共基础设施,本阶段直接复用,定义与前文完全一致,你不需要重新手写。
import lombok.Data;
import org.slf4j.MDC;
@Data
public class ApiResult<T> {
private int code; // 0=成功,非 0=错误码
private String message; // 提示信息
private T data; // 业务数据
private String traceId; // 链路追踪 id
public static <T> ApiResult<T> ok(T data) {
ApiResult<T> r = new ApiResult<>();
r.setCode(ErrorCode.SUCCESS.getCode());
r.setMessage(ErrorCode.SUCCESS.getMessage());
r.setData(data);
r.setTraceId(MDC.get("traceId"));
return r;
}
public static <T> ApiResult<T> ok() {
return ok(null);
}
public static <T> ApiResult<T> fail(int code, String message) {
ApiResult<T> r = new ApiResult<>();
r.setCode(code);
r.setMessage(message);
r.setTraceId(MDC.get("traceId"));
return r;
}
public static <T> ApiResult<T> fail(ErrorCode ec) {
return fail(ec.getCode(), ec.getMessage());
}
}
package com.example.demo.common;
/**
* 统一错误码枚举。基础码与全系列保持一致,
* 本篇按订单/库存场景扩展 ORDER_NOT_FOUND / STOCK_NOT_ENOUGH。
*/
public enum ErrorCode {
SUCCESS(0, "success"),
PARAM_ERROR(40001, "参数错误"),
UNAUTHORIZED(40101, "未登录或登录已过期"),
FORBIDDEN(40301, "无权限访问"),
USER_NOT_FOUND(40401, "用户不存在"),
ORDER_NOT_FOUND(40403, "订单不存在"),
STOCK_NOT_ENOUGH(50002, "库存不足"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
package com.example.demo.exception;
import com.example.demo.common.ErrorCode;
/**
* 统一业务异常:业务代码主动抛出,由全局异常处理器统一转成 ApiResult。
*/
public class BizException extends RuntimeException {
private final int code;
public BizException(ErrorCode ec) {
super(ec.getMessage());
this.code = ec.getCode();
}
public BizException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() {
return code;
}
}
package com.example.demo.exception;
import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理器:把各种异常统一转成 ApiResult。
* 兜底异常对外只返回模糊提示,详细堆栈只进日志(防止泄露内部实现细节)。
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
// 业务异常:直接透出错误码与提示
@ExceptionHandler(BizException.class)
public ApiResult<Void> handleBiz(BizException e) {
log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
return ApiResult.fail(e.getCode(), e.getMessage());
}
// 参数校验异常(@Valid 触发):提示第一条校验错误
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(f -> f.getField() + " " + f.getDefaultMessage())
.orElse(ErrorCode.PARAM_ERROR.getMessage());
return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
}
// 兜底异常:对外模糊提示,详细堆栈进日志
@ExceptionHandler(Exception.class)
public ApiResult<Void> handleOther(Exception e) {
log.error("系统异常", e);
return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
}
}
本章小结:环境已就绪——JDK 17 + Spring Boot 3.2.5 +
MyBatis-Plus +
Testcontainers,公共类已复用,三张表和一套订单模块代码骨架即将铺开。接下来进入正文。
4. 正文章节
第 1 章 测试金字塔与 JUnit 5
1.1
测试金字塔:为什么「多写单元测试,少写端到端测试」
测试金字塔把测试按「离真实环境的距离」分成三层,越往上越真实、也越慢越贵:
/
/ E2E 测试:少而精(真实浏览器/全链路)
/---- 集成测试:中量(@SpringBootTest + 真实/容器依赖)
/------ 单元测试:最多(JUnit + Mockito,纯内存,毫秒级)
/--------
| 层级 | 用什么 | 特点 | 数量建议 |
|---|---|---|---|
| 单元测试 | JUnit 5 + Mockito | 只测一个类,依赖全 Mock,毫秒级、稳定 | 最多(70% 左右) |
| 集成测试 | @SpringBootTest + Testcontainers |
测真实 Bean 装配、真实数据库 | 中等(20% 左右) |
| E2E 测试 | Selenium/Playwright 等 | 模拟真实用户操作,慢、脆、贵 | 最少(10% 左右) |
核心原则:能用单元测试锁住的逻辑,就不要上升到集成测试。因为单元测试跑一次只要几十毫秒、且不受环境影响;而集成测试要起容器、连数据库,动辄几十秒,还容易因为环境抖动而「假失败」。
关键点:金字塔不是让你「不写集成测试」,而是让每层各司其职。业务规则(金额计算、状态流转、参数校验)放单元测试;跨组件协作(Controller
→ Service → Mapper → 数据库)放集成测试。
1.2 JUnit 5
生命周期:@BeforeEach / @AfterEach
JUnit
5(Jupiter)用注解控制测试的生命周期。先看一个最小可用示例,再解释每个注解。
被测试对象是一个「订单金额计算器」,它把单价和数量相乘,并支持满减:
package com.example.demo.service;
import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import java.math.BigDecimal;
/**
* 订单金额计算器:纯逻辑类,不依赖任何 Bean,是单元测试的最佳练手对象。
* 用 BigDecimal 而非 double,因为金额计算不允许浮点误差。
*/
public class OrderAmountCalculator {
// 满 200 减 20(满减阈值和金额用常量,便于测试和调整)
public static final BigDecimal FULL_REDUCTION_THRESHOLD = new BigDecimal("200");
public static final BigDecimal FULL_REDUCTION_AMOUNT = new BigDecimal("20");
/** 计算金额 = 单价 * 数量,入参非法直接抛业务异常 */
public BigDecimal calculate(BigDecimal unitPrice, int quantity) {
if (unitPrice == null) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品单价不能为空");
}
if (unitPrice.compareTo(BigDecimal.ZERO) <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品单价必须大于0");
}
if (quantity <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "购买数量必须大于0");
}
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
/** 满减:总金额达到阈值减固定金额,否则原样返回 */
public BigDecimal applyFullReduction(BigDecimal total) {
if (total == null) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "订单金额不能为空");
}
if (total.compareTo(FULL_REDUCTION_THRESHOLD) >= 0) {
return total.subtract(FULL_REDUCTION_AMOUNT);
}
return total;
}
}
对应测试类:
package com.example.demo.service;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
class OrderAmountCalculatorTest {
private OrderAmountCalculator calculator;
private int testCount; // 用来演示 @BeforeEach 每个测试都会重置
@BeforeEach
void setUp() {
// 每个测试方法执行前都会新建一个「干净」的 calculator,
// 避免测试之间通过共享状态互相污染
calculator = new OrderAmountCalculator();
testCount = 0;
}
@AfterEach
void tearDown() {
// 这里演示「清理资源」的位置:真实场景常用来释放临时文件、清空缓存等
calculator = null;
}
@Test
@DisplayName("正常计算:单价乘以数量")
void testCalculate() {
BigDecimal total = calculator.calculate(new BigDecimal("99.00"), 3);
// 用 compareTo 比较 BigDecimal,而不是 equals(equals 会连 scale 一起比)
assertEquals(0, new BigDecimal("297.00").compareTo(total));
testCount++;
assertEquals(1, testCount); // 证明这个测试里 testCount 是从 0 开始累加的
}
@Test
@DisplayName("满减:达到 200 减 20")
void testFullReduction() {
BigDecimal result = calculator.applyFullReduction(new BigDecimal("250.00"));
assertEquals(0, new BigDecimal("230.00").compareTo(result));
assertEquals(0, testCount); // 这个测试里的 testCount 仍然是 0(被 @BeforeEach 重置)
}
}
关键注解解释(为什么这么设计):
| 注解 | 时机 | 为什么需要 |
|---|---|---|
@BeforeEach |
每个 @Test 之前 |
保证每个测试从干净的初始状态开始,互不影响 |
@AfterEach |
每个 @Test 之后 |
释放该测试占用的资源,防止内存/连接泄漏 |
@BeforeAll |
所有测试之前(须 static) |
只执行一次的昂贵初始化(如启动容器,第 4 章会用到) |
@AfterAll |
所有测试之后(须 static) |
只执行一次的清理 |
关键点:永远不要假设测试方法有固定执行顺序。JUnit
默认按某个不确定顺序执行,你的测试必须「自给自足」——所有依赖数据都在
@BeforeEach里自己准备好。谁依赖执行顺序,谁就会写出
flaky(时好时坏)的测试。
1.3 断言:从
assertEquals 到
assertThrows、assertAll
JUnit 5 的断言全部在 org.junit.jupiter.api.Assertions
里,常见的有:
| 断言 | 作用 | 适用场景 |
|---|---|---|
assertEquals(expected, actual) |
相等 | 绝大多数场景 |
assertTrue/assertFalse |
布尔 | 结果判断、返回值校验 |
assertNull/assertNotNull |
空值 | 查不到数据返回 null 的判断 |
assertThrows(异常.class, 可执行代码) |
断言抛异常 | 异常分支测试(必会) |
assertAll(...) |
组合多个断言 | 一个对象多个字段一起校验,失败信息更全 |
fail() |
主动让测试失败 | 走到不该走的分支时标记失败 |
其中 assertThrows
是测「异常分支」的核心武器,assertAll
能避免「第一个断言失败后面全不执行」的问题:
package com.example.demo.service;
import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
class OrderAmountCalculatorAssertTest {
private final OrderAmountCalculator calculator = new OrderAmountCalculator();
@Test
@DisplayName("数量为 0 时抛参数异常")
void testInvalidQuantity() {
// assertThrows 会执行第二个参数里的代码,并断言它抛出了指定异常
BizException ex = assertThrows(BizException.class,
() -> calculator.calculate(new BigDecimal("99.00"), 0));
// 进一步断言异常里的错误码,确保抛的是「参数错误」而不是别的业务异常
assertEquals(ErrorCode.PARAM_ERROR.getCode(), ex.getCode());
}
@Test
@DisplayName("单价为负时抛参数异常")
void testNegativePrice() {
assertThrows(BizException.class,
() -> calculator.calculate(new BigDecimal("-1.00"), 1));
}
@Test
@DisplayName("assertAll:一次校验多个字段")
void testCalculateAll() {
BigDecimal total = calculator.calculate(new BigDecimal("99.00"), 2);
// assertAll 会执行所有断言并汇总失败,而不是第一个失败就停止
assertAll("金额计算校验",
() -> assertEquals(0, new BigDecimal("198.00").compareTo(total)),
() -> assertNotNull(total),
() -> assertTrue(total.compareTo(BigDecimal.ZERO) > 0)
);
}
}
关键点:断言异常时不要只断言「抛了异常」,还要断言异常里携带的错误码或消息。否则未来代码把
BizException(PARAM_ERROR)误改成
BizException(SYSTEM_ERROR),你的测试照样通过,等于没测。
1.4
参数化测试:一份逻辑,多组数据
当同一逻辑要测多组输入时,与其复制粘贴多个 @Test,不如用
@ParameterizedTest 让框架帮你循环。JUnit 5
提供了四种数据源:
@ValueSource:单参数,提供基础类型/字符串数组;@CsvSource:多参数,逗号分隔(字符串可含引号);@MethodSource:从静态方法取数据,最灵活,适合对象/复杂参数;@NullSource/@EmptySource/
@NullAndEmptySource:专测空值边界。
package com.example.demo.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.math.BigDecimal;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class OrderAmountCalculatorParamTest {
private final OrderAmountCalculator calculator = new OrderAmountCalculator();
// 单参数:同一个单价,测不同数量
@ParameterizedTest
@ValueSource(ints = {1, 2, 5, 10})
@DisplayName("合法数量:单价 * 数量")
void testCalculateWithQuantity(int quantity) {
BigDecimal expected = new BigDecimal("99.00").multiply(BigDecimal.valueOf(quantity));
assertEquals(0, expected.compareTo(calculator.calculate(new BigDecimal("99.00"), quantity)));
}
// 多参数:单价、数量、期望金额
@ParameterizedTest
@CsvSource({
"99.00, 2, 198.00",
"199.00, 1, 199.00",
"50.00, 4, 200.00"
})
@DisplayName("CSV 多组:单价/数量/期望金额")
void testCalculateCsv(String price, int quantity, String expected) {
assertEquals(0, new BigDecimal(expected).compareTo(
calculator.calculate(new BigDecimal(price), quantity)));
}
// 复杂数据源:满减的边界值(刚好等于阈值、超过阈值、低于阈值)
static Stream<org.junit.jupiter.params.provider.Arguments> provideFullReduction() {
return Stream.of(
org.junit.jupiter.params.provider.Arguments.of(new BigDecimal("199.99"), new BigDecimal("199.99")),
org.junit.jupiter.params.provider.Arguments.of(new BigDecimal("200.00"), new BigDecimal("180.00")),
org.junit.jupiter.params.provider.Arguments.of(new BigDecimal("500.00"), new BigDecimal("480.00"))
);
}
@ParameterizedTest
@MethodSource("provideFullReduction")
@DisplayName("满减边界:199.99 不减 / 200 整减 20 / 500 减 20")
void testFullReductionBoundary(BigDecimal total, BigDecimal expected) {
assertEquals(0, expected.compareTo(calculator.applyFullReduction(total)));
}
}
关键点:参数化测试的数据本身要能自解释。
200.00
这个「刚好等于阈值」的边界值,比随便填几个数字更有价值——边界是 bug
的高发地带。这也是「有效测试」和「刷数量」的区别。
1.5
其他高频注解:@BeforeAll / @Timeout /
@Disabled / @Nested
除了前面用到的,JUnit 5 还有几个在真实项目里经常出现的注解:
package com.example.demo.service;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
class OrderAmountCalculatorAdvancedTest {
private static OrderAmountCalculator sharedCalculator;
@BeforeAll
static void initOnce() {
// 只执行一次:适合昂贵的初始化,注意必须是 static。
// 第 4 章启动 Testcontainers 容器就是典型的 @BeforeAll 场景
sharedCalculator = new OrderAmountCalculator();
}
@Test
@Timeout(1) // 超过 1 秒算失败,防止死循环/慢查询拖垮整个测试套件
@DisplayName("计算必须在 1 秒内完成")
void testCalculateFast() {
BigDecimal total = sharedCalculator.calculate(new BigDecimal("99.00"), 3);
assertEquals(0, new BigDecimal("297.00").compareTo(total));
}
@Test
@Disabled("满减规则待产品确认,先临时关闭")
@DisplayName("满减:临时禁用")
void testDisabled() {
// 这个测试暂时不会运行,避免它一直红着干扰 CI
}
@Nested
@DisplayName("满减相关测试")
class FullReductionTests {
@Test
@DisplayName("达到阈值减 20")
void testHitThreshold() {
assertEquals(0, new BigDecimal("180.00").compareTo(
sharedCalculator.applyFullReduction(new BigDecimal("200.00"))));
}
}
}
| 注解 | 作用 | 为什么需要 |
|---|---|---|
@BeforeAll / @AfterAll |
所有测试前后各执行一次(须 static) |
昂贵的一次性初始化/清理(如启动容器) |
@Timeout |
限定单个测试执行时间 | 防止死循环/网络阻塞让 CI 无限等待 |
@Disabled |
临时禁用某个测试 | 已知 bug、待确认规则,先不阻塞 CI |
@Nested |
嵌套分组测试 | 把相关测试组织成层级,报告更清晰 |
@Tag("fast") |
给测试打标签 | 用 mvn test -Dgroups=fast 按标签筛选用例 |
关键点:
@Disabled
要有明确的禁用理由(写进注解参数里)。否则三个月后没人知道它为什么被关掉,测试套件里就多了一个「永远不跑的僵尸测试」。
本章小结
测试金字塔指导你「把测试放到最便宜的那层」;JUnit 5 给了你
@BeforeEach
保证隔离、assertThrows/assertAll
保证断言质量、参数化测试保证边界覆盖。这三样是后面一切测试的地基。
第 2 章 Mockito 单元测试
2.1 为什么必须 Mock 依赖
单元测试的目标是「只测当前这个类」。但一个 Service
通常依赖 Mapper、其他 Service、缓存等。如果测试
OrderServiceImpl 时真的去连数据库,会有三个问题:
- 慢:每个测试都起连接、建数据,测试套件跑一次要几分钟;
- 不稳定:数据库里数据一变,测试就挂了,和你的代码好坏无关;
- 无法控制:你没法轻松制造「库存不足」「商品不存在」这些异常场景。
Mock(模拟对象)
就是给这些依赖造一个「替身」,你想让它返回什么就返回什么。这样测试只关注
OrderServiceImpl 自己的逻辑,毫秒级跑完,且完全可控。
关键点:单元测试永远不要连真实数据库/真实外部服务。这是本阶段最核心的一条纪律。
2.2
三个核心注解:@ExtendWith / @Mock /
@InjectMocks
@ExtendWith(MockitoExtension.class) // ① 启用 Mockito
class OrderServiceImplTest {
@Mock // ② 创建依赖的 Mock 替身
private OrderMapper orderMapper;
@Mock
private StockService stockService;
@Mock
private ProductService productService;
@InjectMocks // ③ 把上面的 Mock 自动注入被测试对象
private OrderServiceImpl orderService;
}
| 注解 | 作用 | 类比 |
|---|---|---|
@ExtendWith(MockitoExtension.class) |
开启 Mockito 支持,替代手写MockitoAnnotations.openMocks(this) |
开关 |
@Mock |
给某个依赖造一个「假对象」,默认方法返回默认值(null/0/false) | 替身演员 |
@InjectMocks |
把 @Mock的对象通过构造器注入被测试类 |
导演把演员塞进戏里 |
为什么
@InjectMocks
能匹配上:因为我们的OrderServiceImpl用了
@RequiredArgsConstructor(构造器注入 +final
字段),Mockito
会优先通过构造器注入,字段名和类型都能对上。这就是全系列坚持构造器注入的另一个好处——可测试性。
2.3
被测试对象:OrderServiceImpl(含正常 + 异常分支)
先看要测试的业务代码。它依赖
OrderMapper(落库)、StockService(扣库存)、ProductService(查单价):
package com.example.demo.service.impl;
import com.example.demo.common.ErrorCode;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.entity.Order;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.OrderMapper;
import com.example.demo.service.OrderService;
import com.example.demo.service.ProductService;
import com.example.demo.service.StockService;
import com.example.demo.vo.OrderVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.atomic.AtomicLong;
/**
* 订单服务实现。注意:这里只写业务规则,不关心数据库具体实现——这正是可测试的前提。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
// 订单状态常量:避免魔法数字
public static final int STATUS_PENDING = 1; // 待支付
public static final int STATUS_PAID = 2; // 已支付
public static final int STATUS_CANCELED = 3; // 已取消
private final OrderMapper orderMapper;
private final StockService stockService;
private final ProductService productService;
// 订单号生成(教学简化版):真实生产用分布式 ID 方案(如雪花算法),这里只做演示
private final AtomicLong sequence = new AtomicLong(0);
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
@Override
@Transactional(rollbackFor = Exception.class)
public Long createOrder(CreateOrderDTO dto) {
// 1. 查单价(ProductService 内部会校验商品是否存在,不存在抛异常)
BigDecimal price = productService.getPrice(dto.getProductId());
// 2. 扣库存;返回 false 说明库存不足,抛业务异常让事务回滚
boolean deducted = stockService.deduct(dto.getProductId(), dto.getQuantity());
if (!deducted) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
// 3. 组装订单
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setUserId(dto.getUserId());
order.setProductId(dto.getProductId());
order.setQuantity(dto.getQuantity());
order.setTotalAmount(price.multiply(BigDecimal.valueOf(dto.getQuantity())));
order.setStatus(STATUS_PENDING);
order.setCreatedAt(LocalDateTime.now());
// 4. 落库
orderMapper.insert(order);
log.info("订单创建成功, orderId={}, orderNo={}", order.getId(), order.getOrderNo());
return order.getId();
}
@Override
public OrderVO getOrder(Long id) {
Order order = orderMapper.selectById(id);
if (order == null) {
// 查不到就抛「订单不存在」,而不是返回 null 让调用方自己判断
throw new BizException(ErrorCode.ORDER_NOT_FOUND);
}
return toVO(order);
}
private String generateOrderNo() {
String ts = LocalDateTime.now().format(formatter);
long seq = sequence.incrementAndGet() % 1_000_000;
return "O" + ts + String.format("%06d", seq);
}
private OrderVO toVO(Order order) {
OrderVO vo = new OrderVO();
vo.setId(order.getId());
vo.setOrderNo(order.getOrderNo());
vo.setUserId(order.getUserId());
vo.setProductId(order.getProductId());
vo.setQuantity(order.getQuantity());
vo.setTotalAmount(order.getTotalAmount());
vo.setStatus(order.getStatus());
vo.setCreatedAt(order.getCreatedAt());
return vo;
}
}
配套的接口、DTO、VO(先给出,后面测试要用):
package com.example.demo.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/** 创建订单入参:带 JSR-303 校验注解,第 3 章 Controller 测试会验证它们 */
@Data
public class CreateOrderDTO {
@NotNull(message = "用户ID不能为空")
private Long userId;
@NotNull(message = "商品ID不能为空")
private Long productId;
@NotNull(message = "购买数量不能为空")
@Min(value = 1, message = "购买数量至少为1")
private Integer quantity;
}
package com.example.demo.vo;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 订单出参:按需裁剪字段,不直接暴露 Entity */
@Data
public class OrderVO {
private Long id;
private String orderNo;
private Long userId;
private Long productId;
private Integer quantity;
private BigDecimal totalAmount;
private Integer status;
private LocalDateTime createdAt;
}
2.4 Mockito
打桩与验证:when / verify
Mockito 的核心就两件事:
- 打桩(Stubbing):
when(依赖.方法(参数)).thenReturn(值)——预设
Mock 的返回值; - 验证(Verification):
verify(依赖, times(次数)).方法(参数)——断言依赖被以某种方式调用了。
// 打桩:当调用 productService.getPrice(100L) 时,返回 99.00
when(productService.getPrice(100L)).thenReturn(new BigDecimal("99.00"));
// 打桩:让方法抛异常(模拟依赖故障)
when(productService.getPrice(999L)).thenThrow(new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品不存在"));
// 验证:断言 insert 被调用了恰好 1 次
verify(orderMapper, times(1)).insert(any(Order.class));
// 验证:断言 insert 从未被调用
verify(orderMapper, never()).insert(any(Order.class));
参数匹配器(ArgumentMatchers)用于「不关心具体值,只关心类型」的场景:
| 匹配器 | 含义 |
|---|---|
any() / any(Type.class) |
任意值(null 也可匹配) |
anyLong() / anyInt() /anyString() |
任意基础类型值 |
eq(值) |
精确等于(与其他匹配器混用时必须用它) |
argThat(...) |
自定义条件(最灵活) |
关键点(高频坑):一旦某个参数用了
any(),所有参数都必须用匹配器,不能有的用匹配器有的用字面量。比如
when(m.deduct(1L, anyInt()))会报错,要写成
when(m.deduct(eq(1L), anyInt()))。
2.5 完整单元测试:覆盖正常 +
异常分支
package com.example.demo.service;
import com.example.demo.common.ErrorCode;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.entity.Order;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.OrderMapper;
import com.example.demo.service.impl.OrderServiceImpl;
import com.example.demo.vo.OrderVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderServiceImplTest {
@Mock
private OrderMapper orderMapper;
@Mock
private StockService stockService;
@Mock
private ProductService productService;
@InjectMocks
private OrderServiceImpl orderService;
private CreateOrderDTO dto;
@BeforeEach
void setUp() {
// 每个测试前准备一份「干净」的入参,避免测试间互相污染
dto = new CreateOrderDTO();
dto.setUserId(1L);
dto.setProductId(100L);
dto.setQuantity(2);
}
// ==================== 正常分支 ====================
@Test
@DisplayName("创建订单成功:扣库存成功则落库,金额 = 单价 * 数量")
void testCreateOrderSuccess() {
// 打桩:商品单价 99 元
when(productService.getPrice(100L)).thenReturn(new BigDecimal("99.00"));
// 打桩:扣库存成功
when(stockService.deduct(100L, 2)).thenReturn(true);
Long orderId = orderService.createOrder(dto);
assertNotNull(orderId);
// 用 ArgumentCaptor 捕获真正落库的 Order 对象,校验字段值
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(orderMapper, times(1)).insert(captor.capture());
Order saved = captor.getValue();
assertEquals(0, new BigDecimal("198.00").compareTo(saved.getTotalAmount()));
assertEquals(2, saved.getQuantity());
assertEquals(OrderServiceImpl.STATUS_PENDING, saved.getStatus());
}
@Test
@DisplayName("查询订单成功:返回 VO")
void testGetOrderSuccess() {
Order order = new Order();
order.setId(1L);
order.setOrderNo("O20240101120000000001");
order.setUserId(1L);
order.setProductId(100L);
order.setQuantity(2);
order.setTotalAmount(new BigDecimal("198.00"));
order.setStatus(1);
when(orderMapper.selectById(1L)).thenReturn(order);
OrderVO vo = orderService.getOrder(1L);
assertNotNull(vo);
assertEquals("O20240101120000000001", vo.getOrderNo());
assertEquals(0, new BigDecimal("198.00").compareTo(vo.getTotalAmount()));
}
// ==================== 异常分支 ====================
@Test
@DisplayName("创建订单失败:库存不足抛 STOCK_NOT_ENOUGH 且不落库")
void testCreateOrderStockNotEnough() {
when(productService.getPrice(100L)).thenReturn(new BigDecimal("99.00"));
when(stockService.deduct(100L, 2)).thenReturn(false); // 模拟库存不足
BizException ex = assertThrows(BizException.class, () -> orderService.createOrder(dto));
assertEquals(ErrorCode.STOCK_NOT_ENOUGH.getCode(), ex.getCode());
// 关键:库存不足时,绝对不允许落库
verify(orderMapper, never()).insert(any(Order.class));
}
@Test
@DisplayName("创建订单失败:商品不存在时直接抛异常,连库存都不扣")
void testCreateOrderProductNotFound() {
when(productService.getPrice(100L))
.thenThrow(new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品不存在"));
assertThrows(BizException.class, () -> orderService.createOrder(dto));
// 商品不存在时,不应该调用扣库存,也不应该落库
verify(stockService, never()).deduct(anyLong(), anyInt());
verify(orderMapper, never()).insert(any(Order.class));
}
@Test
@DisplayName("查询订单失败:订单不存在抛 ORDER_NOT_FOUND")
void testGetOrderNotFound() {
when(orderMapper.selectById(999L)).thenReturn(null);
BizException ex = assertThrows(BizException.class, () -> orderService.getOrder(999L));
assertEquals(ErrorCode.ORDER_NOT_FOUND.getCode(), ex.getCode());
}
}
这段测试覆盖了什么:
| 分支 | 测试方法 | 断言的关键点 |
|---|---|---|
| 正常 | testCreateOrderSuccess |
金额计算正确 + 状态正确 + 落库 1 次 |
| 正常 | testGetOrderSuccess |
VO 字段映射正确 |
| 异常 | testCreateOrderStockNotEnough |
抛 STOCK_NOT_ENOUGH + 不落库 |
| 异常 | testCreateOrderProductNotFound |
抛异常 + 不扣库存不落库 |
| 异常 | testGetOrderNotFound |
抛 ORDER_NOT_FOUND |
关键点:异常分支测试最有价值的地方是**「副作用」断言**——库存不足时
never().insert()。这保证的是「业务规则」,而不仅仅是「抛了个异常」。如果只测
assertThrows,你测不到「没落库」这个约束。
2.6 @Spy:部分
Mock(了解即可)
@Mock
会替换整个对象(所有方法都返回默认值);@Spy
则包一个真实对象,默认走真实逻辑,只有你显式打桩的方法才被改写。适合「只想
Mock 掉一个方法,其余走真逻辑」的场景:
@Spy
@InjectMocks
private OrderServiceImpl orderService;
@Test
void testSpyPartial() {
// 只把 generateOrderNo 打桩成固定值,其他方法走真实逻辑
doReturn("O_TEST_000001").when(orderService).generateOrderNo();
// ...
}
为什么这里用
doReturn(...).when(...)而不是
when(...).thenReturn(...):@Spy
会真实执行方法,若用when(orderService.generateOrderNo())
会真的先调用一次该方法,可能产生副作用。对
@Spy一律用doReturn().when()或
doThrow().when()这个「先 do 后 when」的写法。同样,对
void 方法打桩也必须用
doThrow().when()。
2.7 void
方法打桩、@Mock vs @MockBean、AAA 结构
(1)void 方法怎么打桩
when(x.method()) 只能用于有返回值的方法;遇到 void
方法(比如发消息、记日志),要用 doThrow().when() 或
doNothing().when():
// 假设 StockService 多了一个 void 方法 notifyLowStock():
doThrow(new BizException(ErrorCode.SYSTEM_ERROR))
.when(stockService).notifyLowStock(anyLong());
// 验证 void 方法被调用
verify(stockService, times(1)).notifyLowStock(100L);
(2)@Mock 和 @MockBean
别搞混
| 注解 | 适用场景 | 是否启动 Spring 容器 |
|---|---|---|
@Mock |
纯单元测试(配合 @InjectMocks) |
否 |
@MockBean |
集成/切片测试里替换 Spring 容器中的 Bean | 是 |
一句话:单元测试用 @Mock,切片/集成测试用
@MockBean。前者快,后者要起容器。
(3)AAA 结构:让测试可读
生产级的测试方法通常按 AAA(Arrange 准备 / Act 执行
/ Assert 断言)三段组织:
@Test
void testCreateOrderSuccess() {
// Arrange:准备数据和打桩
when(productService.getPrice(100L)).thenReturn(new BigDecimal("99.00"));
when(stockService.deduct(100L, 2)).thenReturn(true);
// Act:执行被测方法
Long orderId = orderService.createOrder(dto);
// Assert:断言结果和副作用
assertNotNull(orderId);
verify(orderMapper, times(1)).insert(any(Order.class));
}
为什么分三段:空行分隔三段,别人(包括三个月后的你)一眼就能看出「这段测试在准备什么、测什么、验证什么」。测试的可读性和生产代码一样重要。
本章小结
Mockito 让你把 Service
从「真实依赖」中剥离出来,只测它自己的业务规则。核心是:@Mock
造替身、@InjectMocks 注入、when
打桩、verify 验证、assertThrows +
never()
覆盖异常分支的副作用。记住纪律:单元测试不连真实库。
第 3 章 切片测试与集成测试
3.1 单元测试测不到的东西
OrderServiceImplTest
全绿了,但它有个盲区:它假设
OrderMapper、StockService、ProductService
都能正常工作。而这些依赖之间、它们和 Spring
容器之间的真实协作,单元测试完全没碰。比如:
@Controller的@Valid
校验注解到底生不生效?GlobalExceptionHandler能否把BizException
转成ApiResult?@Autowired的 Bean 装配是否完整、有没有循环依赖?- Mapper 的 SQL 在真实数据库上能不能跑通?
这些要靠集成测试(启动 Spring
容器)和切片测试(只启动容器的一部分)来验证。
3.2
@SpringBootTest:启动完整容器
@SpringBootTest 会启动完整的 Spring
上下文(和真实应用几乎一致),然后你可以
@Autowired 任何 Bean:
package com.example.demo;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DemoApplicationTest {
@Test
void contextLoads() {
// 什么都不做:只要能成功启动容器,就说明 Bean 装配、配置、依赖注入都没问题
// 这是每个项目都该有的「冒烟测试」,能第一时间暴露循环依赖、缺失 Bean 等问题
}
}
@SpringBootTest
的问题:慢。每次都要扫描全部 Bean、初始化 DataSource、加载
MyBatis-Plus 等。如果每个测试类都
@SpringBootTest,套件会跑得非常慢。所以它只适合少数「真实链路」场景(第
4 章会和 Testcontainers 结合)。
关键点:
@SpringBootTest需要能连上
DataSource 才能启动成功(MyBatis-Plus 会初始化)。本阶段不用
H2,而是用第 4 章的 Testcontainers 动态提供真实
MySQL。所以这里先看概念,完整可跑版本见第 4 章。
不依赖数据库、但能验证「真实 Bean 装配 + 调用链」的完整容器测试——用
@MockBean 替换掉三个 Mapper,其余 Bean 全部真实装配:
package com.example.demo.service;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.entity.Order;
import com.example.demo.entity.Product;
import com.example.demo.entity.Stock;
import com.example.demo.mapper.OrderMapper;
import com.example.demo.mapper.ProductMapper;
import com.example.demo.mapper.StockMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 集成测试:@SpringBootTest 启动完整容器,验证真实 Bean 装配和调用链。
* 只 @MockBean 掉数据访问层(避免连真实数据库),
* OrderService -> StockService -> ProductService 的依赖注入全部真实走 Spring。
*/
@SpringBootTest
class OrderServiceIntegrationTest {
@MockBean
private OrderMapper orderMapper;
@MockBean
private StockMapper stockMapper;
@MockBean
private ProductMapper productMapper;
@Autowired
private OrderService orderService; // 真实 Bean,内部的 StockService/ProductService 也是真实 Bean
@Test
@DisplayName("真实装配:走通创建订单调用链(数据层 Mock)")
void testCreateOrderRealWiring() {
// 打桩数据层:商品存在、库存充足
Product product = new Product();
product.setId(100L);
product.setProductName("测试商品");
product.setPrice(new BigDecimal("99.00"));
when(productMapper.selectById(100L)).thenReturn(product);
Stock stock = new Stock();
stock.setId(1L);
stock.setProductId(100L);
stock.setStock(10);
when(stockMapper.deductStock(100L, 2)).thenReturn(1);
CreateOrderDTO dto = new CreateOrderDTO();
dto.setUserId(1L);
dto.setProductId(100L);
dto.setQuantity(2);
Long orderId = orderService.createOrder(dto);
assertNotNull(orderId);
verify(orderMapper, times(1)).insert(any(Order.class));
}
}
为什么这个测试有价值:单元测试用
@InjectMocks
手动装配,装配对不对其实没被验证(@InjectMocks
匹配不上依赖时会静默跳过,测试照样可能绿)。而
@SpringBootTest是让 Spring 真正把
OrderService、StockService、ProductService
装起来,任何「少写了个
@Service」「构造器参数类型不匹配」的问题都会在这里暴露。
关键点:用
@MockBean替换掉「会触发真实
IO 的 Bean」(Mapper、外部客户端),保留「纯逻辑协作的
Bean」走真实装配,是@SpringBootTest
最常见的用法——既验证装配,又不依赖数据库。
3.3
切片测试:只加载需要的那一层
切片测试是 Spring Boot 提供的「只启动一部分
Bean」的能力,比完整容器快一个数量级:
| 注解 | 加载内容 | 用途 | 不加载 |
|---|---|---|---|
@WebMvcTest |
仅 MVC 层(Controller、@ControllerAdvice、Filter等) |
测 Controller + MockMvc | Service/Mapper |
@DataJpaTest |
仅 JPA 层(Repository + 内嵌数据库) | 测 Spring Data JPA Repository | Controller/Service |
@MybatisTest |
仅 MyBatis 层(Mapper + SqlSessionFactory) | 测 MyBatis Mapper | Controller/Service |
@DataJpaTest和@MybatisTest
怎么选:取决于你的数据访问技术栈。用 Spring Data JPA 选
@DataJpaTest,用 MyBatis/MyBatis-Plus 选
@MybatisTest。本系列用 MyBatis-Plus,所以 Mapper 层切片用
@MybatisTest;@DataJpaTest是 JPA
项目里对应的等价物。
3.4
@WebMvcTest + MockMvc:测 Controller
先看被测试的 Controller:
package com.example.demo.controller;
import com.example.demo.common.ApiResult;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.service.OrderService;
import com.example.demo.vo.OrderVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public ApiResult<Long> createOrder(@Valid @RequestBody CreateOrderDTO dto) {
return ApiResult.ok(orderService.createOrder(dto));
}
@GetMapping("/{id}")
public ApiResult<OrderVO> getOrder(@PathVariable Long id) {
return ApiResult.ok(orderService.getOrder(id));
}
}
对应的 @WebMvcTest 测试,用 MockMvc 模拟
HTTP 请求、@MockBean 替换掉 Service:
package com.example.demo.controller;
import com.example.demo.common.ErrorCode;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.exception.BizException;
import com.example.demo.service.OrderService;
import com.example.demo.vo.OrderVO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 切片测试:只加载 MVC 层,用 @MockBean 替换掉 OrderService。
* 注意这里不需要启动 DataSource,跑得飞快。
*/
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc; // 模拟 HTTP 请求
@Autowired
private ObjectMapper objectMapper; // 序列化 JSON
@MockBean
private OrderService orderService; // 替换掉真实的 Service
// ==================== 正常分支 ====================
@Test
@DisplayName("创建订单成功:返回 code=0 和订单 ID")
void testCreateOrderSuccess() throws Exception {
CreateOrderDTO dto = new CreateOrderDTO();
dto.setUserId(1L);
dto.setProductId(100L);
dto.setQuantity(2);
when(orderService.createOrder(any(CreateOrderDTO.class))).thenReturn(1L);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(1));
}
@Test
@DisplayName("查询订单成功:返回订单数据")
void testGetOrderSuccess() throws Exception {
OrderVO vo = new OrderVO();
vo.setId(1L);
vo.setOrderNo("O20240101120000000001");
vo.setTotalAmount(new BigDecimal("198.00"));
when(orderService.getOrder(1L)).thenReturn(vo);
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.orderNo").value("O20240101120000000001"));
}
// ==================== 异常分支 ====================
@Test
@DisplayName("创建订单:数量为 0 触发 @Valid 校验,返回 PARAM_ERROR")
void testCreateOrderValidationFail() throws Exception {
// quantity=0 违反了 @Min(value=1),应被 GlobalExceptionHandler 转成参数错误码
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{"userId":1,"productId":100,"quantity":0}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.PARAM_ERROR.getCode()))
.andExpect(jsonPath("$.message").value(containsString("购买数量")));
}
@Test
@DisplayName("查询订单:订单不存在返回 ORDER_NOT_FOUND")
void testGetOrderNotFound() throws Exception {
when(orderService.getOrder(999L))
.thenThrow(new BizException(ErrorCode.ORDER_NOT_FOUND));
mockMvc.perform(get("/api/orders/999"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ErrorCode.ORDER_NOT_FOUND.getCode()));
}
}
这个测试验证了什么:
- URL 映射:
POST /api/orders确实命中
createOrder; - JSON 序列化/反序列化:DTO 能被正确解析成对象;
- 参数校验:
@Valid+@Min
生效,非法入参被拦下; - 全局异常处理:
BizException被
GlobalExceptionHandler正确转成
ApiResult(code=40403)。
关键点:
@WebMvcTest会自动加载
@ControllerAdvice(也就是
GlobalExceptionHandler),所以异常分支的断言是真实走了一遍「异常
→ 处理器 → 响应体」的链路,这比单元测试更接近真实。
版本提示:
@MockBean在 Spring Boot
3.4+(Spring Framework 6.2)被标记为废弃,替代品是
@MockitoBean。在本文的 Spring Boot 3.2.x 里
@MockBean完全可用,但你心里要有个数。
再补几个 MockMvc 的高频用法,它们在真实测试里几乎必用:
// 1. 带查询参数和请求头的 GET 请求
mockMvc.perform(get("/api/orders")
.param("page", "1")
.param("size", "10")
.header("X-User-Id", "1"))
.andExpect(status().isOk());
// 2. print():把完整请求/响应打印到控制台,排错神器
mockMvc.perform(get("/api/orders/1"))
.andDo(print());
// 3. 断言响应头 + 空 body 触发参数校验
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Type", containsString("application/json")))
.andExpect(jsonPath("$.code").value(ErrorCode.PARAM_ERROR.getCode()));
// 4. 访问不存在的路径,断言 404
mockMvc.perform(get("/api/not-exist"))
.andExpect(status().isNotFound());
jsonPath 的常用表达式(基于返回的
ApiResult 结构):
| 表达式 | 含义 |
|---|---|
$.code |
取顶层 code 字段 |
$.data.orderNo |
取 data 里的嵌套字段 |
$.data[0].id |
取 data 数组第一个元素的 id |
$.data.length() |
数组长度 |
$.data[?(@.status == 1)] |
过滤 status=1 的元素 |
关键点:
jsonPath
断言的是响应体的结构,而不仅是「HTTP 200」。因为我们的
GlobalExceptionHandler对业务异常也返回 HTTP
200(只有真正的系统错误才 500),所以必须靠$.code
区分成功和失败——这是本系列「统一响应体」设计下的测试要点。
3.5
@MybatisTest /
@DataJpaTest:只测数据访问层
@WebMvcTest 测 Controller,数据访问层则用
@MybatisTest(本系列)或 @DataJpaTest(JPA
项目)单独测。二者默认会尝试使用内嵌数据库,本阶段不用
H2,而是配合真实数据库来跑(第 4 章展开)。这里先给出
@MybatisTest 的骨架:
package com.example.demo.mapper;
import com.example.demo.entity.Order;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.boot.test.autoconfigure.MybatisTest;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* 切片测试:只加载 MyBatis 层(Mapper + SqlSessionFactory),不加载 Controller/Service。
* 完整可跑版本见第 4 章(那里会配上真实 MySQL 容器)。
*/
@MybatisTest
class OrderMapperSliceTest {
@Autowired
private OrderMapper orderMapper;
@Test
@DisplayName("插入订单后能查到")
void testInsertAndSelect() {
Order order = new Order();
order.setOrderNo("O20240101120000000001");
order.setUserId(1L);
order.setProductId(100L);
order.setQuantity(2);
order.setTotalAmount(new BigDecimal("198.00"));
order.setStatus(1);
orderMapper.insert(order);
assertNotNull(order.getId());
}
}
关键点:
@MybatisTest
默认用内嵌数据库(H2)替代真实库。H2 虽然快,但和 MySQL 行为不一致(见第
4 章),所以本系列的 Mapper 集成测试统一交给 Testcontainers,而不是
@MybatisTest
的默认内嵌库。这里给出骨架是为了让你理解「切片」的概念。
3.6
@DataJpaTest:JPA 项目的 Repository 切片测试
本系列用 MyBatis-Plus,但很多项目用 Spring Data JPA。为了让你对
@DataJpaTest 有完整认识,这里给一个等价示例(假设用
JPA):
// JPA 实体(仅作 @DataJpaTest 演示,与本系列 MyBatis-Plus 实体并存时注意区分)
package com.example.demo.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.math.BigDecimal;
@Data
@Entity
@Table(name = "t_product")
public class ProductJpa {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "product_name", nullable = false)
private String productName;
@Column(nullable = false)
private BigDecimal price;
}
package com.example.demo.mapper;
import com.example.demo.entity.ProductJpa;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductJpaRepository extends JpaRepository<ProductJpa, Long> {
}
package com.example.demo.mapper;
import com.example.demo.entity.ProductJpa;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.math.BigDecimal;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
/**
* 切片测试:只加载 JPA 层(EntityManager + Repository),不加载 Controller/Service。
* 默认用内嵌数据库(H2),启动飞快。
*/
@DataJpaTest
class ProductJpaRepositoryTest {
@Autowired
private ProductJpaRepository repository;
@Test
@DisplayName("保存后能按 ID 查到")
void testSaveAndFind() {
ProductJpa p = new ProductJpa();
p.setProductName("测试商品");
p.setPrice(new BigDecimal("99.00"));
repository.save(p);
Optional<ProductJpa> found = repository.findById(p.getId());
assertTrue(found.isPresent());
assertEquals("测试商品", found.get().getProductName());
}
}
关键点:
@DataJpaTest默认用 H2
内嵌库,这恰恰是第 4 章要解决的问题——如果 Repository
里用了 MySQL 特有语法或原生 SQL,H2 测不出来。所以 JPA
项目同样建议在关键 SQL 上改用 Testcontainers 的真实 MySQL。
本章小结
@SpringBootTest
启动完整容器(慢、真),@WebMvcTest 只启动 MVC
层(快、够用),@MybatisTest/@DataJpaTest
只启动数据层。日常优先用切片测试,只有跨层协作的关键链路才上完整容器。
第 4 章
Testcontainers:用真实依赖测试
4.1 为什么「H2
测试通过,生产报错」
很多项目图省事,测试环境用 H2 内存库,生产用
MySQL。这埋了一个大雷:H2 和 MySQL
不是同一个数据库,语法和行为都可能不同。几个真实差异:
| 差异点 | H2(或默认内嵌库) | MySQL 8.0 | 后果 |
|---|---|---|---|
| 分页语法 | LIMIT ? OFFSET ?(新 H2 兼容 MySQL 模式) |
LIMIT ?, ? |
SQL 写法不同,移植报错 |
| 自增主键 | IDENTITY |
AUTO_INCREMENT |
建表语句不通用 |
| 字符集/排序 | 默认不同 | utf8mb4 + _0900_ai_ci |
中文排序、大小写比较结果不同 |
| 特有函数 | 无 ON DUPLICATE KEY UPDATE |
支持 | 用了 MySQL 特有语法,H2 直接报错 |
| 日期函数 | 行为不完全一致 | NOW()、时区处理 |
时间相关断言时好时坏 |
Testcontainers 的解法:测试时用 Docker
拉起一个和生产的 MySQL
版本一致的真实容器,测试跑在真实数据库上,H2
的「假象」就彻底消失了。
具体到 SQL 语法,一个典型翻车现场:
-- 分页 SQL:MySQL 和 H2 默认写法不同
-- MySQL 写法(本系列目标)
SELECT * FROM t_order ORDER BY id LIMIT 0, 10;
-- H2 默认模式下的写法(若不开 MySQL 兼容模式)
SELECT * FROM t_order ORDER BY id LIMIT 10 OFFSET 0;
-- 「存在则更新」:MySQL 支持,H2 直接报语法错
INSERT INTO t_stock(product_id, stock) VALUES(100, 10)
ON DUPLICATE KEY UPDATE stock = stock + 10;
这些差异不会在单元测试里暴露(因为不连库),只会在「H2 测试通过、生产
MySQL 报错」的现场暴露。Testcontainers 让测试直接在真实 MySQL
上跑,从根上消除这类问题。
关键点:Testcontainers 不只用于
MySQL——Redis、RabbitMQ、Kafka、PostgreSQL 等任何有 Docker
镜像的依赖都能拉起。掌握了 MySQL 这个例子,其他依赖是同样的套路。
4.2 前置条件与依赖
- 本机装好 Docker,且
docker ps能跑通; pom.xml已引入
testcontainers:junit-jupiter和
testcontainers:mysql(见 3.2);- 准备
src/test/resources/schema.sql(内容同 3.3
的建表语句)。
4.3
第一个 Testcontainers 测试:@Container +
@DynamicPropertySource
package com.example.demo.mapper;
import com.example.demo.entity.Order;
import com.example.demo.mapper.OrderMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
/**
* 集成测试:@SpringBootTest 启动完整容器,Testcontainers 提供真实 MySQL。
* 这是「真实链路」测试——SQL 真的在 MySQL 8.0 上执行。
*/
@SpringBootTest
@Testcontainers
class OrderMapperMySQLTest {
// @Container:声明一个 MySQL 8.0 容器,测试类运行期间自动启动/销毁
// 镜像版本与生产一致,避免「测试库和生产库行为不同」
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("demo_test")
.withUsername("test")
.withPassword("test")
.withInitScript("schema.sql"); // 容器启动时自动执行建表语句
// 把容器的连接信息「动态注入」到 Spring 环境,覆盖 application.yml 里的数据源配置
@DynamicPropertySource
static void datasourceProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName);
}
@Autowired
private OrderMapper orderMapper;
@Test
@DisplayName("真实 MySQL:插入订单后能查到")
void testInsertAndSelect() {
Order order = new Order();
order.setOrderNo("O20240101120000000001");
order.setUserId(1L);
order.setProductId(100L);
order.setQuantity(2);
order.setTotalAmount(new BigDecimal("198.00"));
order.setStatus(1);
order.setCreatedAt(LocalDateTime.now());
// 真实执行 INSERT
orderMapper.insert(order);
// 自增主键被回填,证明真的落了 MySQL
assertNotNull(order.getId());
// 真实执行 SELECT
Order found = orderMapper.selectById(order.getId());
assertNotNull(found);
assertEquals("O20240101120000000001", found.getOrderNo());
assertEquals(0, new BigDecimal("198.00").compareTo(found.getTotalAmount()));
}
}
逐个注解解释(为什么这么写):
| 注解/写法 | 作用 |
|---|---|
@Testcontainers |
开启 Testcontainers 的 JUnit 5 集成,自动管理容器生命周期 |
@Container + static |
静态容器在所有测试共享,启动一次复用;非静态则每个测试方法都新建一个(慢) |
.withInitScript("schema.sql") |
容器启动后、测试前自动执行建表脚本 |
@DynamicPropertySource |
在容器拿到随机端口后,把真实连接信息注入 Spring 环境(这是动态的,因为容器端口是随机分配的) |
关键点:容器端口是 Docker
随机分配的,你没法写死在配置文件里。所以必须用
@DynamicPropertySource在运行时把
mysql.getJdbcUrl()等动态值注入 Spring 环境,这也是它和普通
application.yml的区别。
4.4 用真实 MySQL
测「条件更新」:库存扣减不超卖
第 2 章里 StockServiceImpl 的核心是一条条件更新
SQL(WHERE stock >= quantity),这是防止超卖的关键。这条
SQL 在 H2 上未必能和 MySQL 行为一致,必须用真实库验证。先看 Mapper:
package com.example.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Stock;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface StockMapper extends BaseMapper<Stock> {
/**
* 条件扣减:只有当 stock >= quantity 时才更新。
* 返回影响行数:1=扣减成功,0=库存不足。
* 这是「防超卖」的核心——把「判断」和「扣减」合并成一条原子 SQL,避免并发下先查后改的竞态。
*/
@Update("UPDATE t_stock SET stock = stock - #{quantity} " +
"WHERE product_id = #{productId} AND stock >= #{quantity}")
int deductStock(@Param("productId") Long productId, @Param("quantity") int quantity);
}
配套的 Stock 实体和 StockServiceImpl(第 5
章 TDD 会完整实现,这里先给最终版):
package com.example.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("t_stock")
public class Stock {
@TableId(type = IdType.AUTO)
private Long id;
private Long productId;
private Integer stock;
}
真实 MySQL 测试,验证「扣减成功」和「库存不足返回 0」两个分支:
package com.example.demo.mapper;
import com.example.demo.entity.Stock;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Testcontainers
class StockMapperMySQLTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("demo_test")
.withUsername("test")
.withPassword("test")
.withInitScript("schema.sql");
@DynamicPropertySource
static void datasourceProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName);
}
@Autowired
private StockMapper stockMapper;
@Test
@DisplayName("库存充足:扣减成功,库存减少")
void testDeductSuccess() {
Stock stock = new Stock();
stock.setProductId(100L);
stock.setStock(10);
stockMapper.insert(stock);
int rows = stockMapper.deductStock(100L, 3);
assertEquals(1, rows); // 影响 1 行 = 扣减成功
Stock updated = stockMapper.selectById(stock.getId());
assertEquals(7, updated.getStock()); // 10 - 3 = 7
}
@Test
@DisplayName("库存不足:返回 0,库存不变(防超卖)")
void testDeductNotEnough() {
Stock stock = new Stock();
stock.setProductId(200L);
stock.setStock(2);
stockMapper.insert(stock);
int rows = stockMapper.deductStock(200L, 5);
assertEquals(0, rows); // 0 行 = 库存不足,什么都没扣
Stock unchanged = stockMapper.selectById(stock.getId());
assertEquals(2, unchanged.getStock()); // 库存保持 2,没被扣成负数
}
}
关键点:第二个测试是本阶段的精华。它验证了「库存不足时不会扣成负数」这个约束,而这条约束是由
WHERE stock >= #{quantity}这条 SQL
保证的。这种测试放在 H2
上跑,很可能因为方言差异得出错误结论,只有真实 MySQL
才有说服力。
4.5 复用容器:把 40 秒压到 5
秒
上面每个测试类都用 @Container 起自己的
MySQL,两个类就是两个容器,跑一次可能 40
秒以上。生产实践里会用一个静态容器在所有测试类间共享。做法是抽一个抽象基类:
package com.example.demo;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.lifecycle.Startables;
/**
* 所有需要真实 MySQL 的测试类继承它,共享同一个容器,大幅缩短测试时间。
* 容器只在 JVM 内启动一次(static 初始化块),所有子类复用。
*/
@Testcontainers
public abstract class AbstractMySQLContainerTest {
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("demo_test")
.withUsername("test")
.withPassword("test")
.withInitScript("schema.sql");
static {
// 类加载时就启动容器,而不是等到第一个测试方法
Startables.deepStart(MYSQL).join();
}
@DynamicPropertySource
static void datasourceProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.datasource.driver-class-name", MYSQL::getDriverClassName);
}
}
继承后,子类变得干净:
@SpringBootTest
class OrderMapperMySQLTest extends AbstractMySQLContainerTest {
@Autowired
private OrderMapper orderMapper;
@Test
void testInsertAndSelect() {
// 直接写业务断言,容器的启动/注入/清理都在基类里搞定
}
}
关键点:容器共享后,要注意测试之间通过共享数据互相污染的问题——因为所有测试类共用一个库。解决方式是每个测试自己
@BeforeEach
清理相关表,或用随机唯一的数据(比如每个测试用不同的
productId/orderNo)。
4.6
@ServiceConnection:Spring Boot 3.1+ 的新写法
Spring Boot 3.1 引入了 @ServiceConnection,可以省掉
@DynamicPropertySource 的手动绑定:
@SpringBootTest
@Testcontainers
class OrderMapperMySQLTest {
@Container
@ServiceConnection // 自动识别 MySQL 容器并配置数据源,替代 @DynamicPropertySource
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36")
.withDatabaseName("demo_test")
.withUsername("test")
.withPassword("test");
}
两种写法怎么选:
@ServiceConnection
更简洁,但要求 Spring Boot 3.1+ 且容器类型被 Spring
识别;@DynamicPropertySource
更通用、可控,能看到每一项配置从哪来。本文主推
@DynamicPropertySource(因为它显式、不黑盒),@ServiceConnection
作为备选。
4.7 真实链路:OrderService
端到端测试
把前面的单元测试、切片测试、Mapper
集成测试串起来,写一个「端到端」的集成测试:真实 MySQL + 真实 Bean
全链路,从下单到查单:
package com.example.demo.service;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.entity.Product;
import com.example.demo.entity.Stock;
import com.example.demo.mapper.ProductMapper;
import com.example.demo.mapper.StockMapper;
import com.example.demo.vo.OrderVO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
/**
* 端到端集成测试:真实 MySQL 容器 + 真实 Bean(不加任何 Mock)。
* 走完整链路:Service -> ProductService -> StockService -> Mapper -> MySQL。
*/
@SpringBootTest
class OrderServiceEndToEndTest extends AbstractMySQLContainerTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductMapper productMapper;
@Autowired
private StockMapper stockMapper;
@BeforeEach
void setUpData() {
// 每个测试自备数据:商品 100,库存 10
Product product = new Product();
product.setId(100L);
product.setProductName("测试商品");
product.setPrice(new BigDecimal("99.00"));
productMapper.insert(product);
Stock stock = new Stock();
stock.setProductId(100L);
stock.setStock(10);
stockMapper.insert(stock);
}
@Test
@DisplayName("端到端:下单成功后能查到订单,且库存被真实扣减")
void testCreateAndGetOrder() {
CreateOrderDTO dto = new CreateOrderDTO();
dto.setUserId(1L);
dto.setProductId(100L);
dto.setQuantity(2);
Long orderId = orderService.createOrder(dto);
assertNotNull(orderId);
OrderVO vo = orderService.getOrder(orderId);
assertEquals(0, new BigDecimal("198.00").compareTo(vo.getTotalAmount()));
// 真实库存:10 - 2 = 8,验证扣库存真的落到了 MySQL
Stock updated = stockMapper.selectOne(
new LambdaQueryWrapper<Stock>().eq(Stock::getProductId, 100L));
assertEquals(8, updated.getStock());
}
}
关键点:这是「最终验收」型测试——它验证的是整条业务链路真实可用,而不是某个类的局部正确。它慢,所以只保留少数几条「关键业务闭环」,不要为每个小方法都写端到端测试。
本章小结
Testcontainers 用「真实容器」替换「内嵌假库」,解决了 H2 与 MySQL
行为不一致的核心痛点。核心套路:@Testcontainers +
@Container(静态共享)+
@DynamicPropertySource(动态注入连接信息)。用真实 MySQL
验证「防超卖」的条件更新,是它最有价值的应用场景。
第 5 章 覆盖率与 TDD
5.1 覆盖率是什么、为什么要测
覆盖率(Code
Coverage)回答一个朴素的问题:我的代码有多少行真的被测试跑到了。它是「测试写得够不够」的一个量化指标,但不是唯一指标。
JaCoCo 是 Java 生态最主流的覆盖率工具,它统计三类指标:
| 指标 | 含义 | 说明 |
|---|---|---|
| 行覆盖(Line) | 有多少行代码被执行过 | 最直观,也是大家常说的「覆盖率 80%」 |
| 分支覆盖(Branch) | if/switch 的每个分支是否都走过 |
比行覆盖更严格,更能暴露「只测了 if 没测 else」 |
| 指令覆盖(Instruction) | 字节码指令的执行比例 | 最底层,一般和行覆盖一起看 |
关键点(本节最重要的观点):覆盖率是下限,不是目标。一个测试调用了某行代码却不断言结果,覆盖率照样
+1,但它毫无价值。所以「有效断言 >
覆盖率数字」——先保证每个测试都断到了点上,再看覆盖率数字。
5.2 配置 JaCoCo
在 pom.xml 里加 JaCoCo 插件:
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<!-- prepare-agent:测试运行前挂载探针,收集覆盖率数据 -->
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- report:test 阶段结束后生成报告 -->
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
<configuration>
<!-- 排除不参与业务逻辑的类,避免它们拉低/虚高覆盖率:
Entity/DTO/VO 是纯字段,Config/启动类是框架代码 -->
<excludes>
<exclude>com/example/demo/entity/**</exclude>
<exclude>com/example/demo/dto/**</exclude>
<exclude>com/example/demo/vo/**</exclude>
<exclude>com/example/demo/common/**</exclude>
<exclude>com/example/demo/config/**</exclude>
<exclude>com/example/demo/DemoApplication.class</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
生成报告:
# 跑测试并生成覆盖率报告(因为 report 绑定在 test 阶段,一条命令即可)
mvn clean test
# 报告位置
open target/site/jacoco/index.html
打开报告后,你会看到每个包、每个类、每个方法的覆盖率,点进
OrderServiceImpl
能看到哪些行是绿色(已覆盖)、哪些是红色(未覆盖)、哪些是黄色(部分覆盖)。
关键点:红色(未覆盖)行才是你该重点关注的——它们往往是异常分支、边界条件、防御性代码。盯着红色行补测试,比盲目追求整体数字有意义得多。
如果你想让「覆盖率不达标就构建失败」,可以再加一个 check
goal(把覆盖率变成 CI 门禁):
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<!-- 核心业务整体行覆盖率低于 80% 直接让构建失败 -->
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<!-- 分支覆盖率建议 70% 起步,分支是 bug 高发地 -->
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
关键点:
check
是把覆盖率从「参考数字」变成「硬约束」。但它只该约束核心业务包(配合前面的
excludes排除
Entity/DTO/Config),别对着全项目一刀切——否则你会被迫为 getter/setter
写一堆垃圾测试来凑数。
5.3 有效断言 >
覆盖率数字(一个反例)
看这个「假覆盖率」测试:
// 反例:这个方法会让覆盖率 +1,但什么都没验证
@Test
void testUseless() {
orderService.getOrder(1L); // 只调用,不断言。抛异常也不影响——如果没异常它照样通过
}
它执行了 getOrder 的代码,覆盖率上去了,但即使
getOrder
把订单号算错、状态写错,这个测试也照样绿。这就是「刷覆盖率」。
正确的做法:每个测试至少要有一个有意义的断言(assertEquals/assertThrows/verify),并且断言的是「业务结果」而非「执行过程」。再对比一组「有效
vs 无效」断言:
// 无效:只调不断言,覆盖率 +1,但毫无价值
@Test
void testUseless() {
orderService.getOrder(1L); // 结果对不对?不知道,反正不报错就算过
}
// 有效:断言了业务结果和异常分支
@Test
void testMeaningful() {
when(orderMapper.selectById(1L)).thenReturn(order);
OrderVO vo = orderService.getOrder(1L);
assertEquals("O20240101120000000001", vo.getOrderNo()); // 断言业务结果
when(orderMapper.selectById(999L)).thenReturn(null);
assertThrows(BizException.class, () -> orderService.getOrder(999L)); // 断言异常
}
一句话记住:如果一个测试删掉所有断言后仍然「通过」,那它就不是测试,只是「执行了一遍代码」。
5.4 TDD:Red-Green-Refactor
TDD(Test-Driven
Development,测试驱动开发)把「写测试」从「事后补」变成「事前驱动」,三步循环:
- Red(红):先写一个失败的测试——它描述了你想要的行为,但因为功能还没实现,测试跑不过;
- Green(绿):写最少的代码让测试通过,不写任何多余逻辑;
- Refactor(重构):在测试全绿的保护下,安全地优化代码结构。
为什么先写失败测试:先写测试迫使你先想清楚「输入什么、期望输出什么、边界在哪」,而不是边写代码边糊弄。而且一个「先红后绿」的过程能证明:你的测试真的在测东西(而不是永远绿的空壳)。
5.5 完整 TDD
演示:库存扣减功能
下面用 TDD 实现 StockServiceImpl 的 deduct
方法(库存扣减),走完「红 → 绿 → 重构」。
第一步(Red):先写失败测试
package com.example.demo.service;
import com.example.demo.common.ErrorCode;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.StockMapper;
import com.example.demo.service.impl.StockServiceImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
/**
* TDD 第一步:在 StockServiceImpl 还不存在(或还没实现)时,先写下期望行为的测试。
*/
@ExtendWith(MockitoExtension.class)
class StockServiceImplTest {
@Mock
private StockMapper stockMapper;
@InjectMocks
private StockServiceImpl stockService;
@Test
@DisplayName("库存充足:扣减成功返回 true")
void testDeductSuccess() {
// 打桩:影响 1 行 = 扣减成功
when(stockMapper.deductStock(100L, 3)).thenReturn(1);
boolean result = stockService.deduct(100L, 3);
assertTrue(result);
verify(stockMapper, times(1)).deductStock(100L, 3);
}
@Test
@DisplayName("库存不足:扣减失败返回 false")
void testDeductNotEnough() {
when(stockMapper.deductStock(100L, 99)).thenReturn(0); // 0 行 = 库存不足
boolean result = stockService.deduct(100L, 99);
assertFalse(result);
}
@Test
@DisplayName("数量非法:抛 PARAM_ERROR,且不调用 Mapper")
void testDeductInvalidQuantity() {
BizException ex = assertThrows(BizException.class, () -> stockService.deduct(100L, 0));
assertEquals(ErrorCode.PARAM_ERROR.getCode(), ex.getCode());
// 参数非法时,连数据库都不该碰
verify(stockMapper, never()).deductStock(anyLong(), anyInt());
}
}
第二步(Red 运行):确认测试失败
mvn test -Dtest=StockServiceImplTest
此时会看到编译错误或断言失败——因为
StockServiceImpl
还不存在,或者即便存在也没做参数校验。这一步的「红」是预期结果,它证明了你的测试真的在约束行为。
第三步(Green):写最少代码让它变绿
package com.example.demo.service.impl;
import com.example.demo.common.ErrorCode;
import com.example.demo.entity.Stock;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.StockMapper;
import com.example.demo.service.StockService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class StockServiceImpl implements StockService {
private final StockMapper stockMapper;
@Override
public boolean deduct(Long productId, int quantity) {
// 参数校验:非法入参直接拒绝,避免脏数据进入 SQL
if (productId == null || productId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品ID非法");
}
if (quantity <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "扣减数量必须大于0");
}
// 条件扣减:影响 1 行=成功,0 行=库存不足
int rows = stockMapper.deductStock(productId, quantity);
return rows > 0;
}
@Override
public int getStock(Long productId) {
Stock stock = stockMapper.selectOne(
new LambdaQueryWrapper<Stock>().eq(Stock::getProductId, productId));
return stock == null ? 0 : stock.getStock();
}
}
再次运行:
mvn test -Dtest=StockServiceImplTest
现在三个测试全绿。注意第三步只写了让测试通过的最少逻辑——参数校验就是测试
testDeductInvalidQuantity 逼出来的。
第四步(Refactor):在绿色保护下重构
测试全绿后,你可以放心地做小重构,比如把魔法数字提成常量、抽出校验方法。每改一步跑一次测试,绿着就继续,红了就回退:
@Service
@RequiredArgsConstructor
public class StockServiceImpl implements StockService {
private final StockMapper stockMapper;
@Override
public boolean deduct(Long productId, int quantity) {
validate(productId, quantity); // 重构:把校验逻辑抽成单独方法,主流程更清晰
return stockMapper.deductStock(productId, quantity) > 0;
}
private void validate(Long productId, int quantity) {
if (productId == null || productId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品ID非法");
}
if (quantity <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "扣减数量必须大于0");
}
}
// getStock 略
}
关键点:这个演示最值得记住的是
testDeductInvalidQuantity
这个测试。如果你不是先写测试,大概率会漏掉「数量非法」这个边界——因为它不影响主流程,代码跑起来也没问题。TDD
的价值就在于:测试先于代码,逼你把边界和异常想在前面。
本章小结
覆盖率(JaCoCo)帮你量化「测试覆盖了多少行代码」,但「有效断言 >
覆盖率数字」——先保证每个测试都断言到了业务结果。TDD 的
Red-Green-Refactor
把「写测试」变成驱动设计和发现边界的手段,是写生产级代码的心法。
5.
生产级实战项目:订单模块完整测试套件
本章把前四章的知识串起来,为订单模块产出一套完整、可运行的测试套件,覆盖四层:
- 单元测试:
OrderServiceImplTest(业务逻辑
+ Mock)、StockServiceImplTest(TDD
产出)、OrderAmountCalculatorTest(纯逻辑); - 切片测试:
OrderControllerTest(@WebMvcTest
+ MockMvc); - 集成测试:
OrderMapperMySQLTest、StockMapperMySQLTest(Testcontainers
+ 真实 MySQL)。
5.1 完整目录结构
demo/
├── pom.xml
└── src
├── main
│ ├── java/com/example/demo
│ │ ├── DemoApplication.java
│ │ ├── common/ApiResult.java, ErrorCode.java
│ │ ├── exception/BizException.java, GlobalExceptionHandler.java
│ │ ├── controller/OrderController.java
│ │ ├── service/OrderService.java, StockService.java, ProductService.java, OrderAmountCalculator.java
│ │ ├── service/impl/OrderServiceImpl.java, StockServiceImpl.java, ProductServiceImpl.java
│ │ ├── mapper/OrderMapper.java, StockMapper.java, ProductMapper.java
│ │ ├── entity/Order.java, Stock.java, Product.java
│ │ ├── dto/CreateOrderDTO.java
│ │ └── vo/OrderVO.java
│ └── resources/application.yml
└── test
├── java/com/example/demo
│ ├── AbstractMySQLContainerTest.java # 共享容器基类
│ ├── service/OrderServiceImplTest.java
│ ├── service/StockServiceImplTest.java
│ ├── service/OrderAmountCalculatorTest.java
│ ├── controller/OrderControllerTest.java
│ └── mapper/OrderMapperMySQLTest.java, StockMapperMySQLTest.java
└── resources/schema.sql
前面已经给出大部分文件,这里补齐还没给的:DemoApplication、Order/Product
实体、OrderMapper/ProductMapper、OrderService/ProductService/StockService
接口、ProductServiceImpl。
5.2 补齐主代码文件
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
package com.example.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private Long userId;
private Long productId;
private Integer quantity;
private BigDecimal totalAmount;
private Integer status; // 1=待支付 2=已支付 3=已取消
private LocalDateTime createdAt;
}
package com.example.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
@Data
@TableName("t_product")
public class Product {
@TableId(type = IdType.AUTO)
private Long id;
private String productName;
private BigDecimal price;
}
package com.example.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Order;
public interface OrderMapper extends BaseMapper<Order> {
}
package com.example.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Product;
public interface ProductMapper extends BaseMapper<Product> {
}
package com.example.demo.service;
import com.example.demo.dto.CreateOrderDTO;
import com.example.demo.vo.OrderVO;
public interface OrderService {
Long createOrder(CreateOrderDTO dto);
OrderVO getOrder(Long id);
}
package com.example.demo.service;
public interface StockService {
/** 扣减库存,返回 true=成功,false=库存不足 */
boolean deduct(Long productId, int quantity);
/** 查询当前库存 */
int getStock(Long productId);
}
package com.example.demo.service;
import java.math.BigDecimal;
public interface ProductService {
/** 查询商品单价,商品不存在时抛业务异常 */
BigDecimal getPrice(Long productId);
}
package com.example.demo.service.impl;
import com.example.demo.common.ErrorCode;
import com.example.demo.entity.Product;
import com.example.demo.exception.BizException;
import com.example.demo.mapper.ProductMapper;
import com.example.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductMapper productMapper;
@Override
public BigDecimal getPrice(Long productId) {
Product product = productMapper.selectById(productId);
if (product == null) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "商品不存在");
}
return product.getPrice();
}
}
5.3 运行整套测试
# 跑全部测试(单元 + 切片 + 集成,其中集成测试会自动拉起 MySQL 容器)
mvn clean test
# 只跑某个测试类
mvn test -Dtest=OrderServiceImplTest
# 只跑某个测试方法
mvn test -Dtest=OrderServiceImplTest#testCreateOrderSuccess
# 跳过集成测试(本地没装 Docker 时)
mvn test -Dtest='!*MySQLTest'
预期结果:所有测试通过,JaCoCo 报告生成在
target/site/jacoco/index.html,OrderServiceImpl、StockServiceImpl
等核心类的行覆盖率建议达到 80% 以上。
5.4 这套套件为什么是「生产级」
| 维度 | 做法 |
|---|---|
| 正常分支 | 每个 Service 方法都有「成功」用例 |
| 异常分支 | 库存不足、商品不存在、订单不存在、参数非法全部覆盖,并断言副作用(never().insert()) |
| 隔离 | 单元测试全 Mock 依赖,不连库;集成测试用独立容器,不影响开发库 |
| 真实依赖 | 集成测试用 MySQL 8.0 容器,和生产的 MySQL 行为一致 |
| 可观测 | JaCoCo 覆盖率报告 + 红色行定位未覆盖分支 |
| 可维护 | 共享容器基类复用、每个测试自给自足、不依赖执行顺序 |
6. 常见坑与排错指南
| 坑/现象 | 原因 | 解决方案 |
|---|---|---|
| 测试单独跑通过,一起跑就挂 | 测试依赖了执行顺序,或有共享状态被污染 | 每个测试 @BeforeEach自建数据,禁用共享可变状态;容器测试用随机唯一数据 |
| 单元测试跑得极慢 | Service 测试里 @Autowired 了真实 Mapper 连了真库 |
单元测试用 @Mock 替换依赖,绝不连库 |
| H2 上测试通过,生产 MySQL 报 SQL 语法错 | H2 与 MySQL 方言/函数不一致 | 集成测试用 Testcontainers 拉真实 MySQL 容器 |
| 只测了 happy path,上线后异常分支炸 | 异常/边界用例没写 | 每个方法至少补 assertThrows + 边界值用例 |
| 覆盖率 90% 但一堆 bug | 只调用不断言,刷出来的「假覆盖」 | 关注有效断言:每个测试断言业务结果,而非执行过程 |
每个测试类都 @SpringBootTest,CI 跑半小时 |
滥用完整容器启动 | 优先 @WebMvcTest/@MybatisTest切片;集成测试共享容器 |
@WebMvcTest 下接口 404/401 |
自定义拦截器、Security、@Import 的配置没加载进切片 |
用 @Import(配置类.class) 显式导入切片缺失的配置 |
Mockito 报 InvalidUseOfMatchersException |
when() 里参数「有的用匹配器、有的用字面量」 |
一旦用 any(),所有参数都用匹配器;字面量用eq() 包一层 |
when() 对 @Spy 或 void方法不生效/抛异常 |
when(x.m()) 会真实调用一次方法 |
对 @Spy 和 void 方法改用 doReturn().when()/ doThrow().when() |
Testcontainers 报Could not find a valid Docker environment |
本机 Docker 没启动,或 DOCKER_HOST 未配置 |
启动 Docker daemon;CI 环境确认 Docker-in-Docker 可用 |
8. 总结与延伸阅读
本阶段你补齐了「写生产级代码」的最后一块能力——测试。核心是一条主线:用最便宜的测试层锁住最多的业务逻辑。单元测试(JUnit
5 +
Mockito)负责业务规则,切片测试(@WebMvcTest/@MybatisTest)负责层与层的装配,Testcontainers
负责真实依赖的一致性,JaCoCo 负责量化覆盖、TDD
负责让测试先于代码驱动设计。记住三条纪律:单元测试不连真库、异常分支和正常分支同等重要、有效断言比覆盖率数字更值钱。
延伸资源
- JUnit 5
User Guide——参数化测试、生命周期、扩展模型的权威文档 - Mockito
官方文档——打桩、验证、ArgumentCaptor 的完整 API - Testcontainers
官方文档——支持的容器模块与最佳实践 - JaCoCo
官方文档——覆盖率指标定义与 Maven 插件配置 - 《单元测试的艺术》(Roy
Osherove)——关于「有效测试」「可信赖测试」的经典读物