13《单元测试与集成测试:JUnit5 + Mockito实战》
单元测试与集成测试: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)的核心不是“先写测试”,而是通过测试来定义需求。它的循环很简单:
- 写一个失败的单测(描述需求)
- 写最少代码让测试通过
- 重构代码,同时保持测试通过
听起来像教科书?实际项目中,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();
}
}
如此循环。
你会发现,最终产生的代码天然被测试覆盖,而且每个分支都有对应测试用例。
四、嵌入式场景的特殊考量
在嵌入式开发中做单元测试,有几个常见问题:
-
硬件依赖:测试代码不能依赖实际硬件
- 方案:用接口抽象硬件操作,测试时注入 Mock
- 例如
ISensor.read()接口,实机实现读寄存器,测试时用 Mockito 模拟返回值
-
时序和并发:
- 避免在单测中写
Thread.sleep(),用CountDownLatch或虚拟时间(如Awaitility库)
- 避免在单测中写
-
内存约束:
- 测试本身也可能内存泄漏,定期检查测试套件的内存增长
个人经验建议
-
不要追求 100% 覆盖率:关键逻辑(业务规则、条件分支、错误处理)必须覆盖,但 getter/setter 或简单委托方法不必强求。覆盖率是辅助指标,不是目标。
-
测试代码也要重构:看到重复的测试数据准备代码,就该考虑抽成
@BeforeEach或工厂方法。测试代码的维护成本也是成本。 -
嵌入式项目先测逻辑层:从硬件抽象层(HAL)往上数,越靠近业务逻辑的层,单元测试收益越高。驱动层代码用集成测试补覆盖。
-
JUnit5 迁移可以渐进:老项目不用一次性全迁,新模块用 JUnit5,老模块保持 JUnit4,两者可以共存。
-
单测失败即视为阻塞: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 -> 测试方法
}
}
生命周期:顺序很重要
标准执行顺序
- 调用
@BeforeAll(静态方法) - 对于每个
@Test方法:- 创建测试类的新实例(**重点!**每个测试方法都是独立实例)
- 执行
@BeforeEach - 执行
@Test方法体 - 执行
@AfterEach
- 调用
@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做验证。
个人经验与坑点
-
测试隔离是第一位
每个@Test方法独立实例的设计,就是为了强制隔离。如果你发现测试之间有依赖,先检查是不是误用了static变量。 -
@BeforeAll的静态限制
想在这个方法里初始化非静态资源?考虑用@TestInstance(Lifecycle.PER_CLASS)注解类,让整个测试类只创建一个实例。但慎用,可能破坏隔离性。 -
IDE的兼容问题
有些老版本的IntelliJ对JUnit5的嵌套测试支持不完整。如果@Nested类里的测试没被识别,升级IDE或检查插件版本。 -
生命周期扩展的执行顺序
多个@ExtendWith注解时,执行顺序是声明顺序。如果有依赖关系,把被依赖的扩展放前面。 -
参数化测试的命名
默认的显示名[1]、[2]不友好,用@ParameterizedTest(name = "Case {index}: {0}")自定义,日志排查时能省很多时间。 -
不要依赖测试执行顺序
虽然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);
}
这样,测试代码读起来就像领域语言,维护点也集中。
经验性建议
- 断言信息要具体:失败消息里带上输入值、上下文,别让同事猜谜。
- 浮点比较必用 delta:这是单元测试的经典坑,我见过至少三次线上问题源于此。
- 善用假设:不是所有测试都必须在所有环境跑,条件性执行能让测试套件更健壮。
- 标签化要尽早:项目大了再加标签成本高,一开始就分 fast/slow。
- 自定义断言封装领域知识:测试代码也是代码,要遵循 DRY 原则。
- 链式断言调试友好:特别是对象多个字段验证时,一次性看到所有问题。
- 断言库选型要统一:一个项目里别混用多种风格,增加维护成本。
最后提醒:测试失败信息是你写给三个月后的自己(或同事)的调试指南。多花 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的优势在于:
- 参数可以从文件、数据库动态加载
- 支持复杂的对象参数
- 用例可复用
动态测试:运行时生成测试用例
参数化测试的用例是编译期确定的,而动态测试可以在运行时动态创建用例。这个特性在测试数据驱动或不确定用例数量的场景下非常有用。
一个真实场景:配置文件驱动测试
我们系统有几十种消息模板,每种模板有不同变量。手动写测试不现实。
@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)
但要小心共享状态的问题。
我的经验建议
-
参数选择策略:优先用
@MethodSource,它最灵活。CSV适合简单数据,ValueSource基本只用于演示。 -
测试数据管理:复杂的测试数据建议放到JSON或YAML文件里,用
@MethodSource加载。这样测试代码和测试数据分离,非技术人员也能维护用例。 -
动态测试的使用时机:不要为了炫技而用动态测试。它的适用场景是:用例数量不确定、用例需要运行时计算、测试需要分层组织。
-
IDE兼容性:IntelliJ IDEA对JUnit5支持最好,Eclipse稍慢。动态测试在CI/CD工具中的报告格式要提前验证。
-
最重要的原则:参数化测试的目的是提高覆盖率,不是减少代码行数。如果一个测试用例的逻辑分支完全不同,拆成多个普通测试反而更清晰。
最后一点思考
好的测试应该像文档一样可读。参数化测试的用例数据,实际上就是一份可执行的业务规则说明书。下次产品经理问你“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,我的体会是:模拟对象越多,测试离现实越远。如果一个类需要模拟三个以上的依赖才能测试,很可能这个类职责太重了,该重构了。
好的单元测试应该在“隔离”和“真实”之间找到平衡。我现在的做法是:
- 核心业务逻辑用真实对象+内存数据库
- 外部服务(支付、短信、邮件)用模拟对象
- 每加一个模拟对象,就问自己一次:这个类是不是做太多了?
最后记住,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,有些心得不吐不快:
-
匹配器别过度:测试的核心是确定性。如果每个参数都用
any(),那测试在验证什么?关键参数用具体值,次要参数用匹配器,这个分寸要把握好。 -
注解虽好,但要清醒:
@InjectMocks在简单场景下省事,但依赖复杂时可能掩盖了真正的依赖关系。我见过有人被注入错误搞了一下午。复杂服务建议显式构造,测试意图更清晰。 -
Spy是最后的选择:就像goto语句,不是不能用,但要有个好理由。每次想用Spy时,先问问:是不是代码设计有问题?能不能重构得更可测试?
-
保持测试纯净: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配置参数(如classes、properties)稍有不同,就会重新构建上下文。曾经有个项目定义了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参数。搞混了就会遇到“为什么我的配置没生效”的灵魂拷问。
五、性能优化实战建议
-
分层使用策略
- 70%测试用Test Slice(秒级启动)
- 20%用
@SpringBootTest+@MockBean(5-10秒) - 10%用完整集成测试(真实依赖,跑夜间构建)
-
上下文缓存配置
在src/test/resources/application-test.properties里加:spring.test.context.cache.max-size=50 # 默认32,大项目可以调高然后观察日志里的
ContextCache统计,调整到合适值。 -
避免@DirtiesContext
这注解强制重建上下文,性能杀手。如果测试必须修改容器状态(比如改Bean定义),考虑用@TestConfiguration静态内部类提供测试专用Bean。 -
数据库测试的黄金法则
@DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) // 用真实数据库时 @Transactional(propagation = Propagation.NOT_SUPPORTED) // 某些测试需要关事务 @TestInstance(TestInstance.Lifecycle.PER_CLASS) // 共享测试数据准备 class HeavyRepositoryTest { // 用@BeforeAll准备基础数据,所有测试方法共用 }
六、我踩过的那些坑
-
MockBean的副作用
在@SpringBootTest里用@MockBean,这个Mock会污染所有用到该Bean的测试。曾经有个@MockBean UserService,导致另外三个不相关的测试失败。解决方案:要么用@TestConfiguration局部替换,要么把MockBean测试单独放一个类。 -
静态字段导致内存泄漏
Spring测试上下文缓存会持有所有Bean引用。如果你的Bean有静态Map且不断往里塞数据,内存会持续增长。遇到过OOM,最后用@DirtiesContext临时解决,长期方案是修复那个静态缓存的设计。 -
测试顺序依赖
JUnit 5默认测试方法顺序是不确定的。但用@SpringBootTest时,如果测试方法A改了数据库,方法B可能意外通过。后来我们强制加@TestMethodOrder(MethodOrderer.DisplayName.class),用方法名排序,至少让问题可复现。 -
配置文件优先级陷阱
测试时Spring会加载application-test.properties、application.properties、命令行参数……曾经因为一个配置在多个文件出现,调试了两小时。现在团队约定:测试配置全部写在@SpringBootTest的properties参数里,一目了然。
七、写在最后
好的集成测试应该像体检中的专项检查——胃疼就查胃镜,别动不动做全身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只需要加载部分应用上下文。
性能优化建议
-
复用应用上下文:Spring Test默认会缓存应用上下文,相同配置的测试类共享一个上下文。保持测试配置一致能大幅提升速度。
-
避免@DirtiesContext:这个注解强制Spring重新加载上下文,非常耗时。除非必须(比如修改Bean定义),否则不要用。
-
分批次测试:把需要相同数据准备的测试放在同一个类里,减少数据初始化次数。
-
谨慎使用@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())来设置自定义超时。
个人经验建议
-
测试分类要明确:
@WebMvcTest只测Controller层,Service层用普通的@ExtendWith(MockitoExtension.class),两者别混在一起。我见过有人用@WebMvcTest却把Service的@MockBean换成@Autowired真实对象,结果测试跑3分钟,失去了快速反馈的意义。 -
验证“不变量”:每个测试除了验证正常逻辑,还要验证一些不变量。比如删除接口,无论成功与否,都应该验证审计日志被调用;创建接口,验证返回的Location头是否正确。
-
合理使用Mock:Controller的依赖(Service、Repository)全部Mock,但转换器(Converter)、验证器(Validator)建议用真实的。因为这些对象通常无状态、无依赖,用真实的能发现配置错误。
-
关注异常转换:Spring的
@ControllerAdvice会统一处理异常。测试时要覆盖各种异常路径,确保业务异常能正确转换为HTTP状态码。我曾经漏测一个异常分支,导致生产环境抛出的异常直接暴露了SQL语句。 -
保持测试独立:每个测试方法结束后,用
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报告
报告里应该包含:
- 失败用例的日志截图(自动捕获)
- 测试耗时TOP 10榜单
- 新增用例的覆盖情况
- 与上次运行的对比趋势
我们团队在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,最大的感悟是:持续集成流水线不是测试执行器,而是团队开发习惯的镜子。
几个血泪教训:
-
别追求100%通过率,而要追求100%稳定性。偶尔失败的测试用例(flaky test)比总是失败的用例更可怕,它会让人养成“重跑一下试试”的坏习惯。
-
流水线失败必须阻塞合并,这是铁律。曾经妥协过,允许“某些不影响功能的测试失败可以合并”,结果三个月后技术债爆发,修复成本是当初的十倍。
-
给测试用例写测试。听起来绕,但很重要——定期检查测试用例是否还能检测出缺陷。我们每个月会故意在代码里注入一些bug,验证测试用例能否捕获。
-
CI配置也要做代码审查。见过因为一个错误的缓存配置,导致所有分支共用同一个测试数据库,数据污染得一塌糊涂。
-
保持反馈环的短促。如果开发人员提交代码后要等30分钟才知道结果,他们就会开始刷手机,上下文切换成本极高。我们的标准是:核心测试链必须在10分钟内完成。
最后说个真实故事:去年重构一个老旧系统,原来的CI流水线已经三年没人敢动。我们花了两个月时间,把那个一跑就挂的“摆设流水线”,改成了团队每天依赖的质量守护者。现在每次代码评审,第一句话都是:“CI过了吗?”
这就是持续集成的终极意义——它不是挂在墙上的仪表盘,而是长在团队心里的质量意识。当你开始相信流水线的结果胜过自己的直觉时,才真正走上了工程化的正轨。
流水线会老,测试会过时,但那个凌晨两点被虚假告警吵醒的夜晚,提醒着我们:可靠的测试不是写出来的,是持续不断养出来的。
想要解锁更多Java底层原理与实战技巧,欢迎订阅我的专栏。
更多推荐


所有评论(0)