写测试这事,团队里一直有一个奇怪的现象:一说“要写单元测试”,大家第一反应就是给类加上@SpringBootTest注解,启动整个Spring容器,然后调用接口,跑通就算完事。这种测试跑得慢、稳定性差,而且真正出问题的时候它什么都查不出来。我见过很多项目,测试覆盖率报了80%,结果每次发版还是靠人肉回归,原因就在于这些测试全在同一个层面——它们没有真正测到代码的逻辑分支,只是在验证“容器能启动”而已。
SpringBoot Test这套体系,从底层设计上就比“启动整个应用再调接口”要精细得多。它把测试拆成了多层,每一层都有对应的工具和注解,从纯单元级的Mock测试,到加载部分上下文的切片测试,再到贴近生产环境的集成测试,粒度完全不同。这篇文章不打算泛泛罗列注解,我想结合自己实际维护项目和排查测试问题的经验,把SpringBoot Test里真正影响测试质量的关键点讲清楚——从注解机制到分层实战,再到版本升级的坑、事务回滚的失效场景,一次聊透。
1. 为什么SpringBoot Test不只是“@SpringBootTest”一个注解
很多人对SpringBoot Test的理解就停留在“启动上下文”上,这是最大的误区。SpringBoot Test真正的价值,是它提供了一套完整的测试骨架,从最小的单元测试到全链路的集成测试,每一层都有对应的技术方案。
1.1 从一次线上故障说起:测试覆盖的假象
我之前接手过一个老项目,代码里写了大几十个@SpringBootTest测试类,看起来覆盖得挺全。但后来线上出了一个很隐蔽的问题——积分模块在某个用户批次数据下会出现重复发放,根因是Service层里一段并发判断逻辑写错了,当同一用户的两个请求同时到达时,两条线程都通过了“是否已发放”的校验。
这个逻辑用单元测试很容易抓出来:只需要Mock掉持久层接口,分别构造“已发放”和“未发放”两种状态,再模拟两次并发调用就能复现。但当时项目里没有Service层的单元测试,全是启动容器后直接调接口的“伪单元测试”,测试数据被真实地写进了数据库,两个请求一前一后执行,根本无法同步触发竞态条件,bug就这么漏过去了。
这个例子说明了什么?@SpringBootTest加载完整上下文,属于集成测试的范畴,它验证的是组件之间的协作是否正确。但如果你把所有测试都写成这种形式,就牺牲了单元测试的两个核心优势:一是速度快,能频繁运行;二是定位准,哪个方法出错立刻就能指出来。单元测试、切片测试、集成测试,三者用途不同,不该互相替代。
1.2 SpringBoot Test的三层体系与选择逻辑
在SpringBoot Test这套体系里,测试大致可以分成三层:
- 单元测试:不启动Spring容器,直接用JUnit + Mockito测某个类的方法逻辑。Service层的业务规则、工具类的算法逻辑、各种if-else分支,都该在这一层覆盖。
- 切片测试:只加载Web层、数据层或JSON处理层的一部分Bean,用
@WebMvcTest、@DataJpaTest、@JsonTest等注解实现。它比单元测试多了一些真实组件的协作验证,又不需要启动整个上下文。 - 集成测试:用
@SpringBootTest加载完整上下文,叠加Testcontainers或真实中间件,验证跨模块链路。
选型的核心逻辑就一句话:**测试的上下文越大,跑得越慢,定位问题越难。**能用单元测试覆盖的逻辑,不要为了“省事”直接丢给集成测试。我在做项目规范时给团队定的红线是:Service层核心业务规则必须有单元测试兜底,Web层必须有切片测试验证参数绑定和响应结构,跨服务或者跨中间件的链路才允许用集成测试。
2. 核心注解与运行机制拆解
2.1 @SpringBootTest完整上下文:加载了什么,代价是什么
@SpringBootTest的作用是启动完整的Spring应用上下文,让测试直接使用所有的Bean。它背后的机制是SpringBootTestContextBootstrapper,通过SpringApplication创建并缓存一个ApplicationContext,测试类里的@Autowired依赖都能正常注入。
这个注解有几个关键参数值得注意:
webEnvironment:控制Web环境类型。MOCK是默认值,它会创建一个模拟的ServletWebServerApplicationContext,但不启动真实的Web服务器;RANDOM_PORT会启动真实的服务器并分配随机端口,适合需要测试完整HTTP请求的场景;DEFINED_PORT和NONE分别对应指定端口和不启动Web环境。properties:通过properties = {"spring.datasource.url=jdbc:h2:mem:testdb"}临时覆盖配置,作用范围比application-test.yml更精确。classes:指定配置类,默认会从当前测试类所在的包向上查找@SpringBootApplication。
用@SpringBootTest最容易忽略的问题是上下文数量。SpringBoot默认的测试上下文缓存器(SpringBootContextLoader)会缓存上下文,如果一个项目里有多个不同的配置组合,缓存就会失效重建,直接导致整体测试时间成倍增加。我之前调整过一个项目的测试启动时间,从20多分钟缩短到6分钟,核心就是把测试类里的@SpringBootTest(properties = ...)配置统一收敛到application-test.yml,保证同一个上下文尽量复用。
2.2 切片测试:启动半个容器而不是整个应用
切片测试是SpringBoot Test里最被低估的能力。以@WebMvcTest为例,它只实例化Controller层相关的Bean——@Controller、@ControllerAdvice、WebMvcConfigurer等,同时自动配置MockMvc,而Service层和Repository层的Bean不会加载,需要手动用@MockBean声明。
做个对比你就明白差异在哪里:
| 特性 | @SpringBootTest + MockMvc | @WebMvcTest |
|---|---|---|
| 上下文加载范围 | 全部Bean | Controller + MVC相关配置 |
| 启动时间 | 秒级到十几秒 | 百毫秒级 |
| Service依赖 | 可真实注入,也可Mock | 必须Mock |
| 定位能力 | 组件协作问题 | Web层交互问题 |
同理,@DataJpaTest只加载JPA相关的配置和Repository Bean,默认使用内嵌数据库(H2),且每个测试方法自动回滚事务。@JsonTest只负责测试JSON序列化和反序列化,适合测DTO的字段映射。
切片测试的意义不在于“快”本身,而在于它把问题边界收敛了。如果Web层测试失败了,你不需要去排查是不是某个Service里的逻辑出错,因为Service层本来就是Mock的。这种隔离性对排错体验的提升非常明显。
2.3 @MockBean的演进:从Maven坐标到弃用风波
@MockBean一直是SpringBoot Test里用得非常多的注解,它可以把指定类型的Bean替换成Mockito的Mock对象,在测试时方便地stub方法行为。但从SpringBoot 3.4.0开始,官方在@MockBean的Javadoc里标注了@Deprecated,推荐替代方案是org.springframework.test.context.bean.override.mockito.MockitoBean。
这个变化很多人没注意到,它其实反映了Spring框架的一个新方向:@MockBean的机制是通过MockitoPostProcessor在BeanPostProcessor阶段替换BeanDefinition,实现上有点“重”,而且对某些自定义BeanFactory后置处理器会不兼容。新的@MockitoBean基于BeanOverrideHandler机制,实现更轻量,语义也更清晰。
实际升级时要注意,@MockitoBean和@MockBean的导入包路径不同:
// 旧写法 import org.springframework.boot.test.mock.mockito.MockBean; // 新写法 import org.springframework.test.context.bean.override.mockito.MockitoBean;如果你还在用SpringBoot 3.3或更早版本,@MockitoBean是不存在的,只能用@MockBean。升级到3.4及以后建议直接切到新注解。
2.4 断言神器:AssertJ让测试代码像人话
SpringBoot项目默认集成了AssertJ,我一直觉得这是SpringBoot Test体系里最实用的组件。对比一下:
// JUnit原生断言 assertEquals(200, response.getStatusCode().value()); assertTrue(result.isSuccess()); assertNotNull(result.getData()); // AssertJ链式断言 assertThat(response.getStatusCode().value()).isEqualTo(200); assertThat(result.isSuccess()).isTrue(); assertThat(result.getData()).isNotNull(); // 更复杂的对象断言 assertThat(order.getItems()) .hasSize(3) .extracting(OrderItem::getSkuId) .containsExactly("sku-001", "sku-002", "sku-003");我看到很多老项目的测试代码还在用一堆assertTrue+if做判断,可读性很差,断言失败时也看不出具体是哪个字段出了问题。AssertJ的extracting和filteredOn在做集合断言时尤其好用,配合as()描述信息,跑挂的时候控制台输出一眼就能定位问题。
3. 分层测试实战:三种注解用法的完整范式
3.1 Controller层:MockMvc的请求构造与响应断言
Controller层的测试重点在HTTP协议交互本身——参数绑定、校验规则、响应状态码、JSON结构。用@WebMvcTest配合MockMvc是标准做法。
一个典型的Controller测试长这样:
@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private UserService userService; @Test void shouldReturnUserWhenGetById() throws Exception { UserVO userVO = new UserVO(1L, "张三", "zhangsan@example.com"); when(userService.getById(1L)).thenReturn(userVO); mockMvc.perform(get("/api/users/{id}", 1L) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1)) .andExpect(jsonPath("$.name").value("张三")) .andExpect(jsonPath("$.email").value("zhangsan@example.com")); } @Test void shouldReturnBadRequestWhenIdInvalid() throws Exception { mockMvc.perform(get("/api/users/{id}", -1L)) .andExpect(status().isBadRequest()); } }关键点有两个。
第一,jsonPath是MockMvc最强大的断言工具,它直接解析响应JSON路径,像$.data.items[0].skuId这样的表达式可以精确到嵌套对象。但要注意,用JSONPath断言时如果字段值是中文,有些低版本依赖会出现编码问题,建议在application-test.yml里统一配置server.servlet.encoding.force=true。
第二,校验逻辑的测试用切片测试最合适。比如@Validated注解加上@NotBlank的DTO字段,用MockMvc发送空值请求,看是否返回400 Bad Request,这比用完整上下文启动再调真实接口要快得多。但需要注意,如果Controller的异常处理依赖@RestControllerAdvice里的特定逻辑,记得在@WebMvcTest时把它也带上,或者在测试类里@Import进来。
3.2 Service层:不启动容器的单元测试才是核心
Service层是业务逻辑最集中的地方,也是单元测试性价比最高的地方。这里不需要@SpringBootTest,直接使用JUnit 5 + Mockito就可以了。
一个合格的Service层单元测试示例:
class OrderServiceTest { @Mock private OrderRepository orderRepository; @Mock private InventoryClient inventoryClient; @InjectMocks private OrderService orderService; private static final Long USER_ID = 42L; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test void shouldCreateOrderWhenStockEnough() { when(inventoryClient.getStock(1001L)).thenReturn(10); OrderCreateRequest request = new OrderCreateRequest(1001L, 2); Order order = orderService.createOrder(USER_ID, request); assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); verify(orderRepository).save(any(Order.class)); verify(inventoryClient).deductStock(1001L, 2, USER_ID); } @Test void shouldThrowExceptionWhenStockNotEnough() { when(inventoryClient.getStock(1001L)).thenReturn(1); OrderCreateRequest request = new OrderCreateRequest(1001L, 2); assertThatThrownBy(() -> orderService.createOrder(USER_ID, request)) .isInstanceOf(InsufficientStockException.class) .hasMessageContaining("库存不足"); verify(orderRepository, never()).save(any(Order.class)); } }关于Mockito的使用,我给三点建议:
when和verify分开看。when是stub,定义的是“如果依赖返回什么,被测代码走什么分支”;verify是行为验证,确认某个方法确实按期望被调用了。很多新手只写when不写verify,结果方法内部根本走错了分支,测试照样能过。- 用
ArgumentCaptor捕获参数。相比any(),捕获真实参数再逐字段断言,能发现很多参数传递类的bug。比如上面的例子,可以捕获传给orderRepository.save()的Order对象,断言语状态和金额字段是否符合预期。 @InjectMocks虽然方便但有隐患。它是通过反射把Mock注入到被测类的字段里,如果一个类有多个同类型的依赖,或者依赖是通过构造器注入的,@InjectMocks的行为就不那么可控。我自己更倾向于在@BeforeEach里手动new被测类并把Mock传进去,虽然代码多了几行,但依赖关系一目了然。
3.3 Repository层:@DataJpaTest与内嵌数据库
Repository层的测试重点是SQL语义、字段映射和分页排序。@DataJpaTest默认扫描@Entity和Repository接口,自动配置嵌入式数据库,每个测试方法在事务内执行并回滚。
@DataJpaTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test void shouldFindUserByEmail() { User user = new User(); user.setName("李四"); user.setEmail("lisi@example.com"); userRepository.save(user); Optional<User> found = userRepository.findByEmail("lisi@example.com"); assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo("李四"); } }这里有几个很容易踩的细节:
@DataJpaTest默认不加载@Component类的Bean,如果Repository依赖某个自定义的@Component(比如自定义审计逻辑、加密字段转换器),需要通过@Import手动引入。- 内嵌数据库默认是H2,但H2的SQL方言和MySQL/Oracle存在差异,比如自增主键策略、函数名不同。如果项目用了比较复杂的原生查询,用H2测可能会跑通但线上报错。这种情况下,用Testcontainers跑真实的MySQL容器更可靠,这块后面会展开。
@DataJpaTest里的测试方法默认是回滚的,但如果测试里手动调用了entityManager.flush()或者触发了clear操作,数据状态可能会产生影响。测试方法之间一定要保证数据隔离,不要依赖某个测试先执行。
4. 集成测试的硬骨头:数据库、Redis、MQ与外部接口
4.1 测试数据库选型:H2便宜但Testcontainers更真实
很多团队的集成测试用的是H2内存数据库,理由是配置简单、启动快。但这种方案在生产环境的数据库是MySQL,等到上线的时候才发现,H2里验证过没事的SQL,在MySQL里因为语法、索引、字符集问题翻车了。
我个人的倾向是——能用Testcontainers就不用H2。Testcontainers通过Docker在测试时启一个真实数据库实例,测试跑完自动销毁,和生产的兼容性基本没有偏差。
一个基于Testcontainers的测试配置:
@Testcontainers @SpringBootTest class UserServiceIntegrationTest { @Container @ServiceConnection static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.33"); @Autowired private UserRepository userRepository; @Test void shouldSaveAndQueryUser() { // 业务逻辑 } }@ServiceConnection是SpringBoot 3.1引入的,它可以自动把容器信息映射到spring.datasource.url、spring.datasource.username等配置,省去了手写@DynamicPropertySource的繁琐代码。如果是低版本SpringBoot,则得用@DynamicPropertySource手动设置:
@DynamicPropertySource static void datasourceProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysql::getJdbcUrl); registry.add("spring.datasource.username", mysql::getUsername); registry.add("spring.datasource.password", mysql::getPassword); }Testcontainers的劣势也很明显:跑测试需要Docker环境,CI服务器上必须能拉取镜像,而且首次启动镜像会耗时较长。但相对于它在兼容性方面带来的收益,这个成本是值得的。我的建议是单元测试不用它,但涉及数据库集成测试,尽量上。
4.2 事务回滚与数据隔离:@Transactional为什么能回滚到初始状态
很多人在集成测试里看到@Transactional就以为它只负责“保证测试数据不用清理”,但实际上它的机制需要说清楚。
在测试方法上标注@Transactional后,Spring的TransactionalTestExecutionListener会把测试方法包在一个事务里,方法结束后直接回滚,所以数据库里不会留下测试数据。但这里有一个很容易被忽略的坑:你在测试方法里调用的Service方法如果本身也带@Transactional,它参与的是同一个事务,数据写入后会被外层测试事务一起回滚,所以看起来测试是“干净”的。
但如果你在测试里用了异步线程、或者调用了REQUIRES_NEW传播级别的方法,数据就会在独立事务中提交,测试事务回滚并不能把那些数据带回来。这时候要么做好清理,要么用TestTransaction手动控制——比如TestTransaction.flagForCommit()可以打破自动回滚,让测试数据真实提交。
还要注意,@Transactional在测试里的行为影响的是“测试方法”本身。如果某个Service方法逻辑里依赖事务的提交事件(比如@TransactionalEventListener),在测试方法里调用它,事件可能不会触发,因为事务还没有提交。这种情况下,要么在测试里显式把事务提交掉,要么针对这类场景单独写集成测试,不依赖测试方法级的事务。
4.3 外部依赖怎么测:WireMock与MockWebServer的取舍
集成测试绕不开外部HTTP服务。常见的方案有两种:WireMock和MockWebServer。
MockWebServer是OkHttp团队出的库,轻量、容易上手,适合验证“我发出去的请求是否正确”——它可以断言请求路径、请求体、Header。WireMock功能更重,支持动态响应、状态化行为、请求匹配等,适合模拟完整的接口契约。
我在接第三方支付和短信服务时,一般会用WireMock做一个本地stub服务。最实用的一个能力是,WireMock支持从录制文件自动生成stub,可以把生产里的一个真实响应保存下来,作为测试的固定预期。
简单示例:
@SpringBootTest @AutoConfigureWireMock(port = 8089) class PaymentClientTest { @Autowired private PaymentClient paymentClient; @Test void shouldParsePaymentCallback() { stubFor(post(urlEqualTo("/api/payment/callback")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBody("{\"code\":\"SUCCESS\",\"tradeNo\":\"123456\"}"))); PaymentResult result = paymentClient.callback("123456"); assertThat(result.isSuccess()).isTrue(); assertThat(result.getTradeNo()).isEqualTo("123456"); } }用WireMock要注意,stub的匹配规则如果太宽松,测试里可能悄悄用了一个旧的响应结构,导致代码里字段名已经改了测试却在绿。我习惯在每个stub里加上withId,并且在阶段切换时清理掉无用的stub,通过WireMock.reset()保证测试独立。
Redis和MQ的集成测试,我用的方案是Testcontainers下的GenericContainer启动redis镜像,MQ则用KafkaContainer或RabbitMQContainer。这里需要强调一下,不要用spring.redis.embedded之类的高仿替代品,嵌入式Redis和真实的Redis在持久化、内存淘汰策略上的行为差异,会在某些边界场景坑到你。
5. 版本、配置与上下文:测试启动的隐形杀手
5.1 SpringBoot 2.x到3.x:测试API的破坏性变化
SpringBoot 3.x基于Spring Framework 6和Jakarta EE 9,包名从javax改成了jakarta。这意味着如果你的测试代码里直接引用了javax.persistence.Entity之类的注解,升级后第一件事就是改包名。
测试API层面,最大的破坏性变化是SpringBoot 2.6之后移除了spring.factories自动配置机制,改用AutoConfiguration.imports,而@SpringBootTest启动上下文时会扫描这些自动配置类,如果你的项目中还在用旧方式注册一些测试相关的自动配置类,升级后测试会直接失败。
还有一点是@MockBean在3.4开始标记为废弃。在这之前,2.x的@MockBean用法在3.x里大体可运行,但如果你同时升级了Spring Framework 6.2,某些自定义的BeanFactoryPostProcessor场景会出现Mock不生效的问题。
版本迁移时,最稳妥的做法是先看spring-boot-dependencies的BOM版本,对照官方迁移指南逐项核对。我遇到过最隐蔽的问题是,SpringBoot 3.x默认启用parameter name反射,而某些通过@WebMvcTest测试的Controller如果依赖了参数名解析(比如@RequestParam不带value),在2.x里能跑通,在3.x里可能直接抛参数名找不到的异常。
5.2 多环境配置与配置解密:测试环境怎么会读到prod的文件
开发者经常在IDE里配置application-test.yml,但实际跑测试时SpringBoot的配置优先级是:bootstrap.yml(如果用了) >application-test.yml>application.yml。如果你的测试类没有显式指定@ActiveProfiles("test"),SpringBoot默认使用defaultprofile,会加载application.yml和application-default.yml。
热搜里那个“IDEA Maven发布时的prod test配置文件”的问题,我猜测正是这类场景——测试环境意外激活了prod配置,于是数据库地址指向了生产环境。这里有个经验,在测试类的基类或者JUnit配置里强制指定profile:
@ActiveProfiles("test") @SpringBootTest public class BaseIntegrationTest { // 公共配置 }或者更保险一点,在src/test/resources下放一个空的application.yml,因为测试classpath下的application.yml优先级高于main里的同名文件,这样即使不激活testprofile,测试环境也不会读到生产配置。
另一个坑是配置加密。项目里用了Jasypt对数据库密码做加密,jasypt.encryptor.password通常通过环境变量或者启动参数注入。测试时如果忘了注入这个密码,所有涉及加密配置的Bean启动就会直接失败。解决方式是在测试配置里放一个测试专用的解密密码:
jasypt: encryptor: password: test-secret同时把测试环境的spring.datasource.url指向本地测试库,避免连到生产。
5.3 上下文缓存与循环依赖:为什么你的测试启动慢得离谱
SpringBoot Test的上下文缓存机制,很多人其实没利用好。SpringBootContextLoader默认会对相同配置的上下文做缓存,但注意缓存key包含了@MockBean的列表、@ActiveProfiles、properties等。如果两个测试类一个用了@MockBean(UserService.class),另一个没用,它们的上下文就不同,不会复用。
所以,如果项目里几十个测试类,每个都@MockBean不同的类,整个测试套件可能启动几十个Spring容器,时间必然爆炸。建议方式是把MockBean的声明上提,尽量在基类里统一声明公共的MockBean,让同模块的测试类共享同一个上下文。
循环依赖在测试里也很烦人。比如@SpringBootTest启动时检测到Bean之间存在循环依赖,SpringBoot 2.6版本开始默认禁止循环依赖,启动直接报错。如果是在升级版本时出现的,靠修改业务代码解开循环依赖才是正解,但如果你实在需要快速验证,可以在配置里设置spring.main.allow-circular-references=true。注意这只是临时掩盖问题,长期还是要把依赖结构理清楚。
测试类之间的依赖也是一个大坑。JUnit默认每个测试类都是独立的实例,测试方法之间没有顺序保证。如果测试代码写了“先执行A方法才能执行B方法”的隐式依赖,那基本就是给CI埋了一颗雷。我见过一个项目因为两个测试类共享同一张表,一个写完数据后没有清理,另一个跑起来就失败,最后靠指定@FixMethodOrder才“稳定”下来——这个方案典型的治标不治本,数据清理必须在测试方法里自己负责。
6. 测试里必须避开的坑:事务、乱序、并发与懒加载
6.1 @Transactional回滚的失效场景
前面提到测试方法的@Transactional会回滚,但下面这几种场景它回滚不了:
- Service方法使用了
REQUIRES_NEW传播级别:新开的事务独立提交,不回滚。 - 异步调用:通过
@Async或新线程执行的逻辑,运行在独立事务中。 - 分布式事务:涉及XA或Seata之类的方案,事务由外部协调器管理,JPA的
@Transactional控制不了。 - 非Spring管理的事务:比如直接在测试里操作
TransactionTemplate之外的原生连接。
针对这类场景,正确的应对不是依赖自动回滚,而是主动做数据清理。可以在@AfterEach里用JdbcTemplate清理测试关联的表,或者给测试数据加上唯一标记(比如数据里带上test_user_id),跑完后按标记删除。
6.2 测试间的数据污染与隔离策略
测试并行执行越来越常见,但并行和数据库测试是天然的敌人。如果两个测试方法同时往同一张表写相同主键的数据,就会发生冲突。
我的建议是,每个测试方法要生成唯一的数据。比如主键用UUID或者时间戳保证唯一,尽量少用固定的“id=1”这种写法。另外,如果测试里查询的数据依赖某个状态,比如查询条件里带状态字段,可以考虑在测试数据里额外加一个test_scope列,所有测试数据都打上标记,查询时带上这个标记,测试结束后统一清理。
如果是@DataJpaTest这种自动回滚的测试,本身不会有数据残留问题,但如果你需要手动验证SQL执行结果,回滚机制反而成了障碍。你可以通过@Rollback(false)关闭某个方法的事务回滚,但最好只在确认需要手动验证的时候才这么干。
6.3 懒加载与异步调用:测试里最常见的“假通过”
还有一个测试失效的经典场景是懒加载异常。JPA实体里的@OneToMany集合默认是Lazy的,在SpringBoot测试里如果事务边界结束后再去访问集合,会抛LazyInitializationException。很多测试跑绿是因为在事务内访问了懒加载集合,但由于测试方法本身的@Transactional把整个方法包在了事务里,从而掩盖了线上接口在无事务环境下会报错的问题。
因此,如果你真的要在Controller层测试接口返回的DTO里带嵌套集合,建议在组装DTO之前就完成关联查询,别依赖懒加载。
异步调用的问题更隐蔽。Service方法调用了一个@Async方法,单元测试里如果直接用Mockito把异步Bean Mock掉了,测试跑得快,但异步逻辑的真实行为完全没有覆盖。如果要验证异步逻辑,可以在测试类里显式配置一个同步执行的Executor(比如SyncTaskExecutor),或者用Awaitility轮询等待异步结果:
await().atMost(Duration.ofSeconds(5)) .untilAsserted(() -> assertThat(asyncService.getResult()).isEqualTo("done"));这里有一个面试高频的问题我也想顺带说清楚:测试里加了@MockBean后,上下文会被标记为dirty,下一次用到同一个上下文的测试类会重建容器。所以不要每个测试方法都去@MockBean同一个类,可以在测试类级别统一定义,最大程度复用上下文缓存。
7. 让测试真正起作用:评审清单与习惯养成
文章最后我想分享一套在实际项目中沉淀下来的测试评审清单,能覆盖90%的常见问题,保证测试不是“自嗨”:
- 单元测试是否覆盖了核心分支:每个Service方法至少有两个测试——正常路径和异常路径,复杂的if/else和循环逻辑尽量都走到。
- 是否用了真实数据库:涉及复杂SQL的Repository测试,优先Testcontainers,别用H2凑合。
- 上下文缓存是否命中:检查测试日志里的
Starting ApplicationContext出现次数,太多说明上下文没复用。 - 测试数据是否隔离:测试方法之间不能有顺序依赖,所有测试数据必须有清理策略。
- 外部服务是否可控:HTTP外部接口用WireMock或MockWebServer,不能用公网环境。
- 是否有没有断言的测试:有些测试跑完整个流程,但一个
assert都不写,失败了也不知道为什么失败。
我在团队里还经常说一句话:测试代码也是代码,要用产品代码的标准来维护它。冗余的测试、断言单薄的测试、依赖环境的测试,时间久了都会变成垃圾,最后整个测试套件让人失去信心。
SpringBoot Test这套体系本身是够用的,哪怕不引入额外的框架,光靠JUnit 5、SpringBoot Test、AssertJ、Mockito、Testcontainers这几个组合,就足以支撑一个中型项目的质量保障。关键是别把测试当成“任务”,要当成工程的组成部分。当你真正跑过足够多失败的测试、分析过足够多上下文启动日志和事务回滚异常之后,你会慢慢建立起一种感觉——测试不是用完就扔的工具,而是你给这个项目留的一份持续可用的技术文档。