Skip to content

Spring MockMvcTester 执行请求详解 🚀

概述

在 Spring Boot 测试中,MockMvcTester 是一个强大的工具,它结合了 AssertJ 的流畅断言 API,让我们能够优雅地执行 HTTP 请求并验证响应。相比传统的 MockMvc,它提供了更加直观和易用的测试体验。

NOTE

MockMvcTester 是 Spring Framework 新引入的测试工具,它将 MockMvc 的功能与 AssertJ 的断言能力完美结合,为开发者提供了更加流畅的测试体验。

核心价值与解决的问题 💡

传统测试方式的痛点

在没有 MockMvcTester 之前,我们进行 Web 层测试时面临以下挑战:

  • 语法冗长:需要导入大量静态方法
  • 断言复杂:Hamcrest 匹配器学习成本高
  • 可读性差:测试代码难以理解和维护

MockMvcTester 的解决方案

kotlin
// 传统 MockMvc + Hamcrest 方式
mockMvc.perform(post("/hotels/{id}", 42)
    .accept(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
    .andExpect(jsonPath("$.name").value("Grand Hotel"))
kotlin
// 新的 MockMvcTester + AssertJ 方式
assertThat(mockMvc.post().uri("/hotels/{id}", 42)
    .accept(MediaType.APPLICATION_JSON))
    .hasStatusOk()
    .hasContentType(MediaType.APPLICATION_JSON)
    .bodyJson().extractingPath("$.name").isEqualTo("Grand Hotel")

基础请求执行 📝

简单请求示例

让我们从一个简单的酒店管理系统开始:

kotlin
@RestController
class HotelController {
    
    @PostMapping("/hotels/{id}")
    fun updateHotel(
        @PathVariable id: Long,
        @RequestBody hotel: Hotel
    ): ResponseEntity<Hotel> {
        // 业务逻辑处理
        val updatedHotel = hotelService.update(id, hotel)
        return ResponseEntity.ok(updatedHotel)
    }
}

使用 MockMvcTester 测试这个接口:

kotlin
@Test
fun `should update hotel successfully`() {
    // 执行 POST 请求并验证响应
    assertThat(mockMvc.post().uri("/hotels/{id}", 42)
        .accept(MediaType.APPLICATION_JSON))
        .hasStatusOk()
        .hasContentType(MediaType.APPLICATION_JSON)
}

TIP

注意这里不需要导入任何静态方法,所有的操作都通过 mockMvc 实例的流畅 API 完成。

分离式断言

当需要对响应进行多个方面的验证时,可以使用 .exchange() 方法获取结果对象:

kotlin
@Test
fun `should validate multiple aspects of response`() {
    // 执行请求并获取结果
    val result = mockMvc.post().uri("/hotels/{id}", 42)
        .accept(MediaType.APPLICATION_JSON)
        .exchange() 
    
    // 分别验证不同方面
    assertThat(result)
        .hasStatusOk()
        .hasContentType(MediaType.APPLICATION_JSON)
    
    assertThat(result)
        .bodyJson()
        .extractingPath("$.id").isEqualTo(42)
        
    assertThat(result)
        .bodyJson()
        .extractingPath("$.name").isNotNull()
}

参数传递方式 🔧

URI 模板参数

MockMvcTester 支持多种参数传递方式,让我们看看查询参数的处理:

kotlin
@GetMapping("/hotels")
fun searchHotels(
    @RequestParam location: String,
    @RequestParam(required = false) rating: Int?
): List<Hotel> {
    return hotelService.search(location, rating)
}

方式一:URI 模板风格

kotlin
@Test
fun `should search hotels with query parameters using URI template`() {
    assertThat(mockMvc.get().uri("/hotels?location={location}&rating={rating}", 
        "Beijing", 5)) 
        .hasStatusOk()
}

方式二:param() 方法

kotlin
@Test
fun `should search hotels with query parameters using param method`() {
    assertThat(mockMvc.get().uri("/hotels")
        .param("location", "Beijing") 
        .param("rating", "5")) 
        .hasStatusOk()
}

IMPORTANT

两种方式的重要区别:

  • URI 模板中的参数会被自动 URL 编码
  • param() 方法的参数需要预先编码

实际业务场景对比

kotlin
@Test
fun `should handle special characters in location`() {
    val location = "São Paulo" // 包含特殊字符
    
    // URI 模板方式 - 自动编码 ✅
    assertThat(mockMvc.get().uri("/hotels?location={location}", location))
        .hasStatusOk()
    
    // param 方式 - 需要手动处理编码
    assertThat(mockMvc.get().uri("/hotels")
        .param("location", URLEncoder.encode(location, "UTF-8")))
        .hasStatusOk()
}
kotlin
@Test
fun `should search with simple parameters`() {
    // 对于简单参数,两种方式都很方便
    assertThat(mockMvc.get().uri("/hotels")
        .param("location", "Beijing")
        .param("rating", "5"))
        .hasStatusOk()
}

异步请求处理 ⏰

现代 Web 应用经常使用异步处理来提高性能,MockMvcTester 对此提供了完善的支持。

异步控制器示例

kotlin
@RestController
class ComputeController {
    
    @GetMapping("/compute")
    fun performHeavyComputation(): DeferredResult<String> {
        val deferredResult = DeferredResult<String>(10000L) // 10秒超时
        
        // 异步处理
        CompletableFuture.supplyAsync {
            Thread.sleep(2000) // 模拟耗时操作
            "Computation completed"
        }.whenComplete { result, throwable ->
            if (throwable != null) {
                deferredResult.setErrorResult(throwable)
            } else {
                deferredResult.setResult(result) 
            }
        }
        
        return deferredResult
    }
}

异步请求测试

kotlin
@Test
fun `should wait for async request completion`() {
    // 默认等待 10 秒
    assertThat(mockMvc.get().uri("/compute").exchange()) 
        .hasStatusOk()
        .bodyText().isEqualTo("Computation completed")
}

@Test
fun `should wait for async request with custom timeout`() {
    // 自定义超时时间为 5 秒
    assertThat(mockMvc.get().uri("/compute")
        .exchange(Duration.ofSeconds(5))) 
        .hasStatusOk()
}

手动管理异步生命周期

如果需要更精细的控制,可以使用 asyncExchange()

kotlin
@Test
fun `should manage async lifecycle manually`() {
    val result = mockMvc.get().uri("/compute")
        .asyncExchange() 
    
    // 可以在这里执行其他操作
    // ...
    
    // 手动等待完成
    assertThat(result.await())
        .hasStatusOk()
}

WARNING

使用 asyncExchange() 时,需要手动调用 await() 方法等待异步操作完成,否则可能获取到不完整的结果。

文件上传处理 📁

文件上传是 Web 应用的常见功能,MockMvcTester 提供了简洁的多部分请求支持。

文件上传控制器

kotlin
@RestController
class FileUploadController {
    
    @PostMapping("/upload")
    fun uploadFiles(
        @RequestParam("files") files: Array<MultipartFile>,
        @RequestParam("description") description: String
    ): ResponseEntity<UploadResult> {
        
        val uploadedFiles = files.map { file ->
            FileInfo(
                name = file.originalFilename ?: "unknown",
                size = file.size,
                contentType = file.contentType ?: "application/octet-stream"
            )
        }
        
        return ResponseEntity.ok(
            UploadResult(
                description = description,
                files = uploadedFiles,
                totalSize = uploadedFiles.sumOf { it.size }
            )
        )
    }
}

data class FileInfo(
    val name: String,
    val size: Long,
    val contentType: String
)

data class UploadResult(
    val description: String,
    val files: List<FileInfo>,
    val totalSize: Long
)

多文件上传测试

kotlin
@Test
fun `should upload multiple files successfully`() {
    val file1Content = "Hello World".toByteArray(StandardCharsets.UTF_8)
    val file2Content = "Spring Boot".toByteArray(StandardCharsets.UTF_8)
    
    assertThat(mockMvc.post().uri("/upload")
        .multipart() 
        .file("files", "hello.txt", file1Content) 
        .file("files", "spring.txt", file2Content) 
        .param("description", "Test upload")) 
        .hasStatusOk()
        .bodyJson()
        .extractingPath("$.totalSize")
        .isEqualTo(file1Content.size + file2Content.size)
}

复杂文件上传场景

kotlin
@Test
fun `should upload files with metadata`() {
    val jsonMetadata = """
        {
            "category": "documents",
            "tags": ["important", "confidential"]
        }
    """.trimIndent()
    
    assertThat(mockMvc.post().uri("/upload")
        .multipart()
        .file("files", "document.pdf", "PDF content".toByteArray())
        .file("metadata", "metadata.json", 
              jsonMetadata.toByteArray(StandardCharsets.UTF_8))
        .param("description", "Document with metadata"))
        .hasStatusOk()
}

NOTE

MockMvcTester 的 multipart 支持内部使用 MockMultipartHttpServletRequest,这意味着不会进行真正的多部分解析,而是直接设置请求数据。

上下文路径和 Servlet 路径 🛣️

在实际部署环境中,应用可能运行在特定的上下文路径下,MockMvcTester 提供了完整的支持。

场景说明

单次请求指定路径

kotlin
@Test
fun `should handle context and servlet paths`() {
    assertThat(mockMvc.get()
        .uri("/app/api/hotels/{id}", 42) 
        .contextPath("/app") 
        .servletPath("/api")) 
        .hasStatusOk()
}

全局默认路径配置

当所有请求都需要相同的路径配置时,可以设置默认值:

kotlin
class HotelControllerTest {
    
    private val mockMvc = MockMvcTester.of(listOf(HotelController())) { builder ->
        builder.defaultRequest(
            MockMvcRequestBuilders.get("/")
                .contextPath("/app") 
                .servletPath("/api") 
                .accept(MediaType.APPLICATION_JSON)
        ).build()
    }
    
    @Test
    fun `should use default paths`() {
        // 不需要重复指定 contextPath 和 servletPath
        assertThat(mockMvc.get().uri("/hotels/{id}", 42))
            .hasStatusOk()
    }
    
    @Test
    fun `should override default paths when needed`() {
        // 可以覆盖默认设置
        assertThat(mockMvc.get().uri("/different/path/hotels/{id}", 42)
            .contextPath("/different")) 
            .hasStatusOk()
    }
}

最佳实践与建议 ✨

1. 选择合适的断言方式

建议

  • 单一验证:直接使用 assertThat(mockMvc.get()...)
  • 多重验证:使用 .exchange() 获取结果对象
  • 异步操作:根据需要选择 exchange()asyncExchange()

2. 参数传递策略

kotlin
// ✅ 推荐:简单参数使用 param()
assertThat(mockMvc.get().uri("/search")
    .param("keyword", "hotel")
    .param("page", "1"))

// ✅ 推荐:复杂参数使用 URI 模板
assertThat(mockMvc.get().uri("/search?q={query}&location={loc}", 
    "luxury hotel", "New York"))

3. 测试组织结构

kotlin
@WebMvcTest(HotelController::class)
class HotelControllerTest {
    
    @Autowired
    private lateinit var mockMvc: MockMvcTester
    
    @MockBean
    private lateinit var hotelService: HotelService
    
    @Nested
    inner class GetHotelTests {
        // GET 相关测试
    }
    
    @Nested
    inner class CreateHotelTests {
        // POST 相关测试
    }
    
    @Nested
    inner class FileUploadTests {
        // 文件上传测试
    }
}

总结 🎯

MockMvcTester 通过以下方式革新了 Spring Boot 的 Web 层测试:

  1. 流畅的 API:无需导入静态方法,代码更加清晰
  2. 强大的断言:结合 AssertJ 提供丰富的验证能力
  3. 异步支持:完善的异步请求处理机制
  4. 灵活配置:支持各种请求参数和路径配置

IMPORTANT

MockMvcTester 不仅仅是一个测试工具,它代表了 Spring 团队对开发者体验的持续改进。通过提供更直观、更强大的测试 API,它让我们能够编写更好的测试,从而构建更可靠的应用程序。

通过掌握这些技巧,你将能够编写出既简洁又全面的 Web 层测试,确保你的 Spring Boot 应用在各种场景下都能正常工作! 🚀