Spring Boot项目JUnit单元测试实战:从Mockito到集成测试
1. 项目概述:为什么我们需要认真对待单元测试
在任何一个有一定规模的Spring Boot项目中,如果你问一个开发者最想逃避但又不得不面对的工作是什么,单元测试的编写与维护大概率会榜上有名。我们常常埋头于业务逻辑的CRUD,享受着Spring Boot带来的“开箱即用”的便利,却容易在测试环节“偷工减料”。然而,随着项目迭代、团队人员变动,缺乏高质量单元测试的代码库,其维护成本会呈指数级增长。一个看似简单的修改,可能会引发一连串意想不到的Bug,而定位这些问题所耗费的时间,远超当初编写测试的时间。
“Spring Boot项目中JUnit使用总结”这个标题,背后折射出的正是这种普遍的痛点与需求。它不是一个简单的API罗列,而是希望系统性地梳理:在一个真实的、集成了Spring生态的微服务项目中,如何高效、正确、可持续地使用JUnit进行单元测试。这涉及到从基础的注解使用,到复杂的依赖隔离(Mock),再到与Spring TestContext框架的集成,以及如何构建可维护的测试代码结构。我经历过从“测试即负担”到“测试即保障”的思维转变,也踩过无数因测试不当而引发的坑。这篇文章,就是将这些经验沉淀下来,希望能帮你构建起坚固的代码质量防线,让测试不再是负担,而是你开发过程中最可靠的伙伴。
2. 测试框架选型与核心依赖解析
在Spring Boot的语境下谈JUnit,我们实际上是在谈论一个以JUnit为核心,整合了Spring Test、Mockito、AssertJ等工具的测试生态。正确的起步,从理解并配置好这些依赖开始。
2.1 核心依赖的“三驾马车”
Spring Boot通过 spring-boot-starter-test 这个Starter,为我们一站式集成了测试所需的大部分库。当你创建一个新的Spring Boot项目时,它通常已经被包含在 pom.xml 或 build.gradle 中。拆开来看,它主要包含了以下核心组件:
- JUnit Jupiter : 这是JUnit 5的核心测试框架。与JUnit 4相比,JUnit 5进行了模块化重构,扩展性更强,注解也更现代化(例如用
@Test替代了@org.junit.Test)。它是我们编写测试用例的基石。 - Spring Test & Spring Boot Test : 这是Spring框架的测试模块。它提供了强大的
SpringExtension(JUnit 5)或SpringJUnit4ClassRunner(JUnit 4),用于加载Spring的ApplicationContext,使我们能在测试中注入Spring管理的Bean(如@Service,@Component)。@SpringBootTest注解就是它的核心,用于启动一个完整的或接近完整的Spring应用上下文。 - Mockito : 目前最流行的Java Mock框架。在单元测试中,我们遵循“隔离”原则,即只测试当前类(单元)的逻辑,其依赖的其他组件(如数据库访问层、外部服务客户端)应该被“模拟”(Mock)。Mockito可以轻松创建和配置这些模拟对象,并验证与它们的交互。
- AssertJ : 一个流式断言库。它提供了比JUnit原生断言更丰富、更易读的断言方式。例如,
assertThat(actual).isEqualTo(expected)的链式调用,让断言语句像自然语言一样流畅,大大提升了测试代码的可读性。 - Hamcrest : 另一个匹配器库,提供了更灵活的匹配规则,常与AssertJ结合或替代使用。
- JSONassert & JsonPath : 专门用于对JSON数据进行断言和处理的库,在测试REST API时非常有用。
注意 :虽然Starter提供了全套工具,但在实际项目中,我们有时需要根据团队规范进行微调。例如,如果团队统一使用AssertJ,可以排除Hamcrest以减少依赖冲突的可能性。
2.2 版本管理与兼容性陷阱
Spring Boot的版本与其集成的测试库版本是强绑定的。例如,Spring Boot 2.7.x默认集成JUnit 5.8.x,而Spring Boot 3.0+则要求JUnit 5.9+。这是一个需要特别注意的“暗坑”。
常见问题 :当你尝试在Spring Boot 2.4的项目中手动升级JUnit到5.9时,可能会遇到 SpringExtension 与JUnit Jupiter API不兼容的问题,导致测试无法运行。同样,Mockito的版本也可能与Spring Boot Test的版本有隐含的依赖关系。
实操心得 : 永远优先使用 spring-boot-starter-test 定义的版本,不要轻易单独升级其中某个子依赖的版本。 如果确有需要(例如需要使用JUnit 5.9的新特性),应该整体升级Spring Boot的版本,或者使用 <properties> 标签在父POM中统一覆盖版本号,并做好全面的兼容性测试。在Gradle中,可以使用 resolutionStrategy 来强制指定版本,但需谨慎。
3. 测试类型划分与对应策略
在Spring Boot项目中,根据测试的粒度和是否需要启动Spring容器,我们可以将测试分为几个层次。选择正确的测试类型,是编写高效测试的第一步。
3.1 纯单元测试(Unit Test)
这是最“纯粹”的单元测试, 不启动Spring容器 。它的目标是测试单个类(通常是 @Service 或工具类)的内部逻辑,所有外部依赖全部通过Mockito进行模拟。
适用场景 :业务逻辑复杂、计算密集的Service类;工具类(如日期处理、字符串格式化);算法实现等。
代码示例与解析 : 假设我们有一个 UserService ,它依赖 UserRepository 来访问数据库。
// UserService.java
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User createUser(String username, String rawPassword) {
if (userRepository.findByUsername(username).isPresent()) {
throw new IllegalArgumentException("用户名已存在");
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(rawPassword));
return userRepository.save(user);
}
}
对应的纯单元测试如下:
// UserServiceTest.java - 纯单元测试
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
// 关键:使用MockitoExtension,而不是SpringExtension。不加载Spring容器。
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock // 模拟依赖项
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks // 将上述Mock注入到被测试实例中
private UserService userService;
@Test
void createUser_shouldSuccess_whenUsernameIsUnique() {
// 1. 准备阶段 (Given)
String username = "testUser";
String rawPassword = "123456";
String encodedPassword = "encoded_123456";
User savedUser = new User();
savedUser.setId(1L);
savedUser.setUsername(username);
savedUser.setPassword(encodedPassword);
// 配置Mock行为:当调用userRepository.findByUsername时,返回空的Optional
when(userRepository.findByUsername(username)).thenReturn(Optional.empty());
// 配置Mock行为:当调用passwordEncoder.encode时,返回编码后的密码
when(passwordEncoder.encode(rawPassword)).thenReturn(encodedPassword);
// 配置Mock行为:当调用userRepository.save时,返回我们预设的savedUser
when(userRepository.save(any(User.class))).thenReturn(savedUser);
// 2. 执行阶段 (When)
User result = userService.createUser(username, rawPassword);
// 3. 断言阶段 (Then)
// 验证结果正确性
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getUsername()).isEqualTo(username);
assertThat(result.getPassword()).isEqualTo(encodedPassword);
// 验证Mock的交互符合预期:save方法被调用了一次
verify(userRepository, times(1)).save(any(User.class));
// 验证encode方法被调用了一次,且参数是rawPassword
verify(passwordEncoder, times(1)).encode(rawPassword);
}
@Test
void createUser_shouldThrowException_whenUsernameExists() {
// Given
String existingUsername = "existingUser";
User existingUser = new User();
when(userRepository.findByUsername(existingUsername)).thenReturn(Optional.of(existingUser));
// When & Then
// 使用AssertJ的异常断言,验证执行方法时抛出了预期的异常
assertThatThrownBy(() -> userService.createUser(existingUsername, "anyPassword"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("用户名已存在");
// 验证在抛出异常后,save方法没有被调用
verify(userRepository, never()).save(any());
}
}
注意事项 :
@ExtendWith(MockitoExtension.class)是JUnit 5的写法,它初始化了Mockito的Mock和Spy。在JUnit 4中,对应的是@RunWith(MockitoJUnitRunner.class)。@InjectMocks会尝试通过构造函数、setter或字段注入的方式,将标记了@Mock的依赖注入到被测试对象中。这要求被测试类(如UserService)的依赖最好是通过构造函数注入(推荐),这样Mockito可以毫无障碍地完成注入。- 测试结构遵循 Given-When-Then 模式,清晰明了。
- 使用
verify来验证模拟对象的交互行为,这是单元测试中验证“副作用”的关键手段。
3.2 集成测试(Integration Test)
集成测试 需要启动Spring容器 ,但可能不是完整的应用上下文。它用于测试多个组件之间的协作,例如Service与Repository(连接真实数据库),或者Controller与Service。
适用场景 :数据库交互逻辑(使用内嵌数据库如H2);缓存集成;消息队列监听器;需要测试 @Transactional 回滚行为等。
核心注解 : @SpringBootTest 。这个注解会启动一个Spring应用上下文。你可以通过其属性进行精细控制:
webEnvironment: 定义Web环境。WebEnvironment.MOCK(默认):加载一个WebApplicationContext并提供模拟的Servlet环境,不启动真实服务器。适用于测试MVC层,配合@AutoConfigureMockMvc。WebEnvironment.RANDOM_PORT:启动一个真实的嵌入式服务器(如Tomcat)并监听一个随机端口。适用于测试完整的HTTP API,包括过滤器、拦截器等。WebEnvironment.NONE:不提供任何Web环境,只加载一个标准的ApplicationContext。适用于非Web的集成测试。
classes: 指定要加载的配置类。通常不需要指定,Spring Boot会自动搜索主配置类。
代码示例与解析 :测试 UserRepository 与内嵌H2数据库的集成。
首先,需要在 src/test/resources/application-test.yml 中配置测试专用的数据源(通常使用H2内存数据库):
# application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop # 测试时创建表,测试后删除
show-sql: true # 显示SQL,便于调试
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
然后编写集成测试:
// UserRepositoryIntegrationTest.java - 集成测试
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import javax.transaction.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
// 关键注解1:@DataJpaTest 是 @SpringBootTest 的一个“切片测试”(Slice Test)变体。
// 它只加载与JPA相关的配置(如DataSource, EntityManager, Repository),大大加快了测试启动速度。
// 它会自动配置一个内嵌数据库(如果存在依赖,如H2)并扫描@Entity和@Repository。
@DataJpaTest
// 关键注解2:激活`test`配置文件,使用上面的`application-test.yml`配置
@ActiveProfiles("test")
class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
@Transactional // 确保测试方法在事务中执行,测试完成后自动回滚,保持数据库干净
void shouldSaveAndFindUser() {
// Given
User user = new User();
user.setUsername("integrationUser");
user.setPassword("encryptedPass");
// When
User savedUser = userRepository.save(user);
Optional<User> foundUser = userRepository.findById(savedUser.getId());
// Then
assertThat(foundUser).isPresent();
assertThat(foundUser.get().getUsername()).isEqualTo("integrationUser");
// 由于事务回滚,这条数据不会真正持久化到数据库中影响其他测试
}
}
实操心得 :
- 善用“切片测试”注解 :Spring Boot Test提供了一系列像
@DataJpaTest,@WebMvcTest,@JsonTest,@RestClientTest这样的注解。它们只加载应用程序的特定“切片”,而不是整个上下文,这使得测试启动速度极快,且关注点更明确。例如,测试Controller就只用@WebMvcTest,它会自动配置MockMvc,而不会加载Service和Repository的Bean。 -
@Transactional的回滚机制 :在集成测试中,为测试方法加上@Transactional注解是一个最佳实践。它会让测试方法在一个事务中执行,并在方法结束后自动回滚,确保每个测试都是独立的,不会污染数据库。 但要注意 :有些测试你可能就是想测试提交后的状态,或者测试本身涉及多个事务,这时就不能加这个注解,需要手动清理数据(可以用@Sql脚本或JdbcTestUtils)。 - 测试数据准备 :对于复杂的数据准备,可以使用
@Sql注解执行SQL脚本,或者使用像Testcontainers这样的库来启动一个真实的、隔离的数据库容器进行测试,这更接近生产环境。
3.3 端到端测试(End-to-End Test)
这类测试启动 完整的Spring Boot应用 (包括内嵌服务器),并通过HTTP客户端(如 TestRestTemplate 或 WebTestClient )模拟真实用户调用整个API链路。
适用场景 :验证关键业务API流程;测试安全过滤器链、全局异常处理等跨切面功能。
代码示例与解析 :
// UserControllerE2ETest.java - 端到端测试
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
// 关键:使用RANDOM_PORT启动真实服务器
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerE2ETest {
@Autowired
private TestRestTemplate restTemplate; // 自动配置的HTTP客户端
@Test
void shouldCreateUserViaApi() {
// Given
Map<String, String> request = new HashMap<>();
request.put("username", "e2eUser");
request.put("password", "e2ePassword");
// When
// 发送POST请求到 /api/users
ResponseEntity<Void> response = restTemplate.postForEntity("/api/users", request, Void.class);
// Then
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getHeaders().getLocation()).isNotNull();
// 可以进一步根据Location头去GET新创建的用户,验证数据一致性
}
}
注意事项 :
- 速度慢 :这是最重量级的测试,启动整个应用耗时较长。 切忌 在每次构建时运行全部端到端测试,通常只在CI/CD流水线的特定阶段(如合并前或发布前)运行。
- 环境依赖 :可能需要连接外部服务(如数据库、Redis)。在CI环境中,需要使用Docker或Testcontainers来提供这些依赖。
- 测试独立性 :确保测试之间没有状态依赖,每个测试要清理自己产生的数据。可以使用
@DirtiesContext注解在测试后重置Spring上下文,但这会进一步降低测试速度。
4. Mockito在Spring Boot测试中的高级用法与陷阱
Mockito是隔离测试对象依赖的利器,但用好它需要一些技巧,否则很容易写出脆弱或无效的测试。
4.1 行为验证(Verification)的精确与宽松
verify 方法用于验证模拟对象是否以预期的参数被调用了预期的次数。但过度验证会导致测试脆弱。
// 过度验证 - 不推荐
verify(userRepository).findByUsername("testUser");
verify(userRepository).save(any(User.class));
verifyNoMoreInteractions(userRepository); // 断言之后没有其他交互
// 适度验证 - 推荐
// 只验证关键的业务交互,比如“保存用户”这个核心操作必须发生
verify(userRepository).save(any(User.class));
// 对于查询操作,如果它不是测试的核心断言点,有时可以省略显式验证,因为它的行为已经在Given阶段通过`when(...).thenReturn(...)`设定了。
verifyNoMoreInteractions 是一个强有力的断言,但它会让测试对实现细节过于敏感。如果未来Service内部增加了一个无关紧要的日志查询,测试就会失败。 仅在需要严格验证“除了这些调用外,没有其他任何交互”的场景下使用它 ,例如测试一个“只读”方法不应该有任何“写”操作。
4.2 参数匹配器(Argument Matchers)的灵活运用
Mockito提供了丰富的参数匹配器,使得验证更加灵活。
// 使用 any() 匹配任何参数
when(userRepository.save(any(User.class))).thenReturn(mockedUser);
// 使用 eq() 匹配特定值
when(userRepository.findByUsername(eq("alice"))).thenReturn(Optional.of(mockedUser));
// 使用自定义匹配器
verify(userRepository).save(argThat(user -> user.getUsername().startsWith("test_")));
// 注意陷阱:混合使用匹配器和具体值时,所有参数都必须使用匹配器
// 错误示例:save(any(), “concreteValue”) -> 编译错误或运行时异常
// 正确示例:save(any(), eq(“concreteValue”))
4.3 模拟void方法与异常抛出
模拟没有返回值的方法或模拟方法抛出异常。
// 模拟一个void方法,当被调用时什么都不做(默认行为)
doNothing().when(notificationService).sendWelcomeEmail(anyString());
// 模拟一个void方法,当被调用时抛出异常
doThrow(new RuntimeException("网络错误")).when(notificationService).sendWelcomeEmail("problemUser");
// 模拟一个有返回值的方法抛出异常
when(userRepository.findById(999L)).thenThrow(new EntityNotFoundException("用户不存在"));
// 在测试中验证异常
assertThatThrownBy(() -> userService.getUserById(999L))
.isInstanceOf(EntityNotFoundException.class);
4.4 Spy与Partial Mock:监视真实对象
有时你不想完全模拟一个对象,而是想大部分调用走真实逻辑,只模拟或验证其中的一部分。这时可以用 @Spy 。
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Spy // 创建一个真实对象的“间谍”,默认调用真实方法
private EmailTemplateEngine emailTemplateEngine;
@InjectMocks
private OrderService orderService;
@Test
void shouldUseCorrectTemplate() {
// 模拟其中一个方法
doReturn("CUSTOM_HTML").when(emailTemplateEngine).generateHtmlTemplate(any());
orderService.confirmOrder(123L);
// 验证这个被模拟的方法被以特定参数调用
verify(emailTemplateEngine).generateHtmlTemplate("ORDER_CONFIRMATION");
// 其他未被模拟的方法,如emailTemplateEngine.getSubject(),会正常执行真实逻辑
}
}
重要提示 :对Spy对象进行“打桩”(stubbing)时,要使用
doReturn().when()的语法,而不是when().thenReturn()。因为when(spy.getSomething()).thenReturn(...)会先调用一次真实的spy.getSomething()方法,可能导致非预期的副作用或异常。
4.5 常见陷阱:忘记配置Mock行为
最常见的错误之一就是没有为Mock对象的方法调用配置行为。对于返回复杂对象、集合或Optional的方法,如果不配置,Mockito会返回其默认值(如 null 、 0 、 false 、空集合等)。这可能导致测试中的 NullPointerException 或者断言失败,但错误信息可能不直观,让你花很长时间去排查业务代码,最后才发现是Mock没配置。
排查技巧 :在测试失败时,首先检查控制台是否有Mockito相关的警告,如“ UnnecessaryStubbingException ”(不必要的打桩)或“ UnfinishedStubbing ”(未完成的打桩)。然后,系统地检查每个被测试方法调用的、属于Mock对象的方法,是否都在 Given 阶段进行了恰当的行为配置。
5. 测试代码的结构、命名与可维护性实践
写出可读、可维护的测试代码,其重要性不亚于生产代码。混乱的测试会成为项目的负担。
5.1 测试类与方法的命名规范
清晰的命名能让人一眼看出测试的意图。
- 测试类名 :通常为
被测试类名 + Test,如UserServiceTest。对于集成测试,可以加上IntegrationTest后缀,如UserRepositoryIntegrationTest。 - 测试方法名 :应该描述 在什么条件下 , 执行什么操作 , 期望什么结果 。推荐使用
should[ExpectedBehavior]When[StateUnderTest]或[MethodUnderTest]_[Scenario]_[ExpectedResult]的格式。- 好的示例:
createUser_shouldReturnSavedUser_whenUsernameIsUnique - 好的示例:
shouldThrowIllegalArgumentExceptionWhenUsernameExists - 避免示例:
testCreateUser(太模糊)
- 好的示例:
5.2 使用@BeforeEach/@AfterEach进行测试准备与清理
JUnit 5的 @BeforeEach 注解用于标记在每个测试方法 之前 运行的方法, @AfterEach 在每个测试方法 之后 运行。这是进行公共设置和清理的理想位置。
class UserServiceTest {
private UserService userService;
@Mock private UserRepository userRepository;
private User testUser;
@BeforeEach
void setUp() {
// 初始化Mock(如果没用@ExtendWith,则需要MockitoAnnotations.openMocks(this))
// 创建被测试对象实例
userService = new UserService(userRepository);
// 创建一些公共的测试数据
testUser = new User();
testUser.setId(1L);
testUser.setUsername("commonUser");
}
@AfterEach
void tearDown() {
// 通常用于清理外部资源,如关闭文件流、删除临时文件。
// 对于纯单元测试,通常不需要。对于集成测试,可能需要清理数据库。
}
@Test
void someTest() {
when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));
// ... 测试逻辑
}
}
注意事项 : @BeforeEach 中的代码应该只包含 所有 测试都需要的东西。如果某个设置只对部分测试需要,考虑在测试方法内部单独设置,或者使用JUnit 5的 @Nested 嵌套类来分组共享设置的测试。
5.3 利用@Nested组织复杂测试
当一个类的功能比较复杂时,对应的测试类也会变得庞大。使用 @Nested 可以将测试按功能或场景分组,使结构更清晰。
class UserServiceTest {
@Nested
class CreateUser {
@Test
void shouldSuccess_whenUsernameIsUnique() { /* ... */ }
@Test
void shouldThrowException_whenUsernameExists() { /* ... */ }
}
@Nested
class UpdateUser {
@BeforeEach
void setUp() {
// 这个Setup只对UpdateUser分组下的测试生效
}
@Test
void shouldUpdateEmail() { /* ... */ }
}
}
在IDE中,这样的结构会以树形视图展示,非常直观。
5.4 测试数据构建的优化:使用Builder或工厂方法
在测试中反复构造复杂的对象很繁琐。可以创建一些辅助方法。
class TestDataFactory {
public static User.UserBuilder aDefaultUser() {
return User.builder()
.id(1L)
.username("defaultUser")
.password("encodedPass")
.email("user@example.com")
.status(Status.ACTIVE);
}
}
// 在测试中使用
User activeUser = TestDataFactory.aDefaultUser().build();
User inactiveUser = TestDataFactory.aDefaultUser().status(Status.INACTIVE).build();
如果项目使用了Lombok,其自带的 @Builder 注解可以很方便地实现这一点。也可以使用像 ObjectMother 或 TestDataBuilder 这样的模式。
6. 与Spring Boot特性集成的测试技巧
Spring Boot的许多特性(如配置属性、Profile、事务管理)在测试中也需要正确处理。
6.1 测试特定配置与Profile
使用 @ActiveProfiles("test") 来激活测试专用的配置(如 application-test.yml )。使用 @TestPropertySource 注解可以直接在测试类上覆盖或添加配置属性,优先级很高。
@SpringBootTest
@ActiveProfiles("test")
@TestPropertySource(properties = {
"app.feature.enabled=true",
"spring.datasource.url=jdbc:h2:mem:alternate_testdb"
})
class FeatureServiceIntegrationTest {
// 这个测试将使用`test` profile,并且上述属性会覆盖配置文件中的值
}
6.2 测试@ConfigurationProperties配置类
Spring Boot鼓励使用类型安全的配置属性。测试这些配置绑定是否正确很简单。
// 生产代码
@ConfigurationProperties(prefix = "app.mail")
@Data // Lombok
public class MailProperties {
private String host;
private int port;
private String from;
}
// 测试代码
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(classes = MailProperties.class) // 只加载这个配置类
@EnableConfigurationProperties(MailProperties.class)
class MailPropertiesTest {
@Autowired
private MailProperties mailProperties;
@Test
void shouldBindProperties() {
assertThat(mailProperties.getHost()).isEqualTo("smtp.example.com");
assertThat(mailProperties.getPort()).isEqualTo(587);
}
}
需要在 src/test/resources/application.yml 中提供对应的测试配置。
6.3 测试事务与回滚
如前所述,在集成测试中, @Transactional 是保证测试独立性的利器。但有时你需要测试事务边界或 @Transactional(propagation = REQUIRES_NEW) 这样的行为。
@SpringBootTest
@Transactional
class TransactionalServiceTest {
@Autowired
private UserService userService;
@Test
void testWithTransaction() {
// 这个操作会在事务中,方法结束后回滚
userService.createUser(...);
// 即使这里去查数据库,用户是存在的,但测试结束后会回滚
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 不在事务中运行
void testWithoutTransaction() {
// 这个操作会立即提交,数据会持久化
// 需要手动清理,或者使用 @DirtiesContext
}
}
重要提醒 : @Transactional 在测试中的回滚,默认是基于事务管理器的回滚。对于使用JPA的情况,这通常工作良好。但如果测试中涉及一些非事务性资源操作(如文件写入、调用外部API),这些操作 不会 被回滚。需要你在 @AfterEach 中手动清理。
7. 测试覆盖率的衡量与Jacoco集成
编写测试很重要,但了解测试覆盖了哪些代码同样重要。Jacoco是Java生态中最流行的代码覆盖率工具,它与Maven/Gradle和Spring Boot集成得很好。
7.1 配置Jacoco Maven插件
在 pom.xml 中配置:
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version> <!-- 使用最新版本 -->
<executions>
<execution>
<goals>
<goal>prepare-agent</goal> <!-- 在测试执行时附加Jacoco代理 -->
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase> <!-- 在verify阶段生成报告 -->
<goals>
<goal>report</goal>
</goals>
</execution>
<!-- 可选:配置覆盖率检查规则 -->
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum> <!-- 要求行覆盖率至少80% -->
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
运行 mvn clean verify 后,Jacoco会在 target/site/jacoco/ 目录下生成HTML格式的覆盖率报告。
7.2 理解覆盖率指标与局限性
Jacoco主要提供以下几种覆盖率指标:
- 行覆盖率(Line Coverage) :有多少行代码被执行了。最直观的指标。
- 分支覆盖率(Branch Coverage) :对于
if/else,switch,&&,||等条件语句,每个分支是否都被测试到。这比行覆盖率要求更高。 - 指令覆盖率(Instruction Coverage) :基于字节码的覆盖率,更底层。
- 圈复杂度(Cyclomatic Complexity) :衡量代码复杂度的指标,可以帮助识别难以测试的代码。
实操心得 :不要盲目追求100%的覆盖率,尤其是行覆盖率。有些代码(如自动生成的getter/setter、简单的POJO、某些异常捕获块)不值得花费大量精力去覆盖。应该更关注 核心业务逻辑 和 复杂条件分支 的覆盖率。将覆盖率作为一个 发现未测试代码的工具 ,而不是一个必须达成的KPI。过高的覆盖率要求可能导致开发者编写大量无意义的“为了覆盖而覆盖”的测试。
7.3 排除无需覆盖的代码
可以通过配置让Jacoco忽略某些类,比如生成的代码、配置类、实体类等。
<configuration>
<excludes>
<exclude>**/model/*.class</exclude> <!-- 排除实体类 -->
<exclude>**/config/*.class</exclude> <!-- 排除配置类 -->
<exclude>**/*DTO.class</exclude>
<exclude>**/*Application.class</exclude> <!-- 排除启动类 -->
</excludes>
</configuration>
也可以在类或方法上使用Lombok的 @Generated 注解,Jacoco默认会忽略带有此注解的代码。
8. 持续集成中的测试策略与优化
在CI/CD流水线中,测试的速度和稳定性直接影响到开发效率。
8.1 测试分层与执行顺序
一个高效的CI测试策略应该是金字塔形的:
- 底层(最多、最快) :大量的纯单元测试(不启动Spring)。这些测试应该在每次代码提交后立即运行,反馈速度在秒级。
- 中层(中等数量、中等速度) :集成测试(使用
@DataJpaTest,@WebMvcTest等切片测试)。它们启动部分Spring上下文,测试组件集成。可以在每日构建或合并请求时运行。 - 顶层(最少、最慢) :端到端测试(
@SpringBootTestwithRANDOM_PORT)。它们启动完整应用,测试完整流程。通常在夜间构建或发布候选版本时运行。
在Maven中,可以利用 maven-surefire-plugin (运行单元测试)和 maven-failsafe-plugin (运行集成测试)进行分离。单元测试命名符合 **/*Test.java ,集成测试命名为 **/*IT.java 或 **/*IntegrationTest.java 。
8.2 并行执行测试
JUnit 5原生支持并行测试执行,可以大幅缩短测试套件的总运行时间。在 src/test/resources/junit-platform.properties 中配置:
# 启用并行执行
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
# 配置线程池大小(根据机器CPU核心数调整)
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
注意事项 :并行测试要求测试之间完全独立,不能共享状态(如静态变量、内存数据库)。对于集成测试,尤其是涉及数据库的,并行化需要更小心,可能需要为每个测试方法或线程配置独立的数据源或数据库实例。
8.3 测试失败分析与日志
确保测试失败时能提供足够的信息进行诊断。
- 使用清晰的断言信息 :AssertJ和JUnit的断言方法都支持自定义失败信息。
assertThat(actualList).hasSize(3) .as("检查用户列表是否包含预置的三个测试用户") .containsExactlyInAnyOrder(user1, user2, user3); - 在CI中捕获并归档日志 :配置Logback或Log4j2,在测试时将日志输出到文件,并在CI任务结束后将其作为构件保存。对于
@SpringBootTest,可以通过@SpringBootTest(properties = "logging.file.name=build/test.log")来指定日志文件。 - 使用
@Disabled和@EnabledIf:对于暂时不通过或只在特定环境运行的测试,使用@Disabled禁用并注明原因。可以使用@EnabledIf条件化地运行测试,例如只在特定操作系统或拥有某个环境变量时运行。
测试是保障Spring Boot应用质量的基石,从简单的 @Test 到复杂的集成场景,从Mockito的巧妙使用到CI流水线的优化,每一个环节都需要用心设计。记住,好的测试应该是 可读的 (像文档一样)、 可维护的 (变更成本低)、 可靠的 (不随机失败)和 快速的 (不拖慢开发流程)。建立起这样的测试文化,你的项目就拥有了应对变化和持续交付的底气。
更多推荐


所有评论(0)