单元测试与集成测试:JUnit5 + Mockito实战

001、测试驱动开发(TDD)与JUnit5核心概念


上周排查一个线上问题,凌晨两点盯着日志发现一段业务逻辑在特定条件下漏处理了空指针。翻出代码一看,那个方法足足有八十行,嵌套了三层条件分支,没有任何单元测试覆盖。当时就想,如果早几年写这段代码的时候能坚持TDD,这个坑根本不会留到今天。

很多团队把单元测试当成“可有可无”的补充环节,往往是开发完成后象征性补几个用例。但真正经历过线上故障连环追查的人都知道——没有测试覆盖的代码,就像没系安全绳走钢丝。


一、从调试噩梦到测试驱动

去年重构一个嵌入式设备通信模块,代码里到处是这样的片段:

// 别这样写!这是调试出来的代码,不是设计出来的
public void parseSensorData(byte[] raw) {
    if (raw != null) {
        if (raw.length > 2) {
            int type = raw[0] & 0xFF;
            if (type == 0x01 || type == 0x02) {
                // 实际处理逻辑混在条件深处
            }
        }
    }
}

这种代码最要命的是:你不敢改。任何修改都可能触发未知的边界问题。

测试驱动开发(TDD)的核心不是“先写测试”,而是通过测试来定义需求。它的循环很简单:

  1. 写一个失败的单测(描述需求)
  2. 写最少代码让测试通过
  3. 重构代码,同时保持测试通过

听起来像教科书?实际项目中,TDD能帮你:

  • 提前暴露接口设计问题(单测都难写的接口,用起来更痛苦)
  • 自然得到高覆盖率的测试套件
  • 重构时有“安全网”兜底

二、JUnit5 不是 JUnit4 的简单升级

很多项目还在用 JUnit4,迁移时直接改个注解了事,这浪费了 JUnit5 的真正优势。

1. 架构彻底重构

JUnit5 拆成了三个模块:

  • JUnit Platform:在 JVM 上启动测试框架的基础层
  • JUnit Jupiter:新编程模型(写测试用的注解和 API)
  • JUnit Vintage:兼容 JUnit4/3 的过渡层

这意味着你可以在 CI 中并行跑 JUnit4、JUnit5 甚至其他测试框架。

2. 注解的变化

// JUnit4 的遗产写法,还能用但不推荐
@Before
public void setUp() { ... }

// JUnit5 的写法,支持非 public 方法
@BeforeEach
void setUp() { ... }

最大的改进是:测试方法和方法生命周期钩子可以不用 public 修饰。小细节,但写起来顺手很多。

3. 断言进化

JUnit4 的断言太简陋,经常要配合 assertThat 和 Hamcrest 混着用。JUnit5 的 Assertions 类直接提供了流式断言:

// 旧写法,出错信息不直观
assertEquals(2, list.size());

// 新写法,支持懒加载错误信息
assertThat(list)
    .hasSize(2)
    .contains("expectedItem");

// 还支持分组断言,所有断言都会执行完再报错
assertAll("用户信息校验",
    () -> assertEquals("张三", user.getName()),
    () -> assertTrue(user.isActive())
);

分组断言特别实用——以前一个断言失败后,后面的就不执行了,调一次只能看到一个错误。

4. 参数化测试不再鸡肋

JUnit4 的参数化测试用起来很别扭,JUnit5 支持多种数据源:

@ParameterizedTest
@CsvSource({
    "1, 张三, true",
    "2, 李四, false"
})
void testUser(int id, String name, boolean active) {
    // 这里踩过坑:CSV 里空字符串要用 "" 显式表示
}

还支持 @MethodSource 从工厂方法取数据,适合复杂参数对象。

5. 动态测试

这是 JUnit5 的隐藏利器,运行时动态生成测试用例:

@TestFactory
Stream<DynamicTest> dynamicTests() {
    return IntStream.range(1, 10)
        .mapToObj(i -> DynamicTest.dynamicTest(
            "测试#" + i,
            () -> assertTrue(i > 0)
        ));
}

适合根据外部数据文件、数据库记录生成测试的场景。


三、TDD 实战片段

假设我们要实现一个简单的传感器数据校验器:

第一步:写失败测试

@Test
void shouldThrowWhenSensorDataIsNull() {
    SensorValidator validator = new SensorValidator();
    assertThrows(InvalidDataException.class,
        () -> validator.validate(null));
}

这时 validate 方法还不存在,编译都过不了。

第二步:写最少实现

public void validate(SensorData data) {
    if (data == null) {
        throw new InvalidDataException();
    }
}

现在测试通过了。

第三步:加新需求(继续写失败测试)

@Test
void shouldThrowWhenTemperatureOutOfRange() {
    SensorData data = new SensorData();
    data.setTemperature(200.0); // 超范围
    assertThrows(InvalidDataException.class,
        () -> validator.validate(data));
}

第四步:扩展实现

public void validate(SensorData data) {
    if (data == null) throw new InvalidDataException();
    if (data.getTemperature() < -50 || data.getTemperature() > 150) {
        throw new InvalidDataException();
    }
}

如此循环。

你会发现,最终产生的代码天然被测试覆盖,而且每个分支都有对应测试用例。


四、嵌入式场景的特殊考量

在嵌入式开发中做单元测试,有几个常见问题:

  1. 硬件依赖:测试代码不能依赖实际硬件

    • 方案:用接口抽象硬件操作,测试时注入 Mock
    • 例如 ISensor.read() 接口,实机实现读寄存器,测试时用 Mockito 模拟返回值
  2. 时序和并发

    • 避免在单测中写 Thread.sleep(),用 CountDownLatch 或虚拟时间(如 Awaitility 库)
  3. 内存约束

    • 测试本身也可能内存泄漏,定期检查测试套件的内存增长

个人经验建议

  1. 不要追求 100% 覆盖率:关键逻辑(业务规则、条件分支、错误处理)必须覆盖,但 getter/setter 或简单委托方法不必强求。覆盖率是辅助指标,不是目标。

  2. 测试代码也要重构:看到重复的测试数据准备代码,就该考虑抽成 @BeforeEach 或工厂方法。测试代码的维护成本也是成本。

  3. 嵌入式项目先测逻辑层:从硬件抽象层(HAL)往上数,越靠近业务逻辑的层,单元测试收益越高。驱动层代码用集成测试补覆盖。

  4. JUnit5 迁移可以渐进:老项目不用一次性全迁,新模块用 JUnit5,老模块保持 JUnit4,两者可以共存。

  5. 单测失败即视为阻塞:CI 中任何单测失败都应该立即修复,否则测试就会逐渐失去信任——最后没人再看 CI 结果。


刚开始写单测会觉得拖慢进度,特别是工期紧的时候。但长期看,它减少的调试时间和线上问题追查时间,远多于编写测试的时间。好的测试套件就像一份可执行的文档,新同事通过看测试用例,能最快理解代码的预期行为。

下次写新功能时,不妨先打开测试类,写下第一个 @Test。从一个小方法开始,体会测试先行的节奏。

002、JUnit5架构、注解与生命周期深度解析


从一次深夜调试说起

上周排查一个时序问题,测试用例跑了几遍结果都不一致。同事在本地跑是绿的,上了CI就红。最后发现是测试类里某个@BeforeEach方法被重复执行了两次——JUnit4时代养成的混合使用@Before@BeforeClass的习惯,在JUnit5里遇到了新引擎的加载规则差异。这才意识到,很多人用JUnit5写了半年测试,却还在用JUnit4的思维去理解它的生命周期。

今天我们就拆开JUnit5的引擎盖,看看它到底是怎么转的。


JUnit5的三层架构

JUnit5不是简单的升级,它是彻底的重构。整个框架分三层:

JUnit Platform
测试执行的入口层,提供命令行、IDE、构建工具(Maven/Gradle)的接入能力。你的IDE里那个“Run Test”按钮,背后调的就是Platform的Launcher API。

JUnit Jupiter
这才是我们写测试时真正打交道的部分。提供新的编程模型、注解扩展机制。@Test@BeforeEach这些注解都在这一层。

JUnit Vintage
为了向后兼容JUnit3/4的过渡层。如果你的项目里还有老测试用例,Vintage引擎会负责把它们转成JUnit5能识别的格式。

关键点:Jupiter和Vintage是并列的测试引擎。一个测试类跑在哪个引擎上,行为可能不同。混用老注解时尤其要注意。


注解:不只是换了名字

核心生命周期注解

class OrderServiceTest {
    // 类级别资源初始化,比如数据库连接池
    // 静态方法!别用成实例方法,否则IDE会报错
    @BeforeAll
    static void initGlobal() {
        System.out.println("整个测试类只执行一次");
    }
    
    // 每个测试方法前的清理
    // 这里踩过坑:如果@BeforeEach里抛异常,对应的@Test方法会被跳过
    @BeforeEach
    void setUp() {
        System.out.println("每个@Test前都执行");
    }
    
    @Test
    void testCreateOrder() {
        // 实际测试代码
    }
    
    // 每个测试方法后的收尾
    // 即使@Test抛异常,这里的代码也会执行(类似finally)
    @AfterEach
    void tearDown() {
        System.out.println("每个@Test后都执行");
    }
    
    @AfterAll
    static void cleanup() {
        System.out.println("所有测试跑完后执行一次");
    }
}

几个容易用错的注解

@DisplayName vs 方法名

@Test
@DisplayName("创建订单-正常流")
void testCreateOrder_NormalFlow() {
    // 这个注解只影响报告显示,不影响代码逻辑
    // 中文名可以,但建议英文方法名还是保持规范
}

@Disabled 不是 @Ignore
JUnit5里废弃了@Ignore,改用@Disabled。支持给禁用原因:

@Test
@Disabled("等第三方接口修复后再启用")
void testExternalAPI() {
    // 暂时不跑的测试
}

@Nested 的隔离性
嵌套测试类有自己的生命周期,每个@Nested类会重新执行@BeforeEach

class OrderServiceTest {
    @BeforeEach
    void setUp() { /* 外层执行 */ }
    
    @Nested
    class ValidationTests {
        @BeforeEach
        void nestedSetUp() { /* 内层先执行这个 */ }
        // 执行顺序:外层setUp -> nestedSetUp -> 测试方法
    }
}

生命周期:顺序很重要

标准执行顺序

  1. 调用@BeforeAll(静态方法)
  2. 对于每个@Test方法:
    • 创建测试类的新实例(**重点!**每个测试方法都是独立实例)
    • 执行@BeforeEach
    • 执行@Test方法体
    • 执行@AfterEach
  3. 调用@AfterAll(静态方法)

每个测试方法都是新实例——这意味着测试方法之间通过实例变量共享状态是危险的。我见过有人用成员变量存Mock对象,结果下一个测试方法里Mock状态不对了。

扩展模型(Extension Model)

JUnit5最大的改进之一:用扩展机制替代了JUnit4的Runner体系。你可以挂载自定义逻辑到生命周期任意节点:

// 自己写个扩展
class LoggingExtension implements BeforeEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) {
        System.out.println("进入测试: " + context.getDisplayName());
    }
}

// 通过@ExtendWith挂载
@ExtendWith(LoggingExtension.class)
class MyTest {
    // 你的测试
}

常见的现成扩展:Spring的@SpringBootTest、Mockito的@MockitoExtension都是基于这个机制。


参数化测试:少写80%的重复用例

@ParameterizedTest
@ValueSource(strings = {"order1", "order2", "order3"})
@DisplayName("批量测试订单号")
void testOrderIds(String orderId) {
    assertNotNull(orderService.validate(orderId));
}

// 更实用的:CsvSource
@ParameterizedTest
@CsvSource({
    "100, 0.1, 90",   // 原价100,折扣0.1,最终90
    "200, 0.2, 160"
})
void testDiscount(int original, double discount, int expected) {
    assertEquals(expected, priceService.calculate(original, discount));
}

参数化测试的每个参数组合会被视为独立的测试用例。在报告里你会看到testDiscount(int, double, int)[1]这样的条目。


断言体系:AssertJ够用,但Jupiter的也还行

JUnit5自带Assertions类,比JUnit4的丰富:

import static org.junit.jupiter.api.Assertions.*;

@Test
void testAll() {
    // 异常断言
    assertThrows(IllegalArgumentException.class, () -> {
        orderService.createOrder(null);
    });
    
    // 超时断言
    assertTimeout(Duration.ofSeconds(2), () -> {
        service.process();
    });
    
    // 链式断言(JUnit5.8+)
    assertAll("订单属性",
        () -> assertEquals("PENDING", order.getStatus()),
        () -> assertTrue(order.getId() > 0)
    );
}

个人习惯:简单断言用JUnit5自带的,复杂对象比较用AssertJ。别在测试里写一堆if-else做验证。


个人经验与坑点

  1. 测试隔离是第一位
    每个@Test方法独立实例的设计,就是为了强制隔离。如果你发现测试之间有依赖,先检查是不是误用了static变量。

  2. @BeforeAll的静态限制
    想在这个方法里初始化非静态资源?考虑用@TestInstance(Lifecycle.PER_CLASS)注解类,让整个测试类只创建一个实例。但慎用,可能破坏隔离性。

  3. IDE的兼容问题
    有些老版本的IntelliJ对JUnit5的嵌套测试支持不完整。如果@Nested类里的测试没被识别,升级IDE或检查插件版本。

  4. 生命周期扩展的执行顺序
    多个@ExtendWith注解时,执行顺序是声明顺序。如果有依赖关系,把被依赖的扩展放前面。

  5. 参数化测试的命名
    默认的显示名[1][2]不友好,用@ParameterizedTest(name = "Case {index}: {0}")自定义,日志排查时能省很多时间。

  6. 不要依赖测试执行顺序
    虽然JUnit5提供了@TestMethodOrder,但除非真有强需求(比如性能测试的热身阶段),否则别用。测试就该是独立、无序的。


最后说一句

JUnit5的架构设计,本质上是在解决“测试代码也是代码,也需要维护”的问题。注解体系、生命周期、扩展机制,都是在为可读性、可维护性服务。下次写测试前,花两分钟想想:这个@BeforeEach里的代码,真的需要每个测试都执行吗?这个测试类是不是太胖了,该拆成@Nested了?

好的测试结构,比覆盖率达到80%更有价值。

003、JUnit5断言、假设与标签化测试实战


从一次深夜调试说起

上周排查一个线上问题,设备日志显示某个传感器数值偶尔会跳变到异常范围。本地单元测试全部通过,但集成到硬件上就出问题。最后发现是测试用例写得太“宽容”——用了简单的 assertEquals(expected, actual),但没考虑浮点数精度和边界场景。那晚我意识到,断言不是写完就行,得写得聪明

今天我们就深入 JUnit5 的断言、假设与标签化测试,这些都是让测试更精准、更灵活的核心工具。


一、断言:别只满足于 assertEquals

JUnit5 的断言都在 org.junit.jupiter.api.Assertions 里,但很多人只用了十分之一的功能。

1. 基础断言:建议带上自定义失败信息

@Test
void testBasicAssertions() {
    int result = someService.calculate(2, 3);
    
    // 别这样写:失败时只知道值不对,不知道上下文
    // assertEquals(5, result);
    
    // 这样写:失败信息直接告诉你问题在哪
    assertEquals(5, result, "calculate(2,3) 应该返回 5");
    
    // 懒求值消息:只有断言失败时才拼接字符串,性能友好
    assertEquals(5, result, () -> "calculate(" + 2 + "," + 3 + ") 失败");
}

2. 浮点数比较:一定要用 Delta

@Test
void testFloatComparison() {
    double actual = 0.1 + 0.2;
    
    // 这里踩过坑:直接比较浮点数会因精度问题失败
    // assertEquals(0.3, actual); // 大概率失败
    
    // 正确姿势:指定允许的误差范围
    assertEquals(0.3, actual, 0.000001, "浮点数比较必须用 delta");
}

3. 链式断言:一次执行多个检查

@Test
void testGroupedAssertions() {
    User user = userService.findById(1);
    
    // 传统写法:第一个失败就停,看不到其他字段状态
    // assertEquals("John", user.getName());
    // assertEquals(30, user.getAge());
    
    // 链式断言:所有检查都执行,失败信息集中输出
    assertAll("user 属性检查",
        () -> assertEquals("John", user.getName()),
        () -> assertTrue(user.getAge() > 18, "年龄应成年"),
        () -> assertNotNull(user.getEmail())
    );
}

经验:调试时最头疼的就是“挤牙膏式”失败。链式断言能一次看到所有不符合预期的字段,效率提升明显。


二、假设:让测试更智能地跳过

假设(Assumptions)是经常被忽略的好东西。它让测试只在条件满足时执行。

实战场景:环境依赖测试

@Test
void testDatabaseOperation() {
    // 只有数据库连接正常时才执行测试
    assumeTrue(Database.isConnected(), "数据库未连接,跳过测试");
    
    // 下面的测试代码只在假设成立时运行
    List<User> users = userRepository.findAll();
    assertFalse(users.isEmpty());
}

@Test
void testPlatformSpecific() {
    // 只在 Windows 环境运行
    assumeTrue(System.getProperty("os.name").contains("Win"));
    
    // 平台相关逻辑测试
    // ...
}

@Test
@EnabledOnOs(OS.LINUX)  // 这是条件测试注解,和 assumeTrue 效果类似但更声明式
void testLinuxOnly() {
    // 仅 Linux 执行
}

关键区别

  • 假设不成立时,测试被标记为 aborted(跳过),不是失败。
  • 适合运行时动态判断(如文件是否存在、服务是否可达)。
  • 条件注解更简洁,但灵活性稍差。

三、标签化测试:大型项目的测试管理术

当项目有几百个测试用例时,你会需要标签化(Tagging)。

1. 定义标签

class TagDefinitions {
    static final String FAST = "fast";
    static final String SLOW = "slow";
    static final String INTEGRATION = "integration";
    static final String HARDWARE = "requires-hardware";
}

2. 标记测试类和方法

@Tag(TagDefinitions.FAST)
class FastUnitTests {
    @Test
    @Tag(TagDefinitions.INTEGRATION)
    void testWithExternalService() {
        // 这个虽然类标记为 fast,但方法标记为 integration
        // 实际归类到两个标签下
    }
}

@Tag(TagDefinitions.SLOW)
@Tag(TagDefinitions.HARDWARE)
class HardwareTestSuite {
    // 需要真实硬件,跑得慢
    // 日常开发不用执行
}

3. 运行配置

在 Maven 中:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <!-- 只跑 fast 标签 -->
        <groups>fast</groups>
        <!-- 排除 slow 标签 -->
        <excludedGroups>slow</excludedGroups>
    </configuration>
</plugin>

在 IDE(如 IntelliJ)中:
可以直接在运行配置里选择按标签过滤,开发时只跑 fast 标签,提测前跑全部。

个人实践

  • @fast:纯逻辑单元测试,无 I/O,执行时间 < 100ms。
  • @slow:涉及数据库、网络、文件。
  • @hardware:需要真实设备或特殊外设。
  • @nightly:跑一次要几分钟以上的,放 CI 夜间执行。

这样,开发阶段 mvn test -Dgroups=fast 秒级反馈,效率极高。


四、断言的最佳搭档:Hamcrest 与 AssertJ

JUnit5 自带的断言够用,但有时需要更表达力的匹配器。

Hamcrest 风格

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

@Test
void testWithHamcrest() {
    List<String> result = someService.getNames();
    
    // 可读性更好,接近自然语言
    assertThat(result, hasSize(3));
    assertThat(result, hasItem("Alice"));
    assertThat(result, not(contains("Bob"))); // 不包含 Bob
    assertThat(result, everyItem(startsWith("A"))); // 每个元素都以 A 开头
}

AssertJ 风格(个人更推荐)

import static org.assertj.core.api.Assertions.*;

@Test
void testWithAssertJ() {
    List<User> users = userService.findAll();
    
    // 流式 API,IDE 自动提示很舒服
    assertThat(users)
        .isNotEmpty()
        .hasSize(5)
        .extracting(User::getName)  // 提取字段
        .contains("Alice", "Bob")
        .doesNotContain("Charlie");
    
    // 字符串断言也很强大
    assertThat("Hello, World!")
        .startsWith("Hello")
        .contains("World")
        .doesNotContain("Error");
}

选择建议

  • 小项目用 JUnit5 自带断言足够。
  • 中大型项目,特别是测试代码多的,用 AssertJ。它的链式调用和丰富匹配器能让测试代码更清晰。
  • Hamcrest 较老,很多团队因历史原因在用,新项目可以优先 AssertJ。

五、自定义断言:让领域断言更简洁

当你的领域有特定验证逻辑时,可以封装自定义断言。

// 自定义断言类
public class DeviceAssertions {
    public static void assertIsValidSensorValue(Device device) {
        assertNotNull(device);
        
        // 领域规则:传感器值应在 [0, 100] 且不为 null
        assertAll("设备传感器检查",
            () -> assertNotNull(device.getSensorValue(), "传感器值不能为 null"),
            () -> assertTrue(
                device.getSensorValue() >= 0 && device.getSensorValue() <= 100,
                "传感器值应在 0-100 之间,实际:" + device.getSensorValue()
            )
        );
    }
}

// 使用
@Test
void testDeviceSensor() {
    Device device = deviceReader.read();
    DeviceAssertions.assertIsValidSensorValue(device);
}

这样,测试代码读起来就像领域语言,维护点也集中。


经验性建议

  1. 断言信息要具体:失败消息里带上输入值、上下文,别让同事猜谜。
  2. 浮点比较必用 delta:这是单元测试的经典坑,我见过至少三次线上问题源于此。
  3. 善用假设:不是所有测试都必须在所有环境跑,条件性执行能让测试套件更健壮。
  4. 标签化要尽早:项目大了再加标签成本高,一开始就分 fast/slow。
  5. 自定义断言封装领域知识:测试代码也是代码,要遵循 DRY 原则。
  6. 链式断言调试友好:特别是对象多个字段验证时,一次性看到所有问题。
  7. 断言库选型要统一:一个项目里别混用多种风格,增加维护成本。

最后提醒:测试失败信息是你写给三个月后的自己(或同事)的调试指南。多花 30 秒写好断言消息,可能省下后面 30 分钟的调试时间。


下一篇我们聊《Mockito 入门:让测试隔离依赖》,看看怎么把这些断言用在对 mock 对象的验证上。

004、JUnit5参数化测试与动态测试高级应用


从一次深夜调试说起

上周排查一个订单金额计算的问题,业务规则是不同用户等级对应不同的折扣系数。我写了几个测试用例,手动改了五六次参数,跑一遍改一次,最后发现漏测了边界值——一个刚过VIP门槛的用户金额计算错了。凌晨两点盯着屏幕,突然意识到:这种重复劳动不仅低效,而且容易遗漏关键场景。

这就是参数化测试要解决的问题。


参数化测试:不只是少写几行代码

很多人以为参数化测试就是省点重复代码,其实它的核心价值在于用例的完整性和可维护性。JUnit5的参数化测试比JUnit4强大得多,我们直接看实战。

基础用法:@ValueSource

@ParameterizedTest
@ValueSource(strings = {"VIP", "SVIP", "普通用户"})
void testUserLevel(String level) {
    // 这里踩过坑:不要直接测字符串相等
    // 应该用业务对象包装后再测试
    User user = new User(level);
    assertNotNull(user.getDiscount());
}

但@ValueSource局限性很大,只能传基本类型。实际业务中,我们更需要组合参数。

实战组合:@CsvSource

@ParameterizedTest
@CsvSource({
    "VIP, 100.0, 0.9",
    "SVIP, 200.0, 0.8",
    "普通用户, 50.0, 1.0"
})
void testDiscount(String level, double amount, double expectedRate) {
    Calculator calculator = new Calculator();
    // 注意:浮点数比较要用Delta
    assertEquals(expectedRate, 
                 calculator.getDiscountRate(level, amount), 
                 0.001);
}

CSV格式直观,适合少量数据。但硬编码在注解里,维护起来还是麻烦。

推荐方案:@MethodSource

private static Stream<Arguments> provideOrderCases() {
    return Stream.of(
        Arguments.of("VIP", 99, false),   // 不满100不享受折扣
        Arguments.of("VIP", 100, true),   // 边界值
        Arguments.of("VIP", 101, true),
        Arguments.of("SVIP", 0, true),    // SVIP无门槛
        Arguments.of("普通用户", 1000, false)
    );
}

@ParameterizedTest
@MethodSource("provideOrderCases")
void testOrderDiscount(String level, int amount, boolean expected) {
    // 这里有个细节:测试方法名最好体现业务场景
    OrderService service = new OrderService();
    assertEquals(expected, service.isDiscountApplicable(level, amount));
}

MethodSource的优势在于:

  1. 参数可以从文件、数据库动态加载
  2. 支持复杂的对象参数
  3. 用例可复用

动态测试:运行时生成测试用例

参数化测试的用例是编译期确定的,而动态测试可以在运行时动态创建用例。这个特性在测试数据驱动或不确定用例数量的场景下非常有用。

一个真实场景:配置文件驱动测试

我们系统有几十种消息模板,每种模板有不同变量。手动写测试不现实。

@TestFactory
Stream<DynamicTest> testAllMessageTemplates() {
    // 从配置文件加载所有模板
    List<MessageTemplate> templates = loadTemplates();
    
    return templates.stream()
        .map(template -> DynamicTest.dynamicTest(
            "测试模板: " + template.getName(),
            () -> {
                // 验证模板变量是否合法
                assertTrue(template.getVariables().size() > 0);
                // 验证渲染不会抛异常
                assertDoesNotThrow(() -> 
                    template.render(sampleData())
                );
            }
        ));
}

动态测试的执行结果在IDE里会展开显示,每个动态生成的测试都是独立的。

结合参数化的动态测试

更强大的模式是两者结合:

@TestFactory
Stream<DynamicContainer> testMultiScenarios() {
    return Stream.of("场景A", "场景B")
        .map(scenario -> DynamicContainer.dynamicContainer(
            scenario,
            loadTestCases(scenario).stream()
                .map(testCase -> DynamicTest.dynamicTest(
                    testCase.getDescription(),
                    () -> executeTestCase(testCase)
                ))
        ));
}

这样就能形成分组的测试报告,特别适合集成测试。


踩坑记录与最佳实践

坑1:参数化测试的命名问题

默认的测试名是[1] VIP这种格式,根本看不出测试内容。一定要自定义:

@ParameterizedTest(name = "用户等级:{0}, 金额:{1}, 预期折扣:{2}")
@MethodSource("testData")
void test(String level, double amount, double expected) {
    // ...
}

坑2:动态测试的性能陷阱

别在@TestFactory方法里做耗时初始化,这个方法会被频繁调用。应该用@BeforeAll初始化共享资源。

坑3:参数化测试的并行执行

默认情况下,参数化测试的多个参数是顺序执行的。如果测试独立,可以加上:

@Execution(ExecutionMode.CONCURRENT)

但要小心共享状态的问题。


我的经验建议

  1. 参数选择策略:优先用@MethodSource,它最灵活。CSV适合简单数据,ValueSource基本只用于演示。

  2. 测试数据管理:复杂的测试数据建议放到JSON或YAML文件里,用@MethodSource加载。这样测试代码和测试数据分离,非技术人员也能维护用例。

  3. 动态测试的使用时机:不要为了炫技而用动态测试。它的适用场景是:用例数量不确定、用例需要运行时计算、测试需要分层组织。

  4. IDE兼容性:IntelliJ IDEA对JUnit5支持最好,Eclipse稍慢。动态测试在CI/CD工具中的报告格式要提前验证。

  5. 最重要的原则:参数化测试的目的是提高覆盖率,不是减少代码行数。如果一个测试用例的逻辑分支完全不同,拆成多个普通测试反而更清晰。


最后一点思考

好的测试应该像文档一样可读。参数化测试的用例数据,实际上就是一份可执行的业务规则说明书。下次产品经理问你“VIP用户到底打几折”时,直接让他看测试用例里的那几行CSV数据——这比任何文档都准确。

测试代码的质量,决定了你晚上能不能睡个好觉。参数化和动态测试不是银弹,但用对了地方,能让你少熬几个凌晨两点的夜。

005、Mockito核心:模拟对象、存根与验证行为

昨天排查一个线上问题,花了我整整三个小时。问题出在一个订单服务调用支付网关的环节——测试环境一切正常,线上却间歇性超时。打开日志一看,测试环境用的竟然是模拟的支付接口,而当初写单元测试时,Mockito的存根配置写得太“宽松”,漏掉了异常场景的模拟。这个教训让我决定,今天必须把Mockito这三个核心概念彻底讲透。

模拟对象:不是所有的依赖都值得真实

单元测试的第一原则就是隔离。当你测试A模块时,B、C、D这些依赖模块的行为应该是确定的、可控的。这就是模拟对象(Mock)存在的意义。

// 别直接new真实对象,那样你就得准备数据库连接、配置文件、网络权限...
PaymentGateway realGateway = new PaymentGateway(); // 这是集成测试的写法

// 应该这样
PaymentGateway mockGateway = Mockito.mock(PaymentGateway.class);

模拟对象是个“空壳子”,所有方法默认返回null、0或false。你可能会问:那它有什么用?关键在于,你可以精确控制它的行为,这就是接下来要说的存根。

存根:告诉模拟对象“当……时,你应该……”

存根(Stubbing)是Mockito最常用的功能。它解决了一个核心问题:如何让模拟对象在特定输入下返回特定输出。

// 基础存根:当调用pay方法且参数是100.0时,返回"SUCCESS"
when(mockGateway.pay(100.0)).thenReturn("SUCCESS");

// 连续存根:第一次调用返回成功,第二次返回失败
when(mockGateway.pay(anyDouble()))
    .thenReturn("SUCCESS")
    .thenReturn("FAILED");

// 异常存根:模拟超时异常
when(mockGateway.pay(anyDouble())).thenThrow(new TimeoutException("网关超时"));

// 回调存根:根据输入动态计算返回值
when(mockGateway.pay(anyDouble())).thenAnswer(invocation -> {
    Double amount = invocation.getArgument(0);
    return amount > 10000 ? "PENDING" : "SUCCESS";
});

这里踩过坑:别过度使用any()。我见过有人为了省事,所有参数都用any()匹配。结果测试用例跑了三年都没发现问题,直到线上出现边界值错误。该精确匹配的时候,一定要用具体值。

验证行为:不仅要结果对,过程也要对

存根解决了“返回什么”的问题,验证(Verification)解决的是“是否被调用”以及“如何被调用”的问题。这是很多初级开发者容易忽略的部分。

// 验证方法被调用了一次
verify(mockGateway).pay(100.0);

// 验证方法被调用了特定次数
verify(mockGateway, times(2)).pay(anyDouble());
verify(mockGateway, atLeastOnce()).pay(anyDouble());
verify(mockGateway, never()).refund(any()); // 验证某个方法从未被调用

// 验证调用顺序:先支付后查询
InOrder inOrder = inOrder(mockGateway);
inOrder.verify(mockGateway).pay(100.0);
inOrder.verify(mockGateway).queryStatus(anyString());

// 验证参数捕获:看看实际传了什么值
ArgumentCaptor<Double> amountCaptor = ArgumentCaptor.forClass(Double.class);
verify(mockGateway).pay(amountCaptor.capture());
Double actualAmount = amountCaptor.getValue(); // 拿到实际传递的参数
assertTrue(actualAmount > 0);

参数捕获是个神器。上周我就用它发现了一个bug:代码里计算金额时用了float,导致精度丢失,传给支付网关的金额少了0.01元。

几个容易掉进去的坑

坑一:存根写在调用之后
Mockito的存根必须在方法调用之前设置。这是常识,但紧急改bug时容易手快写反。

坑二:过度验证
验证每个内部方法调用会让测试变得脆弱。重构内部实现时,你可能得改几十个测试用例。只验证公共契约,别验证实现细节。

坑三:忘记重置
在@BeforeEach中初始化模拟对象是好习惯,但如果你在测试类中手动创建了模拟对象,注意不要在不同测试方法间产生状态污染。

坑四:模拟真实对象
有些类不适合模拟:值对象(String、LocalDateTime)、工具类(Collections)、简单POJO。模拟这些反而让测试更复杂。

个人经验:Mockito不是银弹

用了这么多年Mockito,我的体会是:模拟对象越多,测试离现实越远。如果一个类需要模拟三个以上的依赖才能测试,很可能这个类职责太重了,该重构了。

好的单元测试应该在“隔离”和“真实”之间找到平衡。我现在的做法是:

  1. 核心业务逻辑用真实对象+内存数据库
  2. 外部服务(支付、短信、邮件)用模拟对象
  3. 每加一个模拟对象,就问自己一次:这个类是不是做太多了?

最后记住,Mockito帮你模拟的是“不确定”的部分,不是“复杂”的部分。如果你发现测试代码比生产代码还难写,停下来想想,是不是设计出了问题。

测试不是写完就完事的,它和你的代码一起演化。下次写Mockito存根时,多问一句:这个模拟,离真实世界有多远?

006、Mockito进阶:参数匹配器、注解与Spy对象

昨天在代码评审时,看到同事写了这么一段测试:

when(userDao.findByPhone(eq("13800138000"))).thenReturn(mockUser);
when(userDao.findByPhone(eq("13800138001"))).thenReturn(null);

我问:“如果传入的是13800138002呢?”他愣了一下:“这种情况还没测到。”这让我想起刚用Mockito时,我也曾把参数匹配想得太简单。今天我们就聊聊Mockito里那些让测试更灵活的进阶技巧。

参数匹配器:不只是eq()

eq()确实是最常用的匹配器,但Mockito提供了一整套匹配方案。看这个实际场景:我们需要验证某个服务被调用时传入了有效的邮箱格式。

// 别这样写死
when(emailService.send(eq("test@example.com"))).thenReturn(true);

// 试试这个
when(emailService.send(argThat(email -> 
    email != null && email.contains("@")
))).thenReturn(true);

argThat()才是真正的利器。上周排查一个Bug,就是因为测试用例只匹配了固定值,而生产环境的数据格式稍有变化就导致测试覆盖失效。用参数匹配器可以避免这种尴尬。

有时候我们并不关心具体参数值,只关心方法是否被调用:

// 任何字符串都匹配
when(userRepository.findByName(anyString())).thenReturn(Optional.empty());

// 注意:any()会匹配null,any(Class)不会
when(userRepository.findById(any(Long.class))).thenReturn(Optional.empty());

这里踩过坑:any()any(Class)有细微差别。如果你明确不希望匹配null,就用带类型参数的版本。

注解的魔法

每次测试都要写一堆MockitoAnnotations.openMocks(this)挺烦人的。试试注解驱动:

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private PaymentGateway paymentGateway;
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    void shouldProcessOrder() {
        // 不用手动初始化了
    }
}

@Mock创建模拟对象,@InjectMocks会自动注入依赖。但要注意注入顺序——Mockito按字段类型匹配,如果有多个同类型字段,可能会注入错对象。我的经验是:保持字段名和被注入类的字段名一致,减少歧义。

@Captor是我最近才真正用顺手的注解:

@Captor
private ArgumentCaptor<Order> orderCaptor;

@Test
void shouldCaptureOrderDetails() {
    orderService.process(order);
    
    verify(paymentGateway).charge(orderCaptor.capture());
    assertEquals("PENDING", orderCaptor.getValue().getStatus());
}

调试复杂对象流转时,ArgumentCaptor比单纯验证调用次数有用得多。它能让你看到方法间传递的真实数据状态,特别是当对象经过多次修改时。

Spy对象:那个让人又爱又恨的特性

Spy是部分真实的对象。听起来很美好,但用不好就是灾难。

List<String> realList = new ArrayList<>();
List<String> spyList = spy(realList);

// 危险操作:这里会调用真实方法!
when(spyList.get(0)).thenReturn("mock");

// 正确写法
doReturn("mock").when(spyList).get(0);

看到区别了吗?when(spy.get(0))会先执行spy.get(0),如果索引0还没有元素,直接抛IndexOutOfBoundsException。这个坑我踩过三次才长记性。

那什么时候该用Spy?我的经验是:遗留代码改造期。有些老系统的方法依赖太多上下文,完全mock成本太高,但你又想隔离某些行为。比如:

@Spy
private LegacyValidator legacyValidator;

@Test
void shouldValidateWithMockedDatabaseCall() {
    // 只替换数据库查询部分
    doReturn(false).when(legacyValidator).checkInDatabase(any());
    
    // 其他验证逻辑保持真实
    assertFalse(legacyValidator.validate(input));
}

但谨慎使用Spy。它让测试处于半模拟半真实的状态,容易导致测试不稳定。我现在的原则是:能Mock就Mock,除非测试的确实是部分真实逻辑。

匹配器的组合与陷阱

匹配器可以组合,但有个奇怪的限制:

// 这样写会报错
verify(userDao).findByPhone(eq("13800138000"), anyString());

// 必须全部使用匹配器,或者全部不用
verify(userDao).findByPhone(eq("13800138000"), eq("code"));
// 或者
verify(userDao).findByPhone(any(), any());

这个设计有点反直觉,但Mockito要求参数匹配必须一致。我通常的做法:要么全用匹配器,要么全用真实值,避免混用。

个人经验谈

用了五年Mockito,有些心得不吐不快:

  1. 匹配器别过度:测试的核心是确定性。如果每个参数都用any(),那测试在验证什么?关键参数用具体值,次要参数用匹配器,这个分寸要把握好。

  2. 注解虽好,但要清醒@InjectMocks在简单场景下省事,但依赖复杂时可能掩盖了真正的依赖关系。我见过有人被注入错误搞了一下午。复杂服务建议显式构造,测试意图更清晰。

  3. Spy是最后的选择:就像goto语句,不是不能用,但要有个好理由。每次想用Spy时,先问问:是不是代码设计有问题?能不能重构得更可测试?

  4. 保持测试纯净:Mockito的进阶特性容易让人炫技,但测试代码也是代码,要维护的。我现在的风格是:简单直白为主,复杂技巧只在必要时用,并且加上详细注释——因为三个月后我自己也可能看不懂。

好的测试不是展示Mockito掌握多少特性,而是真实反映代码行为。下次写测试时,不妨想想:这个测试在三年后,别人(或你自己)还能看懂它在测什么吗?

007、Spring Boot集成测试:@SpringBootTest与Test Slice


一、从一次深夜调试说起

上周排查一个线上问题,订单服务在本地环境一切正常,上了测试环境就报DataSource连接异常。查了两小时才发现,是测试类里用@SpringBootTest加载了全量上下文,但某个配置类在测试环境下被条件注解激活,引入了额外的数据源配置。这种“测试环境依赖症”在集成测试中太常见了——你以为测的是业务逻辑,实际可能在和环境配置斗智斗勇。

集成测试的痛点从来不是“能不能跑”,而是“到底在测什么”。今天我们就聊聊Spring Boot里两个核心武器:@SpringBootTest和Test Slice,怎么用它们写出既可靠又高效的集成测试。


二、@SpringBootTest:重剑还是钝器?

先看一段典型代码:

@SpringBootTest
class OrderServiceTest {
    @Autowired
    private OrderRepository orderRepository;
    
    @Test
    void shouldCreateOrder() {
        // 测试代码
    }
}

看起来清爽吧?但这里藏了三个隐患:

第一,它启动了完整Spring上下文。你的@Configuration类、@ComponentScan扫到的所有Bean、自动配置类全被加载。跑一个测试方法可能等上30秒——我见过项目里跑完整套测试要25分钟,团队后来都不敢随便加测试。

第二,上下文缓存可能失效。Spring会尝试缓存测试上下文,但如果你的@SpringBootTest配置参数(如classesproperties)稍有不同,就会重新构建上下文。曾经有个项目定义了10种测试配置,每次跑测试都启动10次容器,机器风扇呼呼响。

第三,数据库状态污染。很多人忘了加@Transactional,测试数据留到数据库里,下一个测试方法就莫名其妙挂了。更坑的是有些测试里手动开了事务又没回滚,数据像幽灵一样时隐时现。


三、Test Slice:手术刀式的精准测试

Spring Boot从1.4开始引入“Test Slice”概念,核心思想是:只加载你测试需要的部分。看几个常用切片:

1. @WebMvcTest:控制器层隔离测试

@WebMvcTest(OrderController.class)  // 只加载Controller相关配置
class OrderControllerTest {
    
    @MockBean  // 自动替换容器里的真实Bean
    private OrderService orderService;
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void shouldReturnOrder() throws Exception {
        // 这里mockMvc是自动配置好的,不用自己搭
        mockMvc.perform(get("/orders/1"))
               .andExpect(status().isOk());
    }
}

关键点

  • 不会加载@Service@Repository这些Bean,启动速度秒级
  • @MockBean替换依赖,测试专注在HTTP层和JSON序列化
  • 自动配置MockMvc,省去那些繁琐的standaloneSetup代码

踩坑提醒:如果你在Controller里注入了@Repository,这里会报找不到Bean——这就对了!说明你的Controller耦合了数据层逻辑,该重构了。


2. @DataJpaTest:仓库层测试利器

@DataJpaTest
class OrderRepositoryTest {
    
    @Autowired
    private TestEntityManager entityManager;  // 比JPA EntityManager更好用
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Test
    void shouldFindByStatus() {
        // 用TestEntityManager准备数据,它自带flush和clear管理
        entityManager.persist(new Order("PAID"));
        entityManager.persist(new Order("PENDING"));
        
        List<Order> paidOrders = orderRepository.findByStatus("PAID");
        assertThat(paidOrders).hasSize(1);  // 这里用AssertJ更流畅
    }
}

隐藏福利

  • 默认使用嵌入式H2数据库,即使你生产用MySQL
  • 自动开启事务,每个测试方法结束都会回滚
  • 可以替换为真实数据库:@AutoConfigureTestDatabase(replace = Replace.NONE)
  • 不会加载@Service@Controller,纯粹测JPA层

常见误区:有人非要用生产数据库测,说“H2和MySQL语法有差异”。但集成测试的目标是验证你的代码逻辑,不是测数据库兼容性。后者应该交给QA环境做端到端测试。


3. @JsonTest:JSON序列化专项测试

@JsonTest
class OrderJsonTest {
    
    @Autowired
    private JacksonTester<Order> json;  // 自动配置的Jackson组件
    
    @Test
    void shouldSerialize() throws IOException {
        Order order = new Order("123", BigDecimal.valueOf(99.99));
        
        assertThat(json.write(order))
            .hasJsonPathStringValue("$.orderNumber")  // 这个断言超好用
            .extractingJsonPathStringValue("$.status")
            .isEqualTo("CREATED");
    }
}

这种测试特别适合DTO和API契约变更时快速验证,比跑完整接口测试轻量得多。


4. 自定义切片:组合你的专属工具

如果内置切片不满足需求,可以自己定义:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@BootstrapWith(MyTestSliceBootstrapper.class)  // 关键在这里
@ExtendWith(SpringExtension.class)
public @interface MyCacheTest {
    @AliasFor(annotation = AutoConfigureCache.class, attribute = "enabled")
    boolean enableCache() default true;
}

我们项目里就用自定义切片测试缓存逻辑,只加载CacheManager和相关配置,3秒启动一个测试类。


四、@SpringBootTest的正确打开方式

Test Slice虽好,但有些场景还得用全上下文测试:

1. 多组件集成验证

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)  // 用随机端口避免冲突
class OrderIntegrationTest {
    
    @LocalServerPort
    private int port;  // 注入随机端口号
    
    @Test
    void shouldCreateOrderThroughApi() {
        // 用TestRestTemplate而不是MockMvc,走真实网络栈
        ResponseEntity<Order> response = restTemplate.postForEntity(
            "http://localhost:" + port + "/orders",
            new OrderRequest(),
            Order.class
        );
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}

什么时候用这个:当你需要验证从HTTP入口到数据库的完整链条时。但记住,这是集成测试不是单元测试,运行成本高,应该放在CI流水线的后期阶段。

2. 配置属性覆盖技巧

@SpringBootTest(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",  // 覆盖配置
    "logging.level.com.example=DEBUG"
})
@TestPropertySource(locations = "classpath:test.properties")  // 还能加载文件
class ConfigTest {
    // 测试特定配置下的行为
}

经验之谈:属性加载顺序是个坑。@TestPropertySource优先级高于application.properties,但低于properties参数。搞混了就会遇到“为什么我的配置没生效”的灵魂拷问。


五、性能优化实战建议

  1. 分层使用策略

    • 70%测试用Test Slice(秒级启动)
    • 20%用@SpringBootTest+@MockBean(5-10秒)
    • 10%用完整集成测试(真实依赖,跑夜间构建)
  2. 上下文缓存配置
    src/test/resources/application-test.properties里加:

    spring.test.context.cache.max-size=50  # 默认32,大项目可以调高
    

    然后观察日志里的ContextCache统计,调整到合适值。

  3. 避免@DirtiesContext
    这注解强制重建上下文,性能杀手。如果测试必须修改容器状态(比如改Bean定义),考虑用@TestConfiguration静态内部类提供测试专用Bean。

  4. 数据库测试的黄金法则

    @DataJpaTest
    @AutoConfigureTestDatabase(replace = Replace.NONE)  // 用真实数据库时
    @Transactional(propagation = Propagation.NOT_SUPPORTED)  // 某些测试需要关事务
    @TestInstance(TestInstance.Lifecycle.PER_CLASS)  // 共享测试数据准备
    class HeavyRepositoryTest {
        // 用@BeforeAll准备基础数据,所有测试方法共用
    }
    

六、我踩过的那些坑

  1. MockBean的副作用
    @SpringBootTest里用@MockBean,这个Mock会污染所有用到该Bean的测试。曾经有个@MockBean UserService,导致另外三个不相关的测试失败。解决方案:要么用@TestConfiguration局部替换,要么把MockBean测试单独放一个类。

  2. 静态字段导致内存泄漏
    Spring测试上下文缓存会持有所有Bean引用。如果你的Bean有静态Map且不断往里塞数据,内存会持续增长。遇到过OOM,最后用@DirtiesContext临时解决,长期方案是修复那个静态缓存的设计。

  3. 测试顺序依赖
    JUnit 5默认测试方法顺序是不确定的。但用@SpringBootTest时,如果测试方法A改了数据库,方法B可能意外通过。后来我们强制加@TestMethodOrder(MethodOrderer.DisplayName.class),用方法名排序,至少让问题可复现。

  4. 配置文件优先级陷阱
    测试时Spring会加载application-test.propertiesapplication.properties、命令行参数……曾经因为一个配置在多个文件出现,调试了两小时。现在团队约定:测试配置全部写在@SpringBootTestproperties参数里,一目了然。


七、写在最后

好的集成测试应该像体检中的专项检查——胃疼就查胃镜,别动不动做全身CT。@SpringBootTest是全身CT,该用的时候得用,但别每个测试都来一套。Test Slice是胃镜肠镜超声波,精准快速。

我的习惯是:写测试前先问“我要验证的是什么”。如果是Controller的JSON格式,用@WebMvcTest;如果是Repository查询逻辑,用@DataJpaTest;如果是多个服务协作,才考虑@SpringBootTest

最后记住,测试代码也是代码,需要设计、需要重构、需要维护。那些启动慢、经常失败的测试,最后都会被团队绕过,然后失去存在的意义。让测试保持简单、快速、稳定,它才会被信任、被运行、被持续维护。

测试不是写完就结束的事情,它和业务代码一样,需要在项目演进中不断打磨。今天的一个小优化,可能省下团队未来几百个小时的等待时间——这大概就是工程师的浪漫吧。

008、数据库层测试:@DataJpaTest与嵌入式数据库

昨天排查一个线上问题,日志里看到一条简单的用户查询语句居然抛了DataIntegrityViolationException。开发环境跑单元测试时明明全部通过,怎么到真实数据库就出问题?打开测试代码一看,所有Repository测试都直接连了开发数据库——测试数据互相污染,约束条件形同虚设。是该好好聊聊数据库层测试该怎么写了。

为什么需要隔离的数据库测试

直接连开发库测试Repository,就像用生产服务器练手Linux命令。你永远不知道测试失败是因为代码问题,还是因为隔壁同事刚删了你的测试数据。真正的数据库测试必须满足三个条件:隔离性(每个测试独立)、可重复性(每次结果一致)、快速性(秒级完成)。

Spring Boot给的解决方案很明确:@DataJpaTest + 嵌入式数据库。这组合不是可选项,是数据库层测试的标配。

@DataJpaTest 到底做了什么

@DataJpaTest
class UserRepositoryTest {
    // 测试类
}

这个注解看起来简单,背后干了不少事。它自动配置了H2、HSQL或Derby这些内存数据库(按classpath顺序选择),只初始化JPA相关的Bean。事务默认开启并在每个测试后回滚,保证数据隔离。SQL日志会自动打开,调试时特别有用。

但要注意,它不会加载@Component@Service@Controller这些Bean。如果你在Repository里依赖了某个Service,这里会直接失败。这是设计如此——数据库层测试就该纯粹。

嵌入式数据库选型

Spring Boot默认支持三种内存数据库:

  • H2(推荐):语法最接近MySQL/PostgreSQL,支持内存模式和文件模式
  • HSQLDB:轻量级,启动快
  • Derby:Apache出品,更稳定但稍重

个人习惯用H2,主要是它的兼容模式做得最好。在application-test.properties里可以这样配置:

spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect

注意那个MODE=MySQL参数,它让H2兼容MySQL的语法细节。比如自增ID的处理、时间函数等。这里踩过坑:不加这个参数时,@GeneratedValue策略可能在H2上工作不正常。

测试数据准备的最佳姿势

很多人喜欢在测试方法里手动repository.save(),这不是好习惯。测试数据应该清晰可见,且与测试逻辑分离。

@Test
void findByEmail_WhenUserExists_ReturnsUser() {
    // 别这样写:数据构造混在断言里
    // userRepository.save(new User("test@email.com"));
    
    // 应该用@Sql或者明确的数据准备方法
    User savedUser = givenUserExists("test@email.com");
    
    User found = userRepository.findByEmail("test@email.com");
    
    assertThat(found.getId()).isNotNull();
    assertThat(found.getEmail()).isEqualTo(savedUser.getEmail());
}

private User givenUserExists(String email) {
    return userRepository.save(User.builder()
        .email(email)
        .status(ACTIVE)
        .build());
}

更复杂的初始数据可以用@Sql注解:

@Test
@Sql("/scripts/create-multiple-users.sql")
void count_AfterInsertingFiveUsers_ReturnsFive() {
    assertThat(userRepository.count()).isEqualTo(5);
}

SQL文件放在src/test/resources/scripts/下。这样数据准备逻辑一目了然,也方便复用。

测试事务的坑

@DataJpaTest默认每个测试都在事务中执行,测试结束后自动回滚。这带来一个常见问题:测试方法里的事务和代码里的事务可能产生冲突

@Test
void testUpdateUser() {
    User user = userRepository.findById(1L).orElseThrow();
    user.setName("NewName");
    
    // 这里不会立即更新到数据库!
    // 因为测试方法本身就在事务里,变更还在内存中
    // 需要手动flush或者调用repository.save()
    
    entityManager.flush();  // 强制同步到数据库
    entityManager.clear();  // 清除一级缓存
    
    User updated = userRepository.findById(1L).orElseThrow();
    assertThat(updated.getName()).isEqualTo("NewName");
}

如果测试@Transactional注解的方法,更要注意事务传播行为。有时候需要@Transactional(propagation = Propagation.NOT_SUPPORTED)来暂时挂起事务。

自定义配置与切片测试

有时候你需要覆盖默认配置,比如想用特定的数据库初始化脚本:

@DataJpaTest
@TestPropertySource(properties = {
    "spring.sql.init.schema-locations=classpath:schema-test.sql",
    "spring.sql.init.data-locations=classpath:data-test.sql"
})
class CustomSchemaRepositoryTest {
    // 会先执行schema-test.sql,再执行data-test.sql
}

如果想测试包含少量业务逻辑的Service层,但又想用内存数据库,可以用@DataJpaTest配合@Import

@DataJpaTest
@Import({UserService.class, AuditService.class})  // 只导入需要的Bean
class UserServiceIntegrationTest {
    @Autowired
    private UserService userService;
    
    @Autowired
    private UserRepository userRepository;
    
    // 既能测试数据库操作,又能测试业务逻辑
}

这种“切片测试”比完整的@SpringBootTest快得多,因为Spring只需要加载部分应用上下文。

性能优化建议

  1. 复用应用上下文:Spring Test默认会缓存应用上下文,相同配置的测试类共享一个上下文。保持测试配置一致能大幅提升速度。

  2. 避免@DirtiesContext:这个注解强制Spring重新加载上下文,非常耗时。除非必须(比如修改Bean定义),否则不要用。

  3. 分批次测试:把需要相同数据准备的测试放在同一个类里,减少数据初始化次数。

  4. 谨慎使用@Sql:每个@Sql都会执行一次数据库操作,多个测试类重复执行相同SQL时,考虑在@BeforeClass里一次性初始化。

个人经验

数据库测试不是越多越好,重点测试自定义查询方法复杂关联关系。Spring Data JPA自动生成的CRUD方法基本不用测,那是框架的责任。

遇到诡异测试失败时,先检查这几个地方:H2的兼容模式是否开启、事务边界是否正确、实体类映射是否完整(特别是懒加载关联)。有时候在测试里加个entityManager.flush()就能暴露问题。

最后记住,@DataJpaTest是单元测试,不是集成测试。如果需要测试真实数据库的特性(如存储过程、特定函数),还是得用Testcontainers启动真实数据库实例。但那是另一个话题了。

好的数据库测试应该像数据库本身一样稳定可靠——不引人注意,但随时待命。

009、Web层测试:@WebMvcTest与MockMvc实战

昨天排查线上问题,发现一个诡异的场景:用户提交订单时系统返回500错误,但日志里没有任何异常堆栈。查了半天才发现,原来是Controller里某个字段的校验注解漏写了,导致请求根本进不了业务逻辑。这种问题如果在单元测试阶段就能发现,能省下至少三小时的排查时间。今天我们就聊聊怎么用Spring Boot的测试工具,把Web层的漏洞提前堵上。

为什么需要专门的Web层测试?

很多人觉得Controller就是简单的参数转发,写个Service层的单元测试就够了。这种想法很危险。Controller承担着HTTP协议到Java对象的转换、参数校验、权限拦截、响应格式封装等职责。我见过太多案例:PostMapping误写成GetMapping、@RequestParam漏了required=false导致接口变脆弱、@Validated注解位置放错让校验失效。这些坑,光测Service是发现不了的。

Spring Boot提供了@WebMvcTest注解,它能帮我们启动一个轻量级的Web测试环境,只加载Controller相关的Bean,不启动完整的Spring容器。测试速度比@SpringBootTest快得多,通常能在2秒内跑完几十个Controller测试。

搭建测试脚手架

先看一个典型的测试类结构:

@WebMvcTest(OrderController.class)  // 只加载OrderController这一个Controller
@AutoConfigureMockMvc                // 自动配置MockMvc
@Import(SecurityConfig.class)        // 如果需要安全配置,单独导入
class OrderControllerTest {
    
    @Autowired
    private MockMvc mockMvc;          // 核心测试工具
    
    @MockBean
    private OrderService orderService; // 自动注入Mock对象
    
    // 测试用例写在这里
}

注意@MockBean的用法。它会在Spring的测试上下文中,用Mockito的mock对象替换掉真实的OrderService。这样我们就能完全控制Service层的行为,专注测试Controller的逻辑。千万别用@Autowired注入真实的Service,那样就变成集成测试了,会引入数据库、缓存等各种依赖。

模拟HTTP请求的几种姿势

测试GET接口

@Test
void getOrder_shouldReturnOrder_whenOrderExists() throws Exception {
    // 准备Mock数据
    Order mockOrder = new Order("123", "pending");
    when(orderService.getOrder("123")).thenReturn(mockOrder);
    
    // 发起请求并验证
    mockMvc.perform(get("/orders/123")  // 注意静态导入MockMvcRequestBuilders.*
            .header("Authorization", "Bearer token123")
            .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())     // 验证状态码
        .andExpect(jsonPath("$.id").value("123"))  // 验证JSON字段
        .andExpect(jsonPath("$.status").value("pending"));
    
    // 验证Service被正确调用
    verify(orderService).getOrder("123");
}

这里有个细节:jsonPath语法非常强大,$.items[0].name这种嵌套结构也能轻松验证。但别过度使用,验证关键字段就行,否则测试会变得脆弱。

测试POST接口

@Test
void createOrder_shouldReturn400_whenRequestInvalid() throws Exception {
    // 故意构造一个无效请求
    String invalidJson = "{\"amount\": -100}";  // 金额为负数
    
    mockMvc.perform(post("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content(invalidJson))
        .andExpect(status().isBadRequest());  // 期待400错误
    
    // 验证Service没有被调用(因为参数校验没通过)
    verify(orderService, never()).createOrder(any());
}

这个测试验证了参数校验的有效性。很多团队只测“正常路径”,其实“异常路径”更重要。特别是当你的Controller用了@Valid注解时,一定要测试校验失败的情况。

测试文件上传

@Test
void uploadFile_shouldSucceed() throws Exception {
    MockMultipartFile file = new MockMultipartFile(
        "file",                     // 参数名,对应@RequestParam("file")
        "test.txt",                 // 原始文件名
        "text/plain",               // 内容类型
        "file content".getBytes()   // 文件内容
    );
    
    mockMvc.perform(multipart("/upload").file(file))
        .andExpect(status().isOk());
}

文件上传测试最容易漏掉content-type的验证。如果接口限定了只能上传图片,记得测试上传文本文件是否会被拒绝。

那些年踩过的坑

坑1:日期格式序列化问题

@Test
void testDateSerialization() throws Exception {
    Order order = new Order();
    order.setCreateTime(LocalDateTime.of(2023, 1, 1, 10, 30));
    
    when(orderService.getOrder(any())).thenReturn(order);
    
    mockMvc.perform(get("/orders/1"))
        .andDo(print())  // 打印详细请求响应信息,调试神器
        .andExpect(jsonPath("$.createTime").value("2023-01-01T10:30:00"));
}

如果测试失败,可能是Jackson的日期格式配置问题。@WebMvcTest默认不会加载你的application.yml中的Jackson配置,需要在测试类上额外加@Import(JacksonConfig.class)

坑2:静态方法导致的Mock失效

// Controller里这样写,测试会很痛苦
public ResponseEntity<?> createOrder() {
    AuthUser user = SecurityContextHolder.getContext().getAuthentication(); // 静态方法
    // ...
}

静态方法调用很难Mock。建议改成依赖注入:

// 改成这样
public ResponseEntity<?> createOrder(@AuthenticationPrincipal AuthUser user) {
    // ...
}

坑3:异步接口测试

@Test
void asyncEndpoint() throws Exception {
    mockMvc.perform(get("/async"))
        .andExpect(request().asyncStarted())  // 先验证异步已启动
        .andDo(MvcResult::getAsyncResult)     // 等待异步完成
        .andExpect(status().isOk());
}

异步接口测试容易超时,默认超时时间是30秒。可以通过.andExpect(asyncResult().isDone())来设置自定义超时。

个人经验建议

  1. 测试分类要明确@WebMvcTest只测Controller层,Service层用普通的@ExtendWith(MockitoExtension.class),两者别混在一起。我见过有人用@WebMvcTest却把Service的@MockBean换成@Autowired真实对象,结果测试跑3分钟,失去了快速反馈的意义。

  2. 验证“不变量”:每个测试除了验证正常逻辑,还要验证一些不变量。比如删除接口,无论成功与否,都应该验证审计日志被调用;创建接口,验证返回的Location头是否正确。

  3. 合理使用Mock:Controller的依赖(Service、Repository)全部Mock,但转换器(Converter)、验证器(Validator)建议用真实的。因为这些对象通常无状态、无依赖,用真实的能发现配置错误。

  4. 关注异常转换:Spring的@ControllerAdvice会统一处理异常。测试时要覆盖各种异常路径,确保业务异常能正确转换为HTTP状态码。我曾经漏测一个异常分支,导致生产环境抛出的异常直接暴露了SQL语句。

  5. 保持测试独立:每个测试方法结束后,用Mockito.reset()清理Mock状态,或者直接为每个测试方法创建新的测试实例(JUnit5默认行为)。避免测试之间的相互影响。

最后说个心态问题:别追求100%的测试覆盖率,特别是Controller层。有些简单的CRUD接口,如果业务逻辑都在Service层,Controller只是透传,那么写一两个集成测试可能更划算。测试是手段,不是目的,我们的目标是用最小成本降低线上风险。

下次我们聊聊Service层的测试怎么写,那里才是业务逻辑的重灾区。

010、构建持续集成(CI)测试流水线与测试最佳实践总结


从一次深夜报警说起

上周三凌晨两点,钉钉突然弹出一条告警:“订单服务集成测试失败,分支合并阻塞”。爬起来看了一眼日志,发现是某个Mock对象的返回值配置被覆盖了,导致下游服务超时。这已经是本月第三次因为测试环境问题触发的虚假告警。团队里有人开始抱怨:“测试用例本地跑得好好的,一上CI就挂,这流水线是不是有问题?”

其实问题不在流水线,而在我们对持续集成的理解——很多人把CI简单理解成“自动跑测试的脚本”,却忽略了它作为质量反馈环的核心价值。今天我们就来聊聊,如何让测试流水线真正成为开发流程的可靠环节。


CI流水线的骨架该长什么样

先看一个典型的Java项目CI配置(GitLab CI示例):

stages:
  - validate
  - test
  - analyze
  - package

# 第一阶段:基础验证
code-check:
  stage: validate
  script:
    - mvn clean compile  # 确保能编译通过
    - mvn spotless:check  # 代码格式检查,这里踩过坑:格式问题应该尽早暴露

# 第二阶段:分层测试
unit-test:
  stage: test
  script:
    - mvn test -DskipITs  # 只跑单元测试
  artifacts:
    paths:
      - target/surefire-reports/  # 保存测试报告
    expire_in: 1 week

integration-test:
  stage: test
  script:
    - mvn verify -Dit.test="*IT"  # 专门跑集成测试
    # 注意这里:集成测试需要真实数据库,用Testcontainers启动临时PostgreSQL
    - docker run --rm -d -p 5432:5432 postgres:13-alpine
  dependencies:
    - unit-test  # 依赖单元测试阶段

关键点在于阶段分离。见过不少团队把所有测试混在一个mvn test里跑,结果就是:

  • 单元测试因为数据库连接失败而挂掉(其实不该连数据库)
  • 快速反馈的单元测试被沉重的集成测试拖慢
  • 出问题时难以定位是哪个测试层的问题

测试环境治理:CI稳定性的命门

CI环境最头疼的就是“在我机器上好好的”。分享几个实战经验:

数据库隔离策略

@Testcontainers
class OrderRepositoryIT {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
        .withDatabaseName("testdb")
        .withReuse(true);  // 这个参数很重要:允许容器复用,避免每次测试都重启
    
    @BeforeAll
    static void setup() {
        // 别在测试里写死localhost:5432
        System.setProperty("DB_URL", postgres.getJdbcUrl());
    }
}

外部服务Mock的陷阱

// 危险写法:在CI环境中可能失效
@MockBean
private PaymentService paymentService;

@Test
void should_create_order_when_payment_success() {
    when(paymentService.pay(any())).thenReturn(new PaymentResult("SUCCESS"));
    // CI机器可能因为网络问题导致pay()实际被调用,然后超时失败
}

// 建议写法:增加超时控制和降级
@Test
void should_handle_timeout_in_ci() {
    when(paymentService.pay(any()))
        .thenAnswer(invocation -> {
            // 模拟CI环境下的延迟
            Thread.sleep(100);
            return new PaymentResult("SUCCESS");
        })
        .getMock();  // 这里有个细节:确保Mock对象正确返回
}

CI环境中一定要假设网络不可靠、外部服务可能超时。见过最惨的一次是某个HTTP Mock忘记配置,测试用例直接调了生产环境的支付网关,差点产生真实交易。


测试报告的艺术

流水线跑完了,开发人员看什么?不仅仅是“通过/失败”的状态。

JUnit 5的扩展用法

<!-- pom.xml里加上这些依赖 -->
<dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-suite</artifactId>
</dependency>
<dependency>
    <groupId>io.qameta.allure</groupId>
    <artifactId>allure-junit5</artifactId>
</dependency>

生成可读性强的报告:

mvn test allure:report
# 会在target/site/allure-maven-plugin生成HTML报告

报告里应该包含:

  1. 失败用例的日志截图(自动捕获)
  2. 测试耗时TOP 10榜单
  3. 新增用例的覆盖情况
  4. 与上次运行的对比趋势

我们团队在Slack里配置了机器人,每次流水线结束自动推送:
“✅ 测试通过 | 覆盖率72% (+1.2%) | 最慢用例: OrderServiceIT.createOrder (4.2s)”


那些教科书不会告诉你的最佳实践

1. 测试速度是CI的灵魂
曾经优化过一个项目的CI时间,从45分钟降到8分钟。核心手段:

  • 并行执行:JUnit 5的@Execution(ConcurrentMode.SAME_THREAD)控制粒度
  • 分层执行:改动的模块相关测试优先跑
  • 缓存策略:Maven依赖缓存、Docker镜像缓存

2. 失败用例的自动诊断
不要只记录“AssertionError: expected true but was false”。我们写了个自定义的TestExecutionListener:

public class DiagnosticListener implements TestExecutionListener {
    @Override
    public void executionFinished(TestIdentifier identifier, TestExecutionResult result) {
        if (result.getStatus() == FAILED) {
            // 自动收集当时的内存dump、线程栈、数据库连接池状态
            dumpThreadStack();
            captureApplicationMetrics();
            // 这些信息直接附在测试报告里
        }
    }
}

3. 测试数据的生命周期管理
绝对不要在CI里用@Sql(scripts = "classpath:test-data.sql")加载静态数据。推荐:

  • 每个测试用例自己创建需要的数据(用Factory模式)
  • 测试结束后用@Transactional回滚
  • 或者用@DirtiesContext标记会污染上下文的测试

4. 模拟器的选择智慧

  • 单元测试:Mockito足够了
  • 集成测试:考虑WireMock模拟HTTP服务,Testcontainers模拟数据库
  • 端到端测试:用真实的测试环境,但要有环境健康检查机制

见过有人用Mockito模拟Kafka,结果因为线程模型不对,在CI上随机失败。后来换成EmbeddedKafka,问题立刻消失。


个人经验池

做了这么多年CI,最大的感悟是:持续集成流水线不是测试执行器,而是团队开发习惯的镜子

几个血泪教训:

  1. 别追求100%通过率,而要追求100%稳定性。偶尔失败的测试用例(flaky test)比总是失败的用例更可怕,它会让人养成“重跑一下试试”的坏习惯。

  2. 流水线失败必须阻塞合并,这是铁律。曾经妥协过,允许“某些不影响功能的测试失败可以合并”,结果三个月后技术债爆发,修复成本是当初的十倍。

  3. 给测试用例写测试。听起来绕,但很重要——定期检查测试用例是否还能检测出缺陷。我们每个月会故意在代码里注入一些bug,验证测试用例能否捕获。

  4. CI配置也要做代码审查。见过因为一个错误的缓存配置,导致所有分支共用同一个测试数据库,数据污染得一塌糊涂。

  5. 保持反馈环的短促。如果开发人员提交代码后要等30分钟才知道结果,他们就会开始刷手机,上下文切换成本极高。我们的标准是:核心测试链必须在10分钟内完成。


最后说个真实故事:去年重构一个老旧系统,原来的CI流水线已经三年没人敢动。我们花了两个月时间,把那个一跑就挂的“摆设流水线”,改成了团队每天依赖的质量守护者。现在每次代码评审,第一句话都是:“CI过了吗?”

这就是持续集成的终极意义——它不是挂在墙上的仪表盘,而是长在团队心里的质量意识。当你开始相信流水线的结果胜过自己的直觉时,才真正走上了工程化的正轨。

流水线会老,测试会过时,但那个凌晨两点被虚假告警吵醒的夜晚,提醒着我们:可靠的测试不是写出来的,是持续不断养出来的


想要解锁更多Java底层原理与实战技巧,欢迎订阅我的专栏


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐