本文由 Claude Code 和我共同完成 😂😂😂
Java 的测试体系是每个 Java 开发者都绕不开的话题。本文覆盖从 Maven 构建集成、JUnit5 核心用法、断言体系、测试覆盖率到 Spring Test 的完整知识链路。
Maven 构建生命周期与测试阶段
Maven 的默认生命周期(default lifecycle)共有 23 个阶段,按顺序执行。与测试相关的几个关键阶段如下:
validate
↓
compile ← 编译 src/main/java
↓
test-compile ← 编译 src/test/java
↓
test ← Surefire 在此阶段运行单元测试(UT)
↓
package ← 打包成 jar/war
↓
pre-integration-test ← 启动外部资源(如嵌入式数据库、容器)
↓
integration-test ← Failsafe 在此阶段运行集成测试(IT)
↓
post-integration-test ← 关闭外部资源
↓
verify ← Failsafe 在此阶段汇报集成测试结果,失败则中断构建
↓
install ← 安装到本地 Maven 仓库
↓
deploy ← 发布到远程仓库
这个顺序意味着:执行 mvn install 时,UT 和 IT 都会运行;执行 mvn test 时只运行 UT;执行 mvn verify 时两者都运行但不 install。
在 Maven 中,测试的执行由两个插件负责:
- maven-surefire-plugin:负责运行单元测试,绑定在
test阶段。 - maven-failsafe-plugin:负责运行集成测试,绑定在
integration-test和verify阶段。
两者最重要的区别是:Surefire 中测试失败会立即中断构建,Failsafe 则会在 post-integration-test 阶段完成资源清理后,才在 verify 阶段报告失败。这样可以保证即使测试失败,也不会留下没有关闭的资源(例如启动的数据库、Docker 容器等)。
Surefire 插件默认约定
Surefire 会自动扫描并运行符合以下命名规则的测试类:
**/Test*.java**/*Test.java**/*Tests.java**/*TestCase.java
Failsafe 的默认扫描规则类似,但前后缀带 IT:
**/IT*.java**/*IT.java**/*ITCase.java
如果你使用 JUnit5,需要确保 Surefire 版本在 2.22.0 以上:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
实际项目中几乎不需要关心 IT 插件
尽管 Maven 为 IT 设计了单独的阶段和 Failsafe 插件,但在绝大多数 Java 项目里,我们不区分 UT 和 IT 的构建流程。即便测试用了 @SpringBootTest,本质上是集成测试,但我们依然把它命名为 *Test.java,交给 Surefire 在 test 阶段统一管理。所有测试走同一套流程,简单直接。
只有在少数场景下才需要引入 Failsafe:测试强依赖外部资源(真实数据库、第三方服务、Docker 容器),需要在测试前启动、测试后关闭,这时才有必要用 pre-integration-test / post-integration-test 阶段做生命周期管理。
因此,默认情况下只需要配置 Surefire 即可,不需要引入 Failsafe。
maven-surefire-report-plugin:测试结果报告
maven-surefire-plugin 运行完测试后,会在 target/surefire-reports/ 目录下生成 XML 和 TXT 格式的原始结果文件,每个测试类对应一个文件,记录了通过、失败、跳过的测试及耗时。
maven-surefire-report-plugin 的作用是读取这些 XML,生成一份可读的 HTML 报告,供人浏览。两者是配套关系,Surefire 负责产出原始数据,Surefire Report 负责把数据可视化。
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-report-plugin</artifactId>
<version>3.2.5</version>
<executions>
<execution>
<phase>test</phase>
<goals>
<!-- 生成独立的 HTML 报告,不依赖 mvn site -->
<goal>report-only</goal>
</goals>
</execution>
</executions>
</plugin>
运行 mvn test 后,HTML 报告生成在 target/site/surefire-report.html。
需要注意,这个报告和 JaCoCo 完全没有关系。Surefire Report 告诉你”哪些测试通过了”,JaCoCo 告诉你”哪些代码被测试覆盖到了”,两者数据来源不同,报告内容也不同,只是恰好都输出到 target/site/ 目录下。
如何跳过测试
几种跳过方式的区别
-DskipTests:只跳过测试执行,测试类仍然会被编译(test-compile阶段照常运行),只影响 Surefire。-Dmaven.test.skip=true:同时跳过编译和执行,Surefire 和 Failsafe 都受影响,target/test-classes不会产生。- Surefire 配置
<skipTests>true</skipTests>:效果同-DskipTests,不会跳过测试类的编译,只是不运行测试方法。
一个容易误解的地方:在 pom.xml 里配置了 <skipTests>true</skipTests>,不等于测试代码不编译,test-compile 阶段照样执行。真正能同时跳过编译和执行的,只有 -Dmaven.test.skip=true。
# 跳过测试执行,但仍然编译测试类(常用,打包时跳过)
mvn install -DskipTests
# 跳过测试编译和执行(彻底跳过,编译产物中没有 test-classes)
mvn install -Dmaven.test.skip=true
在 pom.xml 中配置跳过(只跳过执行,不跳过编译):
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
只运行指定测试
# 运行单个测试类
mvn test -Dtest=UserServiceTest
# 运行指定测试方法
mvn test -Dtest=UserServiceTest#testCreateUser
# 运行多个测试类
mvn test -Dtest=UserServiceTest,OrderServiceTest
排除指定测试
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/SlowTest.java</exclude>
</excludes>
</configuration>
</plugin>
JUnit5 基础
依赖引入
JUnit5 由三个模块组成:
- JUnit Platform:测试框架运行的基础平台,提供 Launcher API。
- JUnit Jupiter:JUnit5 的编程模型和扩展模型,包含我们日常使用的注解和 API。
- JUnit Vintage:提供运行 JUnit3/4 测试的引擎,用于兼容旧代码。
实际开发中通常引入聚合依赖:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
如果使用 Spring Boot,spring-boot-starter-test 已经内置了 JUnit5,无需额外添加。
测试类结构
import org.junit.jupiter.api.*;
class CalculatorTest {
// 在当前测试类所有测试方法执行前执行一次
// 默认生命周期(PER_METHOD)下必须是 static 方法
@BeforeAll
static void initAll() {
System.out.println("初始化测试环境");
}
// 在每个测试方法执行前执行
@BeforeEach
void init() {
System.out.println("每个测试前的准备");
}
@Test
@DisplayName("两数相加")
void testAdd() {
int result = 1 + 2;
Assertions.assertEquals(3, result);
}
// 在每个测试方法执行后执行
@AfterEach
void tearDown() {
System.out.println("每个测试后的清理");
}
// 在当前测试类所有测试方法执行后执行一次
// 默认生命周期(PER_METHOD)下必须是 static 方法
@AfterAll
static void tearDownAll() {
System.out.println("清理测试环境");
}
}
JUnit5 默认为每个测试方法创建一个新的测试类实例(PER_METHOD),因此 @BeforeAll / @AfterAll 必须是 static 方法。如果在类上加 @TestInstance(TestInstance.Lifecycle.PER_CLASS),整个测试类只创建一个实例,此时这两个注解就不需要 static 了:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class SomeTest {
// PER_CLASS 下不需要 static
@BeforeAll
void initAll() { ... }
@AfterAll
void tearDownAll() { ... }
}
PER_CLASS 在 @Nested 内部类中也很常用——因为内部类不能有 static 方法,如果内部类需要 @BeforeAll,就必须配合 @TestInstance(Lifecycle.PER_CLASS) 使用。
常用注解
@Test:标记一个测试方法。@DisplayName:为测试类或方法设置展示名称,支持中文和空格,在报告和 IDE 里更易读。@Disabled:禁用测试类或方法,可附带原因说明。@Tag:为测试打标签,可在构建时按标签过滤运行。@Nested:标记嵌套测试类,用于组织有层次的测试结构。@RepeatedTest:将测试方法重复执行指定次数。@ParameterizedTest:参数化测试,配合数据源注解多次运行同一逻辑。@Timeout:设置测试超时时间,超时则视为失败。@TempDir:注入一个临时目录,测试结束后自动清理。
禁用测试
@Disabled("功能尚未实现,暂时跳过")
@Test
void testNotImplemented() {
// ...
}
嵌套测试
嵌套测试非常适合描述 Given/When/Then 这类场景:
@DisplayName("订单服务测试")
class OrderServiceTest {
@Nested
@DisplayName("当商品库存充足时")
class WhenStockSufficient {
@Test
@DisplayName("下单成功")
void shouldCreateOrderSuccessfully() {
// ...
}
}
@Nested
@DisplayName("当商品库存不足时")
class WhenStockInsufficient {
@Test
@DisplayName("下单失败并抛出异常")
void shouldThrowException() {
// ...
}
}
}
参数化测试
参数化测试是 JUnit5 的亮点之一,可以用一组不同的输入数据重复执行同一个测试逻辑。
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testIsPositive(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@ValueSource(strings = {"hello", "world", "java"})
void testIsNotBlank(String str) {
assertFalse(str.isBlank());
}
使用 @CsvSource 可以提供多个参数:
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30"
})
void testAdd(int a, int b, int expected) {
assertEquals(expected, a + b);
}
使用 @MethodSource 可以引用一个静态方法提供参数,适合复杂对象:
@ParameterizedTest
@MethodSource("provideUsers")
void testUserIsValid(User user, boolean expected) {
assertEquals(expected, userValidator.isValid(user));
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("alice", "[email protected]"), true),
Arguments.of(new User("", "invalid"), false)
);
}
断言
JUnit5 内置断言
JUnit5 的断言都在 org.junit.jupiter.api.Assertions 类中,推荐使用静态导入:
import static org.junit.jupiter.api.Assertions.*;
基础断言:
// 相等断言
assertEquals(expected, actual);
assertEquals(expected, actual, "失败时的消息");
// 不相等
assertNotEquals(unexpected, actual);
// 为 null / 不为 null
assertNull(object);
assertNotNull(object);
// 布尔断言
assertTrue(condition);
assertFalse(condition);
// 数组相等(深度比较)
assertArrayEquals(expectedArray, actualArray);
// 同一个对象引用
assertSame(expected, actual);
assertNotSame(unexpected, actual);
异常断言:
// 断言抛出指定异常,并可以对异常对象进一步断言
Exception exception = assertThrows(IllegalArgumentException.class, () -> {
userService.createUser(null);
});
assertEquals("用户名不能为空", exception.getMessage());
// 断言不抛出任何异常
assertDoesNotThrow(() -> {
userService.createUser(validUser);
});
组合断言:
assertAll 会执行所有断言,并在最后统一报告所有失败,而不是遇到第一个失败就停止:
assertAll("用户信息",
() -> assertEquals("alice", user.getName()),
() -> assertEquals("[email protected]", user.getEmail()),
() -> assertTrue(user.isActive())
);
超时断言:
// 断言在指定时间内完成
assertTimeout(Duration.ofMillis(100), () -> {
// 被测代码
Thread.sleep(50);
});
AssertJ:更流畅的断言库
JUnit5 内置断言功能有限,实际项目中更推荐使用 AssertJ,它提供链式调用、更丰富的匹配器和更清晰的错误消息。
Spring Boot 的 spring-boot-starter-test 已包含 AssertJ,无需额外引入。
<!-- 如果不用 Spring Boot,单独引入 -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
import static org.assertj.core.api.Assertions.*;
// 字符串断言
assertThat("Hello World")
.isNotNull()
.startsWith("Hello")
.endsWith("World")
.contains("lo W")
.hasSize(11);
// 数字断言
assertThat(42)
.isGreaterThan(10)
.isLessThan(100)
.isBetween(40, 50);
// 集合断言
List<String> names = List.of("Alice", "Bob", "Charlie");
assertThat(names)
.hasSize(3)
.contains("Alice", "Bob")
.doesNotContain("Dave")
.allMatch(name -> name.length() > 2);
// 对象断言
assertThat(user)
.isNotNull()
.extracting(User::getName, User::getEmail)
.containsExactly("alice", "[email protected]");
// 异常断言
assertThatThrownBy(() -> userService.findById(-1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ID 不合法");
// 或者使用这种风格
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> userService.findById(-1))
.withMessageContaining("ID 不合法");
Mockito:Mock、Stub、Verify
单元测试的核心原则是隔离被测单元。被测代码往往依赖数据库、外部服务、邮件系统等,这些在测试中需要用”替代品”替换掉。Mockito 的使用围绕三个步骤展开:
Mock → Stub → Verify
创建替代品 → 控制它的行为 → 验证它被正确调用
第一步:Mock(创建替代品)
mock() 创建一个假对象,它实现了接口或继承了类,但所有方法默认什么都不做(返回 null / 0 / false):
import static org.mockito.Mockito.*;
UserRepository userRepository = mock(UserRepository.class);
EmailService emailService = mock(EmailService.class);
使用注解更简洁,配合 @ExtendWith(MockitoExtension.class) 使用:
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks // 自动将上面的 mock 注入到 UserService
private UserService userService;
}
第二步:Stub(控制返回值)
Mock 对象默认返回空值,通常需要给它”打桩”,让它在特定调用时返回你想要的数据:
// 返回指定值
when(userRepository.findById(1L))
.thenReturn(Optional.of(new User(1L, "alice")));
// 抛出异常
when(userRepository.findById(-1L))
.thenThrow(new IllegalArgumentException("ID 不合法"));
// 连续调用返回不同值
when(userRepository.count())
.thenReturn(0L)
.thenReturn(1L);
参数匹配器可以让 stub 更灵活:
// any() 匹配任意参数
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// anyLong() / anyString() 等同理
when(userRepository.findByName(anyString())).thenReturn(Optional.empty());
第三步:Verify(验证调用行为)
verify() 用来断言 mock 方法是否被调用、调用了几次、传入了什么参数:
// 默认验证调用了一次
verify(emailService).sendWelcomeEmail("[email protected]");
// 验证调用次数
verify(userRepository, times(2)).save(any(User.class));
verify(userRepository, never()).deleteById(anyLong());
verify(userRepository, atLeastOnce()).findById(anyLong());
完整示例
三步串联在一起,是一个典型单元测试的骨架:
@Test
void testCreateUser() {
// Stub:让 repository 的 save 返回保存后的对象
User user = new User("alice", "[email protected]");
when(userRepository.save(any(User.class))).thenReturn(user);
// 执行被测方法
userService.createUser("alice", "[email protected]");
// Verify:确认 repository 和 emailService 都被正确调用了
verify(userRepository).save(any(User.class));
verify(emailService).sendWelcomeEmail("[email protected]");
}
ArgumentCaptor:捕获参数做细节断言
有时候光验证”方法被调用了”还不够,还需要检查传入的参数内容:
@Captor
ArgumentCaptor<User> userCaptor;
@Test
void testCreateUser() {
userService.createUser("alice", "[email protected]");
verify(userRepository).save(userCaptor.capture());
User savedUser = userCaptor.getValue();
assertThat(savedUser.getName()).isEqualTo("alice");
assertThat(savedUser.getCreatedAt()).isNotNull();
}
Spy:包装真实对象
除了完全替换的 Mock,还有一种 Spy,它是对真实对象的包装。未被 stub 的方法仍然调用真实实现,只有你明确 stub 过的方法才会被替换。
两种创建方式:
// 方式一:注解(需要 @ExtendWith(MockitoExtension.class))
@Spy
private UserValidator userValidator = new UserValidator();
// 方式二:静态方法
UserValidator userValidator = spy(new UserValidator());
Spy 的 stub 必须用 doReturn() 而不是 when().thenReturn():
// ✅ 正确:doReturn 不会触发真实方法
doReturn(true).when(userValidator).isEmailValid(anyString());
// ❌ 危险:when(...) 括号内会先调用真实方法,可能抛异常或产生副作用
when(userValidator.isEmailValid(anyString())).thenReturn(true);
原因是 when(spy.method()) 这行代码在执行时,spy.method() 已经被真实调用了一次。如果这个方法有副作用(比如发网络请求、操作数据库),就会出问题。doReturn().when() 则是先注册 stub 规则,完全不触发真实方法。
Spy 适合”只想替换某一个方法、其余保持真实”的场景,在测试遗留代码时尤其有用。
JaCoCo 测试覆盖率
JaCoCo(Java Code Coverage)是 Java 最主流的代码覆盖率工具,它通过字节码插桩的方式,统计测试执行期间哪些代码被覆盖到了。
在 Maven 中集成 JaCoCo
JaCoCo 的工作原理是:在测试运行时通过 Java Agent 收集覆盖率数据,写入一个二进制文件(默认是 target/jacoco.exec),再基于这份数据文件生成报告。默认只收集 test 阶段(Surefire)的数据,也就是我们通常所说的”测试覆盖率”。
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
运行 mvn test 后,HTML 报告生成在 target/site/jacoco/index.html。
配置覆盖率阈值
可以配置最低覆盖率要求,低于阈值时构建失败:
<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% -->
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
排除不需要统计覆盖率的类
<configuration>
<excludes>
<!-- 排除生成的代码、配置类、DO 类等 -->
<exclude>**/generated/**</exclude>
<exclude>**/*Config.class</exclude>
<exclude>**/*DO.class</exclude>
<exclude>com/example/Application.class</exclude>
</excludes>
</configuration>
JaCoCo 覆盖率指标说明
JaCoCo 提供多个维度的覆盖率统计:
- LINE(行覆盖率):源代码行是否被执行,最直观。
- BRANCH(分支覆盖率):
if/switch等条件的每个分支是否都被覆盖,能发现遗漏的边界条件。 - METHOD(方法覆盖率):方法是否被调用过。
- CLASS(类覆盖率):类是否被实例化或调用过。
- INSTRUCTION(指令覆盖率):JVM 字节码指令粒度的覆盖,是最精确的底层指标。
- COMPLEXITY(复杂度覆盖率):基于圈复杂度计算,反映控制流路径的覆盖情况。
实践中,通常以行覆盖率和分支覆盖率作为主要指标。
如果项目同时跑了 IT(由 Failsafe 管理),JaCoCo 默认的报告不会包含这部分覆盖数据。需要额外配置:用 prepare-agent-integration goal 在 IT 阶段也挂载 Agent、收集独立的 .exec 数据文件,再通过 merge goal 将 UT 和 IT 两份数据合并,最后生成统一报告。原理与 UT 完全一致,只是多了一次数据收集和一次合并步骤。
Spring Test
在没有 Spring Test 的年代,想测试一个 Spring Bean 需要自己写代码手动创建 ApplicationContext、手动装配依赖。而且每个测试类都这样做一遍,启动成本极高。Spring Test 就是为了解决这个问题而诞生的。
Spring Test 做了什么
1. 统一管理 ApplicationContext 的生命周期
Spring Test 的核心是 TestContext Framework,它在测试框架(JUnit5)和 Spring 容器之间充当桥梁。它负责:
- 解析测试类上的注解(
@SpringBootTest、@ContextConfiguration等) - 根据注解创建对应的 ApplicationContext
- 在测试类中完成依赖注入(
@Autowired)
2. ApplicationContext 缓存复用
Spring Test 最重要的优化是上下文缓存。Spring 容器启动一次至少需要几秒钟,如果每个测试类都重新创建一次 ApplicationContext,几百个测试类跑下来时间不可接受。
Spring Test 会根据测试的配置(使用的配置类、激活的 Profile、属性等)生成一个缓存 key,相同 key 的测试类共享同一个 ApplicationContext 实例,整个测试套件只启动一次容器。
// 这两个测试类配置相同,会共享同一个 ApplicationContext
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest { ... }
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest { ... }
一旦修改了上下文配置(比如加了 @MockBean、换了不同的 Profile),就会创建新的上下文,缓存失效。所以要尽量保持测试配置统一,减少上下文数量。
3. 测试中的事务管理
在测试类或方法上加 @Transactional,Spring Test 会在测试结束后自动回滚事务,避免测试数据污染。这是生产代码中 @Transactional 默认提交的行为,在测试中被特意改成了回滚。
4. Test Slice(测试切片)
Spring Boot 在 Spring Test 的基础上提供了一系列 Test Slice 注解,每个 Slice 只加载 ApplicationContext 的一个子集,避免加载整个完整上下文,让测试更快更聚焦:
@WebMvcTest:只加载 Controller、Filter、MVC 配置,不加载 Service/Repository,适合专门测试 Controller 层。@DataJpaTest:只加载 JPA Repository 和 EntityManager,不加载 Controller/Service,适合测试数据访问层。@DataRedisTest:只加载 Redis 相关组件。@JsonTest:只加载 JSON 序列化/反序列化相关组件,适合测试 Jackson 配置是否正确。
为什么需要 Spring Test
对于 Spring 应用,很多逻辑不是孤立的,而是建立在 Spring 的能力之上:
- Controller 层依赖 Spring MVC 的请求映射、参数绑定、消息转换
- Service 层的事务由 Spring AOP 代理处理
- Repository 层的 SQL 由 Spring Data JPA 生成
- 配置注入依赖
@Value、@ConfigurationProperties
这些行为在纯单元测试中根本无法测到,必须有 Spring 容器的参与才能验证。Spring Test 让你在有容器的环境中写测试,同时又提供了足够多的工具来保持测试的速度和隔离性。
@SpringBootTest:集成测试
@SpringBootTest 会启动完整的 Spring ApplicationContext,适合做集成测试:
@SpringBootTest
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Test
void testCreateAndFindUser() {
User user = userService.create("alice", "[email protected]");
assertThat(user.getId()).isNotNull();
User found = userService.findById(user.getId());
assertThat(found.getName()).isEqualTo("alice");
}
}
@SpringBootTest 默认使用 MOCK 环境(创建 MockServletContext,不占用真实端口),可以通过 webEnvironment 参数控制:
// 默认值:创建 MockServletContext,不占用真实端口,配合 MockMvc 使用
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
// 使用随机端口启动真实的 Web 服务器,适合端到端 HTTP 测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// 彻底不创建 Web 环境,适合纯 Service 层的集成测试
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
使用 RANDOM_PORT 时,Spring 会自动注入 TestRestTemplate,它已绑定了随机端口,可以直接发起真实 HTTP 请求:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testGetUser() {
ResponseEntity<User> response = restTemplate.getForEntity("/api/users/1", User.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().getName()).isEqualTo("alice");
}
}
WebFlux 项目对应的是 WebTestClient,用法类似但支持响应式链式调用。
@MockBean 和 @SpyBean(以及 Spring Boot 3.4+ 的新注解)
在 Spring 测试中,可以用 @MockBean 替换 Spring 容器中的 Bean 为 Mockito Mock 对象,用 @SpyBean 对真实 Bean 做 Spy 包装(未被 stub 的方法仍然调用真实实现)。
Spring Boot < 3.4(仍在维护的主流版本),使用 spring-test 模块提供的注解:
@SpringBootTest
class UserServiceTest {
@Autowired
private UserService userService;
@MockBean
private EmailService emailService; // 替换容器中真实的 EmailService
@Test
void testCreateUser() {
userService.createUser("alice", "[email protected]");
verify(emailService).sendWelcomeEmail("[email protected]");
}
}
Spring Boot 3.4+ 引入了新注解:@MockitoBean 和 @MockitoSpyBean,分别替代 @MockBean 和 @SpyBean,旧注解已标注 @Deprecated。新注解来自 spring-test 模块,包名从 org.springframework.boot.test.mock.mockito 变更为 org.springframework.test.context.bean.override.mockito:
// Spring Boot 3.4+ 推荐写法
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
class UserServiceTest {
@MockitoBean
private EmailService emailService;
}
功能和语义与旧注解完全一致,迁移成本极低,只需替换注解名和 import。如果你的项目运行在 Spring Boot 3.4+,直接用新注解;3.4 以下继续用 @MockBean / @SpyBean 即可,两套注解不能混用。
注意:无论新旧注解,使用它们都会导致上下文缓存失效,每次都会重新创建 ApplicationContext,对测试速度有影响,建议谨慎使用。
MockMvc:测试 Web 层
MockMvc 可以在不启动真实 HTTP 服务器的情况下,测试 Controller 层:
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void testGetUser() throws Exception {
mockMvc.perform(get("/api/users/1")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("alice"))
.andExpect(jsonPath("$.email").value("[email protected]"));
}
@Test
void testCreateUser() throws Exception {
CreateUserRequest request = new CreateUserRequest("alice", "[email protected]");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty());
}
}
如果只想测试 Controller 层而不加载完整上下文,可以使用 @WebMvcTest:
@WebMvcTest(UserController.class) // 只加载 UserController 相关的组件
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Spring Boot 3.4+ 改用 @MockitoBean
private UserService userService; // 手动提供 Controller 的依赖
}
@DataJpaTest:测试数据访问层
专门用于测试 JPA Repository,只加载与 JPA 相关的组件,默认使用内嵌数据库(H2)并在每个测试后回滚事务:
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void testFindByEmail() {
userRepository.save(new User("alice", "[email protected]"));
Optional<User> found = userRepository.findByEmail("[email protected]");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("alice");
}
}
如果需要使用真实数据库而非 H2,可以添加:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest {
// 使用 application-test.properties 中配置的真实数据库
}
测试配置隔离
通常会为测试环境单独配置一份 application-test.properties 或 application-test.yml:
# src/test/resources/application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
通过 @ActiveProfiles 激活测试 profile:
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest {
// ...
}
@Transactional 在测试中的应用
在测试类或测试方法上加 @Transactional,测试结束后会自动回滚数据库操作,保证测试之间相互隔离:
@SpringBootTest
@Transactional
class UserServiceTest {
@Autowired
private UserService userService;
@Test
void testCreateUser() {
// 这里的数据库操作在测试结束后会自动回滚
userService.create("alice", "[email protected]");
assertThat(userService.count()).isEqualTo(1);
}
}
单元测试 vs 集成测试的选择
单元测试速度极快(毫秒级),依赖全部 Mock,完全隔离,适合测试业务逻辑、算法、工具类,代表注解是 @ExtendWith(MockitoExtension.class)。
集成测试需要启动 Spring 上下文,速度慢(秒级),使用真实依赖,更接近生产环境,适合验证 Controller、Repository、Service 之间的协作,代表注解是 @SpringBootTest、@DataJpaTest、@WebMvcTest。
实践建议:优先编写单元测试,对于需要验证组件间协作的场景再编写集成测试。对 Service 层要尽量做单元测试(Mock 掉 Repository),集成测试主要用于 Controller 和 Repository 层的验证。
(全文完)
0 条评论