本文探讨了spring Boot集成测试中,当@Transactional注解与mockMvc结合使用时,可能出现的事务隔离问题。核心问题在于测试方法内部的实体修改可能在mockMvc请求的独立事务中不可见,导致意外的数据查询结果。文章提供了使用TransactionTemplate进行显式事务管理作为解决方案,确保测试前置操作的数据库更改能够及时提交并被后续请求感知。
1. 问题背景:集成测试中的事务隔离挑战
在spring boot的集成测试中,我们经常使用@transactional注解来确保每个测试方法都在一个独立的事务中运行,并在测试结束后自动回滚所有数据库操作,从而保持测试环境的清洁。然而,当测试流程涉及到mockmvc发起http请求时,这种默认的事务行为有时会引发预期之外的问题。
考虑一个典型的场景:
- 集成测试方法修改了一个实体(例如,更新用户的唯一名称)。
- 测试方法调用userRepository.saveAndFlush()保存更改。
- 随后,mockMvc发起一个HTTP请求,该请求会触发一个安全过滤器,并在过滤器中尝试根据旧的唯一名称查询用户。
我们期望的是,由于用户名称已被修改,根据旧名称的查询应该返回空。但实际观察到的现象是,查询竟然成功找到了用户,并且返回的实体是已经更新过新名称的。这表明mockMvc请求内部的数据库查询,似乎看到了一个“旧数据”的视图,或者更准确地说,它看到了当前事务(测试方法)中尚未提交的更改,但又以一种混淆的方式呈现。
2. 问题根源:@Transactional与mockMvc的事务边界
这个问题的核心在于事务的隔离性以及mockMvc请求的执行上下文。
- @Transactional在测试方法上: 当一个测试方法被@Transactional注解时,Spring会为该方法创建一个事务。所有在该方法内部对数据库的操作(包括userRepository.saveAndFlush())都会在这个事务中进行。saveAndFlush()会确保更改被刷新到数据库会话中,但这些更改在事务提交之前对其他事务是不可见的(或者根据隔离级别可能部分可见)。在测试结束时,这个事务通常会被回滚。
- mockMvc请求的执行: mockMvc发起的HTTP请求通常会在一个独立的线程中执行其内部逻辑,包括调用控制器、服务层以及安全过滤器。如果这个内部逻辑也涉及到数据库操作(例如,安全过滤器中的userRepository.findUserByUniqueName),那么这些操作可能会在一个与测试方法主事务不同的新事务中执行。
当mockMvc请求在一个新事务中执行时,它将无法看到主测试方法事务中尚未提交的更改。因此,当安全过滤器尝试使用oldUniqueName查询时,它查询的是数据库中已提交的数据。如果主测试方法在mockMvc调用前没有提交其更改,那么数据库中仍然是oldUniqueName对应的记录(或者根本没有newUniqueName对应的已提交记录),这就会导致查询行为与预期不符。
为什么会看到“新名称”的实体? 这可能是因为在某些特定的事务隔离级别下,或者当mockMvc请求的事务与主测试事务共享了某个持久化上下文(如hibernate Session)时,导致了这种混淆。但更常见且更可靠的解释是,mockMvc请求的事务未能看到主事务中未提交的更改。而实际观察到查询结果是“新名称”的实体,则暗示了某种更复杂的持久化上下文同步或缓存行为,使得旧名称的查询最终映射到了新名称的实体,这通常是由于Hibernate一级缓存或二级缓存与事务边界的交互导致的。
3. 解决方案:使用TransactionTemplate显式管理事务
为了解决这个问题,我们需要确保在mockMvc请求执行之前,主测试方法中对数据库的更改能够被提交,从而对所有后续的独立事务可见。实现这一目标的方法是移除测试方法上的@Transactional注解(以避免整个测试方法事务回滚),并使用TransactionTemplate来手动管理需要提交的数据库操作。
TransactionTemplate是Spring提供的一种编程式事务管理方式,它允许我们定义一个事务的边界,并在其中执行数据库操作,然后明确地提交或回滚该事务。
修改后的测试代码示例:
首先,确保你的测试类中注入了PlatformTransactionManager,它是TransactionTemplate的构造函数参数。
import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.springbootTest; import org.springframework.http.HttpHeaders; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc // 移除 @Transactional 注解,以便我们可以手动控制事务提交 class UserIntegrationTest { @Autowired private UserRepository userRepository; @Autowired private MockMvc mockMvc; @Autowired private PlatformTransactionManager transactionManager; // 注入事务管理器 @Test void testSecurityFilterWithChangedUser() throws Exception { // 创建 TransactionTemplate 实例 TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); final String oldUniqueName = "oldUniqueName"; final String newUniqueName = "newUniqueName"; final String endpointUrl = "/api/secure-endpoint"; // 假设的受保护接口 // 1. 初始化一个用户并保存,确保其在数据库中存在 transactionTemplate.execute(status -> { User initialUser = new User(); initialUser.setUniqueName(oldUniqueName); // 假设User实体有其他必要的字段,这里省略 userRepository.save(initialUser); return null; }); // 2. 在一个独立的事务中修改用户并提交 transactionTemplate.execute(status -> { User user = userRepository.findUserByUniqueName(oldUniqueName) .orElseThrow(() -> new IllegalStateException("User not found after initial save.")); assertThat(user).isNotNull(); user.setUniqueName(newUniqueName); userRepository.saveAndFlush(user); // saveAndFlush 将更改同步到数据库 // TransactionTemplate 会在 execute 方法返回后自动提交事务 return null; }); // 此时,数据库中已提交的用户记录的 uniqueName 应该是 "newUniqueName" // 根据 oldUniqueName 查询应该返回 Optional.empty() // 3. 构建 mockMvc 请求,使用旧的 uniqueName HttpHeaders headers = new HttpHeaders(); headers.add("X-Unique-Name", oldUniqueName); // 假设安全过滤器从这个Header获取 // 4. 执行 mockMvc 请求,期望由于 oldUniqueName 不存在而导致未授权 mockMvc.perform(get(endpointUrl).headers(headers)) .andExpect(status().isUnauthorized()); // 期望未授权状态 // 5. (可选) 验证数据库状态,确保测试没有留下脏数据 // 可以再次使用 TransactionTemplate 清理或在 @AfterEach 中处理 transactionTemplate.execute(status -> { userRepository.findUserByUniqueName(newUniqueName) .ifPresent(userRepository::delete); // 清理测试数据 return null; }); } }
安全过滤器示例(保持不变):
@Override @Transactional // 过滤器内部通常也需要事务 protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String uniqueNameFromHeader = extractUniqueNameFromRequest(request); try { // 这里会查询数据库中已提交的数据 User user = userRepository.findUserByUniqueName(uniqueNameFromHeader) .orElseThrow(() -> new Exception("User not found for header unique name")); // update security context filterChain.doFilter(request, response); // 继续请求链 } catch(Exception e) { // handle exception (e.g., set HTTP status to UNAUTHORIZED) response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } }
4. 关键点与注意事项
- 事务隔离级别: 这个问题与数据库的事务隔离级别也有关。在大多数情况下,默认的隔离级别(如READ_COMMITTED)意味着一个事务只能看到其他事务已提交的更改。
- 测试数据管理: 使用TransactionTemplate提交更改后,这些更改会持久化到数据库。在测试结束后,你需要确保这些测试数据被清理,以避免影响其他测试。可以在@AfterEach方法中使用TransactionTemplate来删除测试中创建或修改的数据。
- 何时使用@Transactional: 对于那些不涉及mockMvc或不需要在测试中间提交数据的简单数据库操作测试,@Transactional仍然是方便且推荐的。它提供了自动回滚的便利性。
- 理解Spring Test的事务行为: Spring测试框架默认会在测试方法结束后回滚由@Transactional管理的事务。当我们需要在测试中间强制提交事务时,就必须放弃这种默认行为,转而使用编程式事务管理。
- saveAndFlush()与事务: saveAndFlush()会强制将当前持久化上下文中的更改同步到数据库,但这些更改仍属于当前事务,对其他事务的可见性取决于事务的提交和隔离级别。
5. 总结
在Spring Boot集成测试中,当@Transactional注解与mockMvc结合使用时,可能会遇到事务隔离导致的数据可见性问题。mockMvc请求通常会在一个独立于主测试方法的事务中执行,因此无法看到主事务中尚未提交的数据库更改。通过移除测试方法上的@Transactional注解,并使用TransactionTemplate来显式地管理和提交测试前置的数据库操作,可以确保在mockMvc请求发起时,数据库状态已经更新并对所有新事务可见,从而解决数据不一致的问题,使测试行为符合预期。理解事务边界和隔离级别对于编写健壮的集成测试至关重要。
评论(已关闭)
评论已关闭