Skip to content

MockMvc 与 HtmlUnit 集成:让 Web 测试如虎添翼 🚀

前言:为什么需要 MockMvc 与 HtmlUnit 的集成?

在 Spring Boot 的 Web 应用测试中,我们经常面临这样的困境:

  • MockMvc 虽然能够测试 Controller 层,但对于复杂的前端交互(如表单提交、JavaScript 执行)显得力不从心
  • 传统的集成测试 需要启动完整的 Web 服务器,测试速度慢,资源消耗大
  • 前后端交互测试 往往需要手动构造复杂的 HTTP 请求,维护成本高

IMPORTANT

MockMvc 与 HtmlUnit 的集成为我们提供了一个完美的解决方案:既保持了 MockMvc 的轻量级特性,又获得了 HtmlUnit 强大的 HTML 解析和 JavaScript 执行能力。

核心原理:两个世界的完美融合

TIP

这种集成的核心思想是:让 HtmlUnit 以为它在与真实的 Web 服务器通信,实际上所有的 localhost 请求都被悄悄地重定向到了 MockMvc

环境准备与基础配置

1. 添加依赖

首先,确保项目中包含了 HtmlUnit 的测试依赖:

kotlin
// build.gradle.kts
testImplementation("org.htmlunit:htmlunit")
testImplementation("org.springframework.boot:spring-boot-starter-test")

2. 基础配置示例

kotlin
@SpringBootTest
@AutoConfigureTestDatabase
class MessageControllerHtmlUnitTest {

    @Autowired
    private lateinit var context: WebApplicationContext
    
    private lateinit var webClient: WebClient

    @BeforeEach
    fun setup() {
        // 创建集成了 MockMvc 的 HtmlUnit WebClient
        webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context) 
            .build()
    }
}
kotlin
@SpringBootTest
@AutoConfigureTestDatabase
class AdvancedMessageControllerTest {

    @Autowired
    private lateinit var context: WebApplicationContext
    
    private lateinit var webClient: WebClient

    @BeforeEach
    fun setup() {
        webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context)
            .contextPath("/api") // 设置上下文路径
            .useMockMvcForHosts("example.com", "api.test.com") // 指定使用 MockMvc 的域名
            .build()
    }
}

NOTE

MockMvcWebClientBuilder.webAppContextSetup(context) 是集成的关键,它告诉 HtmlUnit 如何将请求路由到我们的 Spring 应用上下文中。

实战案例:消息管理系统测试

让我们通过一个完整的消息管理系统来演示 MockMvc 与 HtmlUnit 集成的强大功能。

1. 被测试的 Controller

kotlin
@Controller
@RequestMapping("/messages")
class MessageController {

    private val messages = mutableMapOf<Long, Message>()
    private var nextId = 1L

    @GetMapping("/form")
    fun showCreateForm(model: Model): String {
        model.addAttribute("message", Message())
        return "message-form" // 返回表单页面
    }

    @PostMapping
    fun createMessage(@ModelAttribute message: Message): String {
        message.id = nextId++
        messages[message.id!!] = message
        return "redirect:/messages/${message.id}"
    }

    @GetMapping("/{id}")
    fun showMessage(@PathVariable id: Long, model: Model): String {
        val message = messages[id] ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
        model.addAttribute("message", message)
        return "message-detail"
    }
}

data class Message(
    var id: Long? = null,
    var summary: String = "",
    var text: String = ""
)

2. 对应的 HTML 模板

message-form.html 模板内容
html
<!DOCTYPE html>
<html>
<head>
    <title>Create Message</title>
</head>
<body>
    <form id="messageForm" th:action="@{/messages}" method="post">
        <div>
            <label for="summary">Summary:</label>
            <input type="text" id="summary" name="summary" required/>
        </div>
        <div>
            <label for="text">Message:</label>
            <textarea id="text" name="text" required></textarea>
        </div>
        <input type="submit" value="Create Message"/>
    </form>
</body>
</html>

3. 完整的测试用例

kotlin
@SpringBootTest
@AutoConfigureTestDatabase
class MessageControllerHtmlUnitTest {

    @Autowired
    private lateinit var context: WebApplicationContext
    
    private lateinit var webClient: WebClient

    @BeforeEach
    fun setup() {
        webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context)
            .build()
    }

    @Test
    fun `应该能够创建新消息并验证结果`() {
        // 第一步:获取创建消息的表单页面
        val createFormPage: HtmlPage = webClient.getPage("http://localhost/messages/form") 
        
        // 第二步:填写表单数据
        val form: HtmlForm = createFormPage.getHtmlElementById("messageForm")
        val summaryInput: HtmlTextInput = createFormPage.getHtmlElementById("summary")
        summaryInput.valueAttribute = "Spring Boot 测试"
        
        val textInput: HtmlTextArea = createFormPage.getHtmlElementById("text")
        textInput.text = "MockMvc 与 HtmlUnit 集成让测试变得更加简单!"

        // 第三步:提交表单
        val submitButton: HtmlSubmitInput = form.getOneHtmlElementByAttribute("input", "type", "submit")
        val resultPage: HtmlPage = submitButton.click() 

        // 第四步:验证结果
        assertThat(resultPage.url.toString()).endsWith("/messages/1") 
        
        val displayedId = resultPage.getHtmlElementById("id").textContent
        assertThat(displayedId).isEqualTo("1")
        
        val displayedSummary = resultPage.getHtmlElementById("summary").textContent
        assertThat(displayedSummary).isEqualTo("Spring Boot 测试")
        
        val displayedText = resultPage.getHtmlElementById("text").textContent
        assertThat(displayedText).isEqualTo("MockMvc 与 HtmlUnit 集成让测试变得更加简单!")
    }

    @Test
    fun `应该能够处理表单验证错误`() {
        val createFormPage: HtmlPage = webClient.getPage("http://localhost/messages/form")
        
        val form: HtmlForm = createFormPage.getHtmlElementById("messageForm")
        // 故意不填写必填字段
        val submitButton: HtmlSubmitInput = form.getOneHtmlElementByAttribute("input", "type", "submit")
        
        // 在真实场景中,这里会触发客户端验证或服务端验证
        val resultPage: HtmlPage = submitButton.click()
        
        // 验证错误处理逻辑
        // 具体的验证逻辑取决于你的应用实现
    }
}

高级特性:JavaScript 支持与 CDN 测试

JavaScript 执行能力

IMPORTANT

HtmlUnit 使用 Mozilla Rhino 引擎来执行 JavaScript,这意味着我们可以测试包含 JavaScript 交互的页面。

kotlin
@Test
fun `应该能够测试JavaScript交互`() {
    // 启用 JavaScript 支持
    webClient.options.isJavaScriptEnabled = true
    webClient.options.isThrowExceptionOnScriptError = false
    
    val page: HtmlPage = webClient.getPage("http://localhost/interactive-form")
    
    // 触发 JavaScript 事件
    val button: HtmlButton = page.getHtmlElementById("dynamicButton")
    val resultPage = button.click()
    
    // 等待 JavaScript 执行完成
    webClient.waitForBackgroundJavaScript(2000) 
    
    // 验证 JavaScript 执行结果
    val dynamicContent = resultPage.getHtmlElementById("dynamicContent")
    assertThat(dynamicContent.textContent).contains("动态生成的内容")
}

CDN 和外部资源测试

kotlin
@Test
fun `应该能够正确处理CDN资源`() {
    // 配置 MockMvc 只处理 localhost,其他请求走真实网络
    webClient = MockMvcWebClientBuilder
        .webAppContextSetup(context)
        .useMockMvcForHosts("localhost") // 只有 localhost 使用 MockMvc
        .build()
    
    val page: HtmlPage = webClient.getPage("http://localhost/page-with-cdn")
    
    // 页面中的 CDN 资源(如 jQuery、Bootstrap)会通过真实网络请求加载
    // 而应用本身的请求仍然通过 MockMvc 处理
    assertThat(page.titleText).isEqualTo("包含CDN资源的页面")
}

与传统 MockMvc 测试的对比

kotlin
@Test
fun `传统MockMvc方式创建消息`() {
    // 需要手动构造请求参数
    mockMvc.perform(post("/messages") 
        .param("summary", "Spring Boot 测试") 
        .param("text", "传统方式需要手动构造参数")) 
        .andExpect(status().is3xxRedirection())
        .andExpect(redirectedUrlPattern("/messages/*"))
    
    // 无法直接验证重定向后的页面内容
    // 需要额外的请求来获取创建后的消息详情
}
kotlin
@Test
fun `HtmlUnit集成方式创建消息`() {
    // 像真实用户一样操作页面
    val formPage = webClient.getPage("http://localhost/messages/form") 
    val form = formPage.getHtmlElementById("messageForm") 
    
    // 直观地填写表单
    formPage.getHtmlElementById<HtmlTextInput>("summary") 
        .valueAttribute = "Spring Boot 测试"
    
    // 提交表单并自动跟随重定向
    val resultPage = form.getOneHtmlElementByAttribute<HtmlSubmitInput>
        ("input", "type", "submit").click() 
    
    // 直接验证最终页面内容
    assertThat(resultPage.getHtmlElementById("summary").textContent) 
        .isEqualTo("Spring Boot 测试") 
}

最佳实践与注意事项

1. 性能优化

kotlin
@BeforeEach
fun setup() {
    webClient = MockMvcWebClientBuilder
        .webAppContextSetup(context)
        .build()
    
    // 优化 HtmlUnit 性能
    webClient.options.apply {
        isCssEnabled = false // 禁用 CSS 处理以提高速度
        isJavaScriptEnabled = true // 根据需要启用 JS
        isThrowExceptionOnFailingStatusCode = false
        isThrowExceptionOnScriptError = false
    }
}

2. 错误处理

WARNING

在测试中要注意处理可能出现的各种异常情况。

kotlin
@Test
fun `应该优雅处理页面加载失败`() {
    assertThrows<FailingHttpStatusCodeException> {
        webClient.getPage("http://localhost/non-existent-page") 
    }
}

3. 资源清理

kotlin
@AfterEach
fun cleanup() {
    webClient.close() // 确保释放资源
}

总结

MockMvc 与 HtmlUnit 的集成为我们提供了一个强大而优雅的 Web 应用测试解决方案:

无需启动真实服务器:保持测试的轻量级和快速执行
真实的用户交互:像真实用户一样填写表单、点击按钮
JavaScript 支持:能够测试复杂的前端交互逻辑
灵活的配置:可以精确控制哪些请求使用 MockMvc,哪些走真实网络
简化的测试代码:相比传统 MockMvc,测试代码更加直观和易维护

TIP

这种集成方式特别适合测试表单提交、页面跳转、前后端交互等场景。它让我们能够在保持单元测试速度的同时,获得接近集成测试的覆盖度。

通过 MockMvc 与 HtmlUnit 的完美结合,我们的 Web 应用测试变得更加全面、可靠和易于维护! 🎉