news 2026/9/10 20:11:29

SpringBoot测试进阶指南:从MockMvc到Testcontainers的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot测试进阶指南:从MockMvc到Testcontainers的完整实践

1. 开篇:为什么SpringBoot的测试值得单独写一篇

做Java后端这些年,SpringBoot Test是我几乎每个项目都会用到、但很少有同事真正玩明白的一块。很多人对测试的印象还停留在“写几个@Test方法跑一下就算测了”,等到CI流水线上因为一个Bean没注入挂掉、或者因为改了配置把数据库都连炸了,才发现自己根本不会写SpringBoot环境下的测试。

这篇文章就是要把SpringBoot的测试体系从头到尾捋一遍。SpringBoot从1.x时代就提供了spring-boot-starter-test这个全家桶启动器,依赖了JUnit、Mockito、AssertJ、Spring Test等一堆测试库,目的是让你开箱即用地写单元测试、集成测试、MockMvc接口测试、数据库测试甚至容器化测试。它适合谁看?刚接触后端测试的新人可以按章节顺序读完,直接上手写项目里的测试;有一定经验但没系统整理过的人,也可以重点看第2、4章,把切片测试、随机端口、事务回滚这些经常踩坑的点一次性弄明白。

我后面写到的所有内容,都是基于SpringBoot 2.7和3.x都能通用的实践,个别地方会说明版本差异。闲话少说,直接进入正题。

2. 测试方案选型与整体设计思路

2.1 为什么SpringBoot要单独搞一套测试体系

先想一个问题:Spring的ApplicationContext容器本来就是一个完整的依赖注入环境,用JUnit直接new对象、手动set依赖也完全能测,为什么还要引入spring-boot-starter-test

核心原因在于,SpringBoot应用里的大多数Bean都不是你可以随手new出来的。比如DataSourceRedisTemplateRestTemplateJdbcTemplate,这些Bean的组装依赖自动装配和大量外部配置。如果你在测试里手动去new一个JdbcTemplate,这个测试根本不代表应用的真实运行状态,属于“自欺欺人式”测试,上线前该挂还是挂。

Spring Test框架解决的就是这个问题。它通过SpringJUnit4ClassRunner(JUnit4时代)或SpringExtension(JUnit5时代)把测试类纳入Spring容器管理,让测试运行时能拿到真实装配好的Bean。SpringBoot在Spring Test之上又做了一层封装,用@SpringBootTest一个注解就可以启动整个应用上下文,再配合@AutoConfigureMockMvc@MockBeanTestcontainers等能力,把测试的复杂度降了一个量级。

2.2 starter-test里到底集成了什么

直接看依赖比背文档有用。创建一个SpringBoot项目,引入spring-boot-starter-test后,打开依赖树,你会发现它替你拉进了一堆库:

  • JUnit Jupiter:JUnit5时代的核心测试框架,提供@Test@BeforeEach@AfterEach@DisplayName等注解
  • Spring Test & Spring Boot Test:提供@SpringBootTest@WebMvcTest@DataJpaTestTestRestTemplateMockMvc等Spring生态测试能力
  • AssertJ:流式断言库,assertThat(...).isEqualTo(...)的体验比JUnit自带断言好得多
  • Mockito:Mock对象库,配合@MockBean@SpyBean使用,用来模拟依赖
  • JSONassert:针对JSON字符串的断言工具
  • JsonPath:对JSON响应做路径解析和取值断言
  • Awaitility:处理异步测试,轮询等待某个条件成立

这也是为什么SpringBoot的测试体验比传统SSH项目好这么多:它把所有测试可能用到的库都打包好了,你不需要自己去协调JUnit版本和Mockito版本之间的兼容性。

提示:如果你在用SpringBoot 2.1以前的老版本,默认依赖的是JUnit4;2.2之后默认切到JUnit5。现在新建项目基本都是JUnit5,代码里的import org.junit.jupiter.api.Test,不要再用org.junit.Test了。

2.3 如何根据测试目标选择启动范围

这是整个SpringBoot测试设计里最核心的思路:不是所有测试都需要启动完整应用上下文

很多人刚接触的时候,习惯性的做法是给每个测试类都加@SpringBootTest,因为这样什么都不用考虑,Bean肯定都在。坏处是,启动一个完整的ApplicationContext涉及到数据源连接、Redis连接、MQ连接、各种自动配置类初始化,单次耗时轻松上5到10秒。一个模块40个测试类,每个都这么搞,单次CI测试任务跑下来要十来分钟,开发体验极差。

合理的做法是依据测试目标分层:

  • 如果只测一个Service方法,且依赖全是接口,那就用纯粹的JUnit+Mockito,不启动Spring容器,毫秒级跑完
  • 如果测Controller层的HTTP请求和参数校验,用@WebMvcTest只加载Web层相关配置,不加载Service层和数据源
  • 如果测JPA的Repository查询是否正确,用@DataJpaTest,只配置JPA、数据源和事务,不加载全量Bean
  • 只有需要验证跨层交互、事务机制、自动装配是否正常时,才用@SpringBootTest

用一句话总结:测试启动的上下文越小,速度越快,可靠性越高。能用Mock解决就不要起真实容器,这是所有后端测试的第一原则。

3. 测试注解与核心配置逐一拆解

3.1 @SpringBootTest:最重的集成测试注解

@SpringBootTest是SpringBoot测试里最基础的注解,它负责启动一个完整的ApplicationContext。它有一个常用属性webEnvironment,默认是MOCK,表示创建WebApplicationContext但不启动真实的内嵌服务器(Tomcat/Jetty),通过MockMvc来模拟HTTP请求。

webEnvironment一共有四个可选值,我先用表格把区别列出来,再逐个讲解:

配置值Web服务器端口适用场景
MOCK不启动真实服务器配合MockMvc做Controller层测试
RANDOM_PORT启动真实服务器随机端口需要真实HTTP调用的集成测试
DEFINED_PORT启动真实服务器8080或配置指定极少用,容易端口冲突
NONE不加载Web容器上下文只测Service、Repository等非Web逻辑

MOCK环境适合测试@RestController的请求映射、参数绑定、返回结构。RANDOM_PORT适合启动完整应用,用TestRestTemplateWebTestClient发真实HTTP请求,验证过滤器、拦截器、全局异常处理器这些只有真实服务器才会生效的组件。

我实际项目里用来做全链路冒烟测试的写法是这样:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc class UserControllerIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Test @DisplayName("创建用户成功后返回200和用户ID") void createUserShouldReturnUserId() { Map<String, String> body = new HashMap<>(); body.put("name", "张三"); body.put("email", "zhangsan@example.com"); ResponseEntity<JsonNode> response = restTemplate.postForEntity( "/api/users", body, JsonNode.class ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().get("data").get("id").asLong()).isPositive(); } }

RANDOM_PORT模式下测试端口是随机的,不会和你本地启动的应用端口冲突。需要拿真实端口的时候就注入@LocalServerPort

注意:@SpringBootTest默认会去找@SpringBootApplication所在的类,向上逐级扫描直到找到@Configuration。如果你的测试类和主启动类不在同一个包下,建议用@SpringBootTest(classes = YourApplication.class)显式指定配置类,不然很容易出现“找不到Bean”的诡异问题。

3.2 @TestConfiguration:给测试加专属Bean

测试环境经常会需要一些和正式环境不同的Mock Bean或者替代配置。比如正式环境里FeignClient调用远程接口,测试里肯定不想真的发起远程请求。这个问题有两种解法,一种是用@MockBean直接替换,另一种就是定义一个测试专用的@TestConfiguration

@TestConfiguration和普通@Configuration的唯一区别是,它不会被@SpringBootApplication的组件扫描自动加载,必须在测试类里通过@Import显式导入。这个设计很关键,防止测试代码污染生产环境的Bean装配。

典型用法是构建一个测试专用的模拟Bean:

@TestConfiguration public class MockFeignConfig { @Bean @Primary public UserFeignClient userFeignClient() { return Mockito.mock(UserFeignClient.class); } }

然后在测试类上写:

@SpringBootTest @Import(MockFeignConfig.class) class UserServiceTest { @Autowired private UserFeignClient userFeignClient; @Test void testGetUser() { when(userFeignClient.findById(1L)).thenReturn(new UserDTO(1L, "张三")); // 业务断言... } }

有个细节提醒一下:@MockBean会把容器里原有的那个类型的Bean整个替换掉,而@TestConfiguration里定义@Primary的Bean只是多了一个优先级更高的实现。如果你的测试里既想保留原逻辑,又想对某个接口做局部Mock,更优雅的方案是@SpyBean——它是Mockito的Spy机制,能调用真实方法,但允许对特定方法when()覆盖返回结果。

3.3 切片测试:@WebMvcTest / @DataJpaTest / @MybatisTest

切片测试(Slice Test)是SpringBoot的一大特色。它的思路是只加载某个层次相关的自动配置,不加载全量的Bean。

@WebMvcTest只加载Controller层相关的配置:@Controller@ControllerAdviceJsonConverterHandlerInterceptorWebMvcConfigurer等。注意,它默认不会加载@Service@Repository@Component这些Bean,所以Controller里依赖的Service必须用@MockBean手动注入。我写Controller单元测试时常用这样一套组合:

@WebMvcTest(UserController.class) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test @DisplayName("GET /api/users/1 返回用户信息") void getUserById() throws Exception { when(userService.findById(1L)).thenReturn(new UserVO(1L, "张三", "zhangsan@example.com")); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.name").value("张三")) .andExpect(jsonPath("$.data.email").value("zhangsan@example.com")); } }

@WebMvcTest加载速度快,而且不会连数据库。但它也有局限性:Spring Security、Filter、拦截器的顺序和正式环境可能会有微妙的差异,如果你想验证“未登录时访问该接口返回401”,建议还是跑一个RANDOM_PORT的真实HTTP测试。

@DataJpaTest是JPA Repository的切片测试,只加载@Entity@RepositoryDataSourceJpaTransactionManager等JPA相关组件。默认情况下它会用嵌入式内存数据库替换你配置的数据源,比如H2,然后用@Transactional包住每个测试方法,防止脏数据落到真实库。

@DataJpaTest class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test @DisplayName("根据邮箱查找用户") void findByEmailShouldReturnUser() { 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("李四"); } }

如果你用的是MyBatis而非JPA,SpringBoot没有提供开箱即用的@MybatisTest,需要自己组合@MybatisTest相关注解,或者直接用@SpringBootTest把SqlSessionFactory拉起来。这也是为什么很多MyBatis项目的Repository测试反而比JPA项目慢——这是框架选型带来的天然代价,只能接受。

提示:@DataJpaTest默认替换数据源的行为是@AutoConfigureTestDatabase(replace = Replace.ANY)的结果。如果你已经用了@Transactional且不想替换数据源,可以改成@AutoConfigureTestDatabase(replace = Replace.NONE),继续连接你配置的正式数据源,但这样做一定要做好数据清理,否则测试数据会污染库。

3.4 @DirtiesContext和@Transactional:控制测试状态的两种方式

@DirtiesContext@Transactional是测试里控制状态的两把刷子,很多人分不清该用哪个。

@Transactional加在测试类或测试方法上,Spring会为每个测试方法开启一个事务,方法执行完直接回滚,不会真正写入数据库。这个设计就是为了不让测试数据污染环境。

但有几种情况@Transactional是不生效的:

  1. 测试类没被Spring上下文管理,比如你没有加@SpringBootTest@DataJpaTest等注解
  2. 被测试的方法自己开启了新事务,比如REQUIRES_NEW传播级别,回滚外层事务管不到内层
  3. 被测试的代码在独立线程里执行,事务是和线程绑定的,子线程的事务回滚不生效
  4. 你用的是async方法,底层线程池里的调用不受测试事务控制

@DirtiesContext就不一样了。它是“暴力刷新”方案:在指定的时机,Spring会销毁并重建整个ApplicationContext。通常用在某些测试改动了上下文状态,比如修改了Bean属性、注册了新的Bean、修改了类路径资源,必须在一个干净的上下文里跑下一个测试。

代价很明显:每次重建上下文都要重新走一遍自动装配,非常耗时。所以我建议能用事务回滚解决的状态隔离,绝不要用@DirtiesContext,这个注解属于“最后手段”。

3.5 MockMvc:Controller测试的核心工具

MockMvc是Spring MVC测试的入口,它不需要真实启动Tomcat,而是把请求丢进DispatcherServlet,走一遍完整的MVC调用链。你可以理解成“Servlet容器的模拟器”。

使用MockMvc有三步:构建请求、执行请求、断言结果。我项目里沉淀了一套固定写法,可以参考:

@WebMvcTest(UserController.class) @AutoConfigureMockMvc(addFilters = false) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test void shouldReturnBadRequestWhenNameIsBlank() throws Exception { String jsonBody = "{\"name\":\"\",\"email\":\"test@example.com\"}"; mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(jsonBody)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("用户名不能为空")); } }

MockMvc断言链条里的status()jsonPath()content()分别处理HTTP状态码、JSON字段和响应体。我建议一律用JsonPath做精准断言,而不是直接用content().string(containsString(...))去模糊匹配,后者在字段顺序调整或者返回值多了一个嵌套字段时,容易让断言失效但不报错,给上线埋雷。

注意:@AutoConfigureMockMvc(addFilters = false)表示跳过Spring Security的过滤器链。如果你想验证带Token的鉴权场景,需要去掉这个配置,并在请求里显式加入认证信息,比如header("Authorization", "Bearer " + token)或者用@WithMockUser(Spring Security测试专用注解)。

4. 从零动手:一个完整的分层测试实战

4.1 工程结构和依赖准备

这一节我带着你从零搭建一个可运行的测试环境。假设我们有一个极简的用户模块:UserController接收HTTP请求,UserService处理业务逻辑,UserRepository从数据库读写。我在pom里加了spring-boot-starter-testspring-boot-starter-webspring-boot-starter-data-jpah2spring-security-test(如果存在Spring Security依赖,需要显式添加这个测试库来启用@WithMockUser)。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency>

如果你用的SpringBoot 3.x,强烈建议顺手把spring-boot-starter-test版本对齐到Maven中央仓库最新版,否则JUnit5和Mockito版本搭配可能报奇怪的类加载错误。

4.2 Service层测试:Mockito + JUnit5的组合拳

Service层测试的要点是:完全脱离Spring容器,把所有外部依赖Mock掉,专注验证业务逻辑。这是跑得最快的测试,也是单元测试的核心。

class UserServiceTest { @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; @InjectMocks private UserService userService; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } @Test @DisplayName("创建用户时对密码进行加密") void createUserShouldEncodePassword() { User user = new User(); user.setName("王五"); user.setRawPassword("123456"); when(passwordEncoder.encode("123456")).thenReturn("$2a$10$abcdef"); when(userRepository.save(any(User.class))).thenAnswer(invocation -> { User saved = invocation.getArgument(0); saved.setId(1L); return saved; }); User result = userService.createUser(user); assertThat(result.getId()).isEqualTo(1L); assertThat(result.getPassword()).isEqualTo("$2a$10$abcdef"); verify(userRepository).save(any(User.class)); } }

这里@InjectMocks会把@Mock出来的UserRepositoryPasswordEncoder自动注入到UserService实例中,省去了手写构造方法的麻烦。注意MockitoAnnotations.openMocks(this)在JUnit5里不是必需的,如果你在测试类上加@ExtendWith(MockitoExtension.class),Mockito会自己完成初始化,但如果你用了@InjectMocks但忘了加,运行时会直接NPE,这点新手特别容易踩。

4.3 Controller层测试:MockMvc模拟HTTP请求

Controller层测试的定位是:验证请求映射、参数绑定、序列化、异常处理。Service被Mock掉,只关注Web层行为。

@WebMvcTest(UserController.class) @AutoConfigureMockMvc(addFilters = false) class UserControllerTest { @Autowired private MockMvc mockMvc; @MockBean private UserService userService; @Test @DisplayName("请求参数缺失时返回400") void shouldReturn400WhenEmailIsMissing() throws Exception { String jsonBody = "{\"name\":\"张三\"}"; mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(jsonBody)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").exists()); } @Test @DisplayName("用户不存在时返回404") void shouldReturn404WhenUserNotFound() throws Exception { when(userService.findById(999L)).thenThrow(new UserNotFoundException("用户不存在")); mockMvc.perform(get("/api/users/999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value("用户不存在")); } }

jsonPath("$.message")这里的$代表根节点,$.message取的是根节点下的message字段,是JSONPath的标准语法,比一层层嵌套getJsonObject方便太多了。之前有个同事用字符串拼接的方式构造JSON断言,一改字段就崩,换成JsonPath之后维护成本降了一大截。

4.4 Repository层测试:@DataJpaTest验证查询方法

Repository测试的核心目标:验证查询方法写对了,JPQL/HQL没有语法错误,字段映射正确。

@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test @DisplayName("按邮箱查询能匹配到正确用户") void findByEmailReturnsCorrectUser() { User user = new User(); user.setName("赵六"); user.setEmail("zhaoliu@example.com"); userRepository.save(user); Optional<User> found = userRepository.findByEmail("zhaoliu@example.com"); assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo("赵六"); } }

我这里加了Replace.NONE,因为我想测试真正生产用的数据库方言和表结构,而不是H2的简化模式。你需要在src/test/resources/application-test.yml里配置好测试库连接。如果你没有真实测试库可用,就把Replace保持默认,用内置H2跑,速度更快,但要注意H2对某些SQL方言的兼容性问题。

有一个坑要特别说:@DataJpaTest里每个测试方法默认是事务回滚的,但如果你用了clearAutomatically = true(在@DataJpaTest@Transactional上配置),有可能出现“测试方法内保存了数据,但后面的查询查不到”这种诡异现象。这是因为JPA的一级缓存和事务快照机制做了隔离。遇到类似情况,优先检查事务和FlushMode的设置,不要急着改@DirtiesContext

4.5 完整集成测试:@SpringBootTest + Testcontainers

如果你想验证“从HTTP入口到数据库落库”的完整链路,就需要@SpringBootTest+ 真实容器。Testcontainers会在测试启动时用Docker拉起一个MySQL/PostgreSQL,测试结束自动销毁,保证测试环境可复现。

第一步,引入Testcontainers依赖:

<dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.19.3</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>mysql</artifactId> <version>1.19.3</version> <scope>test</scope> </dependency>

第二步,写一个配置类,把Testcontainers的容器提升为静态单例,避免每个测试方法都重新起一个Docker容器,那会慢到怀疑人生:

@Testcontainers class UserIntegrationTest { @Container static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysql::getJdbcUrl); registry.add("spring.datasource.username", mysql::getUsername); registry.add("spring.datasource.password", mysql::getPassword); } @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @Test @DisplayName("完整链路:创建用户后数据库能查到") void fullFlow() { Map<String, String> body = new HashMap<>(); body.put("name", "钱七"); body.put("email", "qianqi@example.com"); ResponseEntity<JsonNode> response = restTemplate.postForEntity( "/api/users", body, JsonNode.class ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(userRepository.findByEmail("qianqi@example.com")).isPresent(); } }

@DynamicPropertySource是SpringBoot 2.5.0之后引入的,它可以在容器上下文创建前动态注入属性,是解决“容器端口在运行前不可知”这类问题的最优解。在它之前,很多项目会通过@TestPropertySource(properties = "...")硬编码一个固定端口,Docker端口一冲突就翻车,现在这个方案彻底解决了。

4.6 配置文件和Profile的选择

SpringBoot测试环境下,配置文件的加载顺序是:src/test/resources/application.yml优先于src/main/resources/application.yml。也就是说,你可以在src/test/resources下放一个测试专用配置,它会覆盖主配置。

我在一个多环境项目里常用这种组合:

  • src/main/resources/application.yml:生产配置,包括真实数据源、Redis地址
  • src/test/resources/application.yml:测试基础配置,关闭一些非必要的自动配置,比如spring.redis.port=0(不连Redis)
  • src/test/resources/application-test.yml:通过@ActiveProfiles("test")激活,里面放Testcontainers相关的动态参数

如果你发现测试启动时每次都连了生产数据库或者生产Redis,不用怀疑,一定是你的测试配置没有正确加载,或者@SpringBootTest的属性优先级覆盖了你预期的配置。排查方法很简单:在测试里注入Environment,打印getProperty("spring.datasource.url")看一眼就知道加载的是谁。

5. 常见问题——实测踩坑记录和解决方案

5.1 测试运行时报“No qualifying bean of type ...”

这是SpringBoot测试里最高的报错频率,没有之一。原因基本逃不出以下三种:

  1. @SpringBootTest扫描范围不对,测试类和主启动类不在同一个包或子包下,导致上下文找不到某个配置类
  2. 测试类使用@WebMvcTest@DataJpaTest等切片注解,但你访问了该切片没有加载的Bean类型,比如@WebMvcTest里注入了UserService(正确做法是用@MockBean
  3. 某个Bean的创建依赖了未配置的第三方组件,比如数据源没配,导致DataSourceBean创建失败

排查思路一览:

报错现象常见原因推荐解法
No qualifying bean of type UserService切片测试未加载Service@MockBean UserService
No qualifying bean of type DataSource测试环境未配置数据源@DataJpaTest或配置application-test.yml
BeanDefinitionStoreException循环依赖SpringBoot 2.6后默认禁止循环依赖重构代码消除循环引用,或spring.main.allow-circular-references=true临时绕过
Failed to load ApplicationContext自动配置需要外部组件未启动用Testcontainers起依赖服务,或把依赖Bean Mock掉

5.2 事务回滚不生效,测试数据污染开发库

这是曾经让我加班到晚上十一点的问题。现象是:测试方法明明是@Transactional的,跑完数据更新还是写到了开发库。

复盘之后是这几个原因:

  • 测试类没有加@SpringBootTest或其他Spring上下文注解,事务管理器根本没生效
  • 被测试的方法内部自己发起了REQUIRES_NEW新事务,外层回滚不影响内层事务的提交
  • 代码里用了@Async或者ThreadPoolTaskExecutor,子线程里的事务不在测试方法的线程上下文里
  • @PostConstruct初始化的数据修改不在测试事务管理范围内

对待@AsyncREQUIRES_NEW这类代码,单纯的@Transactional回滚是救不了的。我的应对策略是:要么在被测方法里加一个transactionTemplate的传播级别参数,测试时显式指定;要么把这些方法从这个测试类里摘出去,单独写一个@SpringBootTest集成测试,测试完手动清理数据。

注意:@DirtiesContext不是回滚的替代方案。它只是重建了上下文,已经提交到数据库的数据不会因为上下文销毁而消失。如果你必须用真实库做测试且数据会污染,正确做法是在@AfterEach里手动写清理SQL,或者使用带事务回滚的@Transactional方案。

5.3 MockMvc请求乱码和中文返回乱码

MockMvc测试中文乱码是令人头大的问题。多半是因为字符编码过滤器没有正确配置。SpringBoot 2.6之后默认的CharacterEncodingFilterUTF-8,但如果你没有把server.servlet.encoding.force设为true,某些场景下响应体编码可能不被强制为UTF-8。

遇到乱码,优先检查这几处:

# 请求端 mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON_VALUE) .characterEncoding(StandardCharsets.UTF_8) ... ) # 断言端 .andExpect(content().encoding("UTF-8"))

如果你项目里自定义了Filter注册顺序,注意CharacterEncodingFilter要放在最前面,否则它处理的时候请求体已经被读走了。

5.4 SpringSecurity拦截了MockMvc请求导致401

如果项目里接了SpringSecurity,@WebMvcTest默认会加载安全配置,你访问受保护接口就会被拦截。处理方式有两种:

  • 方式一:测试类上加@AutoConfigureMockMvc(addFilters = false),跳过过滤器链,只测Controller逻辑
  • 方式二:用Spring Security Test提供的@WithMockUser(username = "admin", roles = "ADMIN"),伪登录后再发请求

方式一简单但测不到真实的鉴权逻辑,方式二能更贴近真实安全策略,但需要确保测试类引用了spring-security-test。我个人的建议是:Controller的常规CRUD用方式一,涉及权限控制的接口一定要用方式二,不然上线后才发现漏配了某个接口的权限,那就是事故级别的问题。

5.5 Testcontainers启动慢或Docker不可用

Testcontainers最大的痛点就是慢。每次跑集成测试都要等Docker pull镜像、起容器、等数据库健康检查。在本地开发机上还能忍,在CI的共享Runner上就容易出问题。

我的经验是三点:

  1. 尽量用@Container static声明容器,让所有测试类共享一个容器实例,不要每个测试类都@Container非静态创建
  2. 在CI上把Docker镜像提前拉好,或者在CI的缓存策略里缓存~/.docker/目录,省掉每次pull的时间
  3. 明确告诉团队:跑IntegrationTest之前必须先把Docker服务启动,否则报出的全是Connection refused这种完全看不懂的错误

如果你真的不想用Docker,也有折中方案:本地用H2,只有CI才切到MySQL。通过@ActiveProfiles来区分,但这种方案有个隐患——H2和MySQL的SQL方言、索引策略都有差异,你在H2上通过的测试不代表MySQL上没问题,所以只适合做快速回归,不能替代真实数据库测试。

6. 工具与技巧:让测试更好用的几个实践

6.1 用@ParameterizedTest做数据驱动测试

很多人在写Controller参数校验测试时,习惯一个一个方法写,代码重复率极高。JUnit5的@ParameterizedTest能把这个场景简化到极致:

@ParameterizedTest @ValueSource(strings = {"", " ", "null", "abc"}) @NullSource @DisplayName("非法email时拒绝请求") void shouldRejectInvalidEmail(String email) throws Exception { Map<String, String> body = new HashMap<>(); body.put("name", "测试"); body.put("email", email); mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(new ObjectMapper().writeValueAsString(body))) .andExpect(status().isBadRequest()); }

@ValueSource@NullSource的组合能一次性覆盖空字符串、空白字符串、null等边界情况。我实测下来,一个参数校验测试的代码量能减少60%以上,而且新增一个非法值只需要加一个入参,不用新增一个测试方法。

6.2 用Awaitility搞定异步测试

验证MQ消费或者异步任务执行时,最原始的做法是Thread.sleep(2000),然后断言。这种超时等待有两个问题:机器卡的时候2秒不够,跑快的时候白白等2秒。Awaitility解决的就是这种“轮询等待条件成立”的问题。

@Test void asyncTaskShouldUpdateDatabaseEventually() { asyncTaskService.processAsync(1L); await().atMost(5, TimeUnit.SECONDS) .untilAsserted(() -> assertThat(orderRepository.findById(1L).get().getStatus()) .isEqualTo("COMPLETED") ); }

Awaitility默认每隔100ms检查一次条件,直到超时或条件成立。它比固定Thread.sleep高效得多,而且断言失败时的报错信息会附带当前的中间状态,排查问题方便不少。

6.3 测试方法执行顺序和并行化

JUnit5默认不保证测试方法的执行顺序。如果你不小心写了“测试A依赖测试B先执行”的代码,那就是埋雷。我建议每个测试方法都要独立、可重复执行、不依赖顺序。

如果你确实需要顺序,可以加@TestMethodOrder(MethodOrderer.OrderAnnotation.class),再给方法标记@Order(1)@Order(2)

并行化方面,JUnit5支持在junit-platform.properties里配置junit.jupiter.execution.parallel.enabled=true,但Spring的ApplicationContext是全局共享的,并行跑Spring集成测试时要小心上下文并发初始化的问题。我实测下来,切片测试并行效果好,@SpringBootTest并行偶尔会因为动态端口或共享容器导致竞争,稳妥起见还是保持串行。

6.4 利用@Sql注解管理测试数据

要不要在测试方法里手写userRepository.save(...)来构造数据?当数据量少的时候可以,一旦涉及外键关联或者多个表,手写插入代码会变得非常啰嗦。Spring提供了@Sql注解,可以直接在测试方法或测试类上执行SQL脚本:

@Test @Sql(scripts = "/sql/user-init.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @Sql(scripts = "/sql/user-cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) void shouldFindUserByEmail() { // 被测代码... }

executionPhase决定了SQL在测试方法的哪个阶段执行。BEFORE_TEST_METHOD负责插入数据,AFTER_TEST_METHOD负责清理。最好保证脚本有幂等性,比如插入前先DELETE FROM user WHERE email = 'xxx',防止测试失败重跑时数据冲突。

7. 我踩过的坑和最后的建议

最后分享几个我自己在团队里推行SpringBoot测试时总结出来的经验,纯属手记,不按条理排序。

第一,写测试一定要盯住“启动范围”。我刚带团队时,大家图省事,所有测试类全都@SpringBootTest,结果200多个测试类,CI跑一次要40分钟。后来把纯Service测试改成Mockito、Controller测试改成@WebMvcTest、Repository测试改成@DataJpaTest,整个测试时长缩到了8分钟。所以不要嫌切片测试配置麻烦,这个时间成本省回来的价值远大于前期的一点学习成本。

第二,@MockBean能少用就少用。它虽然方便,但每次使用都会触发上下文缓存失效,导致Spring在测试类之间不断重建ApplicationContext,整个测试套件变慢。新版SpringBoot推荐@MockitoBean或者直接用@TestConfiguration定义@Primary的Mock Bean,效果相同但缓存命中率高很多。尤其在测试类多、上下文重的项目里,这一个改动就能带来显著的提速。

第三,测试代码和业务代码同等重要。我看到过很多项目的测试只是为了应付覆盖率,随便写几个@Test断言结果为非空就完事。这样的测试跑了等于白跑,不如不跑。测试的价值在于替你把边界情况、异常路径、事务行为验证一遍。写测试的时候反问自己:如果上线前这个测试挂了,我能立刻定位到是哪里出了问题吗?如果答案是不能,那这个测试就是在自欺欺人。

第四,把测试环境配置做成一键可切换。我的团队里有一套application-test.yml,里面把Redis、MQ、第三方接口全部Mock掉,只有数据库使用Testcontainers真实拉起。这样本地跑测试、CI跑测试都能用同一套配置,不会出现“本地全过,CI全挂”的尴尬。

SpringBoot的测试体系能做的事情非常多,这篇稿子只覆盖了日常工作里最常用的部分。如果你在跑上面某段代码时遇到奇怪的问题,先看看版本号——SpringBoot 2.7和3.x之间,JUnit5和Mockito的默认版本差异确实会带来一些测试库兼容性的变化,升级之前最好把测试代码也纳入回归范围。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 20:10:55

GPS坐标转换全攻略:WGS84/GCJ-02/BD-09互转与树莓派实战

做GPS相关开发这几年&#xff0c;我最大的感受是&#xff1a;真正让人头疼的往往不是信号差、收不到卫星&#xff0c;而是好不容易拿到一组坐标&#xff0c;放到地图上却对不上位置。明明GPS模块输出的是WGS84经纬度&#xff0c;地图却告诉你偏移了几百米&#xff0c;这种问题在…

作者头像 李华
网站建设 2026/9/10 20:10:53

WebSocket与React实现实时数据图表优化方案

1. 实时数据图表的前端技术选型在金融行情、IoT监控等实时性要求高的场景中&#xff0c;传统轮询&#xff08;Polling&#xff09;方案存在明显短板。以每秒刷新一次的股票行情为例&#xff0c;轮询会产生大量无效请求&#xff08;当数据未变化时&#xff09;&#xff0c;而Web…

作者头像 李华
网站建设 2026/9/10 20:10:51

2026年博主必备:专业耳机选购与罗技Zone VIBE评测

1. 为什么2026年的博主需要专业耳机&#xff1f;在内容创作领域&#xff0c;音频质量的重要性正以惊人的速度提升。根据最新调研数据&#xff0c;到2026年&#xff0c;超过78%的观众会因音频质量问题直接关闭视频&#xff0c;这个数字比2023年增长了近三倍。作为每天需要录制语…

作者头像 李华
网站建设 2026/9/10 20:10:04

跑步技术指南:从新手入门到进阶训练的科学方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 20:09:48

Flutter m3u_nullsafe鸿蒙适配:音轨映射与字幕渲染实战

1. 项目背景与核心挑战在跨平台开发领域&#xff0c;Flutter与鸿蒙系统的结合正成为新的技术热点。m3u_nullsafe作为Flutter生态中处理多媒体流的重要组件&#xff0c;其鸿蒙适配涉及三个关键技术难点&#xff1a;音轨映射机制差异&#xff1a;鸿蒙的媒体框架采用不同于Android…

作者头像 李华
网站建设 2026/9/10 20:08:53

2026年推荐:专业软文营销平台怎么选

在AI重构信息分发的2026年,企业软文营销的逻辑已经彻底改变——不再是“发了就行”,而是“发了之后能不能被AI看见、被AI引用、被AI推荐”。据艾瑞咨询统计,2026年国内软文营销市场规模已突破900亿元,其中来自AI问答渠道的流量占比高达45%。与此同时,国内软文发稿服务商已从202…

作者头像 李华