【阶段 8】测试:JUnit 5、Mockito 与 Testcontainers

52次阅读
没有评论

1. 导语

前面几个阶段,你已经能写出「跑得通」的
Controller、Service、Mapper。但「能跑」和「能上线」之间,隔着一道分水岭:测试

想象一个真实场景:你改了一个订单金额计算的逻辑,本地点了几下没问题,发到生产环境后,凌晨两点库存扣减出了一个负数,用户下单成功却永远收不到货。这时你才意识到——没有自动化测试兜底,每一次改动都是一次赌博。

本阶段要解决的就是这个问题。你会学到三层武器:

  1. JUnit 5 + Mockito:写又快又稳的单元测试,把 Service
    里的业务逻辑锁死;
  2. 切片测试 + 集成测试:用
    @WebMvcTest@DataJpaTest 只启动需要的
    Bean,再配合 @SpringBootTest 验证真实装配;
  3. Testcontainers:在测试里拉起真实的 MySQL
    容器,让「测试环境」和「生产环境」真正一致,彻底告别 H2 假象。

学完本阶段,你能为一个订单模块写出一套完整、可运行、覆盖正常与异常分支的测试套件,并且用
TDD(测试驱动开发)的思路实现一个库存扣减功能。这是「能写生产级代码」的最后一块拼图。

前置提示:本阶段大量依赖你在阶段 3(Web
层,ApiResult/ErrorCode/BizException)和阶段
4(数据访问,MyBatis-Plus)打下的基础。如果对这两块还生疏,建议先回去复习对应文章再继续。


2. 学习目标与前置要求

学完你能…

  1. 说出测试金字塔的三层结构,并判断「某个逻辑该写在哪一层测试」。
  2. 用 JUnit 5 写出带
    @BeforeEach、参数化测试、assertThrows/assertAll
    的单元测试。
  3. 用 Mockito 的 @Mock/@InjectMocks
    隔离依赖,对 Service 的正常分支和异常分支都做断言。
  4. 区分
    @SpringBootTest@WebMvcTest@DataJpaTest
    各自加载什么,并写出 MockMvc 的 Controller 测试。
  5. 用 Testcontainers 拉起真实 MySQL 容器,跑通 Mapper
    的集成测试,说清「H2 与生产库」的关键差异。
  6. 配置 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-jupitermysql
不用写版本
: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
assertThrowsassertAll

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 时真的去连数据库,会有三个问题:

  1. :每个测试都起连接、建数据,测试套件跑一次要几分钟;
  2. 不稳定:数据库里数据一变,测试就挂了,和你的代码好坏无关;
  3. 无法控制:你没法轻松制造「库存不足」「商品不存在」这些异常场景。

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 的核心就两件事:

  1. 打桩(Stubbing)when(依赖.方法(参数)).thenReturn(值)——预设
    Mock 的返回值;
  2. 验证(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
全绿了,但它有个盲区:它假设
OrderMapperStockServiceProductService
都能正常工作
。而这些依赖之间、它们和 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 真正把
OrderServiceStockServiceProductService
装起来,任何「少写了个
@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()));
    }
}

这个测试验证了什么

  1. URL 映射POST /api/orders 确实命中
    createOrder
  2. JSON 序列化/反序列化:DTO 能被正确解析成对象;
  3. 参数校验@Valid + @Min
    生效,非法入参被拦下;
  4. 全局异常处理BizException
    GlobalExceptionHandler 正确转成
    ApiResultcode=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,测试驱动开发)把「写测试」从「事后补」变成「事前驱动」,三步循环:

  1. Red(红):先写一个失败的测试——它描述了你想要的行为,但因为功能还没实现,测试跑不过;
  2. Green(绿):写最少的代码让测试通过,不写任何多余逻辑;
  3. Refactor(重构):在测试全绿的保护下,安全地优化代码结构。

为什么先写失败测试:先写测试迫使你先想清楚「输入什么、期望输出什么、边界在哪」,而不是边写代码边糊弄。而且一个「先红后绿」的过程能证明:你的测试真的在测东西(而不是永远绿的空壳)。

5.5 完整 TDD
演示:库存扣减功能

下面用 TDD 实现 StockServiceImpldeduct
方法(库存扣减),走完「红 → 绿 → 重构」。

第一步(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);
  • 集成测试OrderMapperMySQLTestStockMapperMySQLTest(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

前面已经给出大部分文件,这里补齐还没给的:DemoApplicationOrder/Product
实体、OrderMapper/ProductMapperOrderService/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.htmlOrderServiceImplStockServiceImpl
等核心类的行覆盖率建议达到 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
负责让测试先于代码驱动设计。记住三条纪律:单元测试不连真库、异常分支和正常分支同等重要、有效断言比覆盖率数字更值钱。

延伸资源

  1. JUnit 5
    User Guide
    ——参数化测试、生命周期、扩展模型的权威文档
  2. Mockito
    官方文档
    ——打桩、验证、ArgumentCaptor 的完整 API
  3. Testcontainers
    官方文档
    ——支持的容器模块与最佳实践
  4. JaCoCo
    官方文档
    ——覆盖率指标定义与 Maven 插件配置
  5. 《单元测试的艺术》(Roy
    Osherove)——关于「有效测试」「可信赖测试」的经典读物
正文完
 0
评论(没有评论)