Skip to content

MockMvcTester 配置指南 ⚙️

概述 💡

在 Spring Boot 的测试世界中,MockMvcTester 是一个强大的测试工具,它结合了 MockMvc 的功能和 AssertJ 的流畅断言风格。想象一下,如果我们要测试一个 Web 控制器,传统的方式可能需要启动整个应用服务器,这不仅慢而且复杂。而 MockMvcTester 就像是给我们提供了一个"虚拟的 Web 环境",让我们可以在不启动真实服务器的情况下,快速、准确地测试我们的控制器逻辑。

NOTE

MockMvcTester 是 Spring Framework 6.0+ 引入的新特性,它将 MockMvc 的强大功能与 AssertJ 的优雅断言语法完美结合。

为什么需要 MockMvcTester? 🤔

传统测试的痛点

在没有 MockMvcTester 之前,我们测试 Web 控制器时面临以下挑战:

  1. 启动成本高:需要启动完整的 Web 服务器
  2. 测试速度慢:网络调用和服务器启动时间
  3. 断言复杂:需要手动解析 HTTP 响应
  4. 环境依赖:依赖外部服务和数据库

MockMvcTester 的解决方案

MockMvcTester 通过以下方式解决了这些问题:

  • 模拟 Web 环境:无需启动真实服务器
  • 快速执行:内存中执行,速度极快
  • 流畅断言:结合 AssertJ,断言更加直观
  • 隔离测试:不依赖外部环境

配置 MockMvcTester 的两种方式 🔧

MockMvcTester 提供了两种主要的配置方式,每种都有其特定的使用场景:

方式一:独立模式(Standalone Mode)

这种方式直接指定要测试的控制器,并手动配置 Spring MVC 基础设施。

TIP

独立模式适用于单元测试,当你只想测试特定控制器的逻辑,而不需要完整的 Spring 上下文时。

kotlin
class AccountControllerStandaloneTests {
    
    // 直接创建控制器实例并配置 MockMvcTester
    private val mockMvc = MockMvcTester.of(AccountController()) 
    
    @Test
    fun `should create account successfully`() {
        // 测试逻辑
        val result = mockMvc.post("/accounts") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"name": "张三", "balance": 1000.0}"""
        }
        
        result.assertThat()
            .hasStatus(HttpStatus.CREATED)
            .hasContentType(MediaType.APPLICATION_JSON)
    }
}
java
public class AccountControllerStandaloneTests {
    
    // 直接创建控制器实例并配置 MockMvcTester
    private final MockMvcTester mockMvc = MockMvcTester.of(new AccountController()); 
    
    @Test
    public void shouldCreateAccountSuccessfully() {
        // 测试逻辑
        var result = mockMvc.post("/accounts")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""{"name": "张三", "balance": 1000.0}""");
            
        result.assertThat()
            .hasStatus(HttpStatus.CREATED)
            .hasContentType(MediaType.APPLICATION_JSON);
    }
}

独立模式的特点:

  • ✅ 测试速度快
  • ✅ 隔离性好
  • ✅ 适合单元测试
  • ❌ 需要手动配置依赖
  • ❌ 无法测试完整的 Spring 集成

方式二:集成模式(Integration Mode)

这种方式通过 Spring 配置来设置 MockMvcTester,包含完整的 Spring MVC 和控制器基础设施。

TIP

集成模式适用于集成测试,当你需要测试控制器与其他 Spring 组件的交互时。

kotlin
@SpringJUnitWebConfig(ApplicationWebConfiguration::class)
class AccountControllerIntegrationTests(@Autowired wac: WebApplicationContext) {
    
    // 从 Spring 上下文创建 MockMvcTester
    private val mockMvc = MockMvcTester.from(wac) 
    
    @Test
    fun `should handle account creation with validation`() {
        val result = mockMvc.post("/accounts") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"name": "", "balance": -100.0}""" // 无效数据
        }
        
        result.assertThat()
            .hasStatus(HttpStatus.BAD_REQUEST)
            .bodyJson().extractingPath("$.errors").isNotNull()
    }
}
java
@SpringJUnitWebConfig(ApplicationWebConfiguration.class)
class AccountControllerIntegrationTests {
    
    private final MockMvcTester mockMvc;
    
    // 构造函数注入 WebApplicationContext
    AccountControllerIntegrationTests(@Autowired WebApplicationContext wac) {
        this.mockMvc = MockMvcTester.from(wac); 
    }
    
    @Test
    public void shouldHandleAccountCreationWithValidation() {
        var result = mockMvc.post("/accounts")
            .contentType(MediaType.APPLICATION_JSON)
            .content("""{"name": "", "balance": -100.0}"""); // 无效数据
            
        result.assertThat()
            .hasStatus(HttpStatus.BAD_REQUEST)
            .bodyJson().extractingPath("$.errors").isNotNull();
    }
}

集成模式的特点:

  • ✅ 完整的 Spring 上下文
  • ✅ 真实的依赖注入
  • ✅ 适合集成测试
  • ❌ 启动时间较长
  • ❌ 依赖外部配置

配置 JSON 消息转换器 🔄

MockMvcTester 的一个强大特性是能够自动将 JSON 响应体转换为你的领域对象,前提是注册了相关的 HttpMessageConverter

IMPORTANT

如果你的应用使用 Jackson 处理 JSON 序列化,你需要显式注册消息转换器以启用自动转换功能。

kotlin
@SpringJUnitWebConfig(ApplicationWebConfiguration::class)
class AccountControllerIntegrationTests(@Autowired wac: WebApplicationContext) {
    
    private val mockMvc = MockMvcTester.from(wac)
        .withHttpMessageConverters( 
            listOf(wac.getBean(AbstractJackson2HttpMessageConverter::class.java))
        )
    
    @Test
    fun `should return account as domain object`() {
        val result = mockMvc.get("/accounts/1")
        
        // 直接转换为领域对象
        val account: Account = result.assertThat()
            .hasStatus(HttpStatus.OK)
            .bodyJson()
            .convertTo(Account::class.java) 
            
        assertThat(account.name).isEqualTo("张三")
        assertThat(account.balance).isEqualTo(1000.0)
    }
}
java
@SpringJUnitWebConfig(ApplicationWebConfiguration.class)
class AccountControllerIntegrationTests {
    
    private final MockMvcTester mockMvc;
    
    AccountControllerIntegrationTests(@Autowired WebApplicationContext wac) {
        this.mockMvc = MockMvcTester.from(wac)
            .withHttpMessageConverters( 
                List.of(wac.getBean(AbstractJackson2HttpMessageConverter.class)));
    }
    
    @Test
    public void shouldReturnAccountAsDomainObject() {
        var result = mockMvc.get("/accounts/1");
        
        // 直接转换为领域对象
        Account account = result.assertThat()
            .hasStatus(HttpStatus.OK)
            .bodyJson()
            .convertTo(Account.class); 
            
        assertThat(account.getName()).isEqualTo("张三");
        assertThat(account.getBalance()).isEqualTo(1000.0);
    }
}

消息转换器的工作原理

从现有 MockMvc 创建 MockMvcTester ♻️

如果你已经有一个配置好的 MockMvc 实例,可以直接基于它创建 MockMvcTester

kotlin
class ExistingMockMvcTests {
    
    @Autowired
    private lateinit var existingMockMvc: MockMvc
    
    private val mockMvcTester by lazy {
        MockMvcTester.create(existingMockMvc) 
    }
    
    @Test
    fun `should work with existing MockMvc`() {
        val result = mockMvcTester.get("/health")
        
        result.assertThat()
            .hasStatus(HttpStatus.OK)
            .bodyText()
            .isEqualTo("UP")
    }
}

实际业务场景示例 💼

让我们看一个完整的账户管理系统的测试示例:

完整的账户控制器测试示例
kotlin
// 账户领域对象
data class Account(
    val id: Long? = null,
    val name: String,
    val balance: Double,
    val createdAt: LocalDateTime = LocalDateTime.now()
)

// 账户控制器
@RestController
@RequestMapping("/api/accounts")
class AccountController(private val accountService: AccountService) {
    
    @PostMapping
    fun createAccount(@Valid @RequestBody request: CreateAccountRequest): ResponseEntity<Account> {
        val account = accountService.createAccount(request.name, request.balance)
        return ResponseEntity.status(HttpStatus.CREATED).body(account)
    }
    
    @GetMapping("/{id}")
    fun getAccount(@PathVariable id: Long): ResponseEntity<Account> {
        val account = accountService.findById(id)
            ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(account)
    }
    
    @PostMapping("/{id}/transfer")
    fun transfer(
        @PathVariable id: Long,
        @RequestBody request: TransferRequest
    ): ResponseEntity<Account> {
        return try {
            val account = accountService.transfer(id, request.toAccountId, request.amount)
            ResponseEntity.ok(account)
        } catch (e: InsufficientFundsException) {
            ResponseEntity.badRequest().build()
        }
    }
}

// 测试类
@SpringJUnitWebConfig(TestConfiguration::class)
class AccountControllerIntegrationTests(@Autowired wac: WebApplicationContext) {
    
    private val mockMvc = MockMvcTester.from(wac)
        .withHttpMessageConverters(
            listOf(wac.getBean(AbstractJackson2HttpMessageConverter::class.java))
        )
    
    @MockBean
    private lateinit var accountService: AccountService
    
    @Test
    fun `should create account successfully`() {
        // Given
        val expectedAccount = Account(1L, "张三", 1000.0)
        given(accountService.createAccount("张三", 1000.0))
            .willReturn(expectedAccount)
        
        // When & Then
        val result = mockMvc.post("/api/accounts") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"name": "张三", "balance": 1000.0}"""
        }
        
        val createdAccount: Account = result.assertThat()
            .hasStatus(HttpStatus.CREATED)
            .hasContentType(MediaType.APPLICATION_JSON)
            .bodyJson()
            .convertTo(Account::class.java)
            
        assertThat(createdAccount.name).isEqualTo("张三")
        assertThat(createdAccount.balance).isEqualTo(1000.0)
    }
    
    @Test
    fun `should return 404 when account not found`() {
        // Given
        given(accountService.findById(999L)).willReturn(null)
        
        // When & Then
        mockMvc.get("/api/accounts/999")
            .assertThat()
            .hasStatus(HttpStatus.NOT_FOUND)
    }
    
    @Test
    fun `should handle insufficient funds during transfer`() {
        // Given
        given(accountService.transfer(1L, 2L, 2000.0))
            .willThrow(InsufficientFundsException("余额不足"))
        
        // When & Then
        mockMvc.post("/api/accounts/1/transfer") {
            contentType = MediaType.APPLICATION_JSON
            content = """{"toAccountId": 2, "amount": 2000.0}"""
        }.assertThat()
            .hasStatus(HttpStatus.BAD_REQUEST)
    }
}

配置模式对比 ⚖️

特性独立模式集成模式
启动速度🚀 极快⏳ 较慢
隔离性✅ 完全隔离❌ 依赖上下文
配置复杂度🔧 需手动配置✅ 自动配置
测试范围🎯 单个控制器🌐 完整集成
适用场景单元测试集成测试
依赖管理需要 MockSpring 管理

最佳实践建议 ⭐

选择合适的配置模式

  • 单元测试:使用独立模式,快速验证控制器逻辑
  • 集成测试:使用集成模式,测试完整的请求处理流程
  • 性能测试:优先考虑独立模式,减少测试执行时间

注意事项

  • 确保消息转换器正确注册,否则 JSON 转换可能失败
  • 在集成模式下,注意 Spring 上下文的启动时间
  • 合理使用 @MockBean 来隔离外部依赖

关键要点

MockMvcTester 不仅仅是一个测试工具,它代表了现代 Spring 测试的最佳实践:快速、可靠、易于维护。通过合理选择配置模式,你可以构建出既高效又全面的测试套件。

总结 🎉

MockMvcTester 为 Spring Boot Web 应用的测试带来了革命性的改进。它不仅解决了传统测试的性能问题,还通过 AssertJ 的流畅 API 让测试代码更加优雅和可读。

无论你选择独立模式还是集成模式,MockMvcTester 都能帮你构建出高质量的测试,确保你的 Web 应用在各种场景下都能正常工作。记住,好的测试不仅能发现 bug,更能提升代码的设计质量和可维护性! 🎯