Skip to content

Spring MockMvc HtmlUnit 集成:让 Web 测试更真实 🌐

什么是 HtmlUnit 集成?为什么需要它?

在 Spring MVC 应用开发中,我们经常面临一个困境:如何既能高效地测试 Web 页面的交互逻辑,又不需要启动完整的服务器? HtmlUnit 集成就是为了解决这个问题而生的。

NOTE

HtmlUnit 是一个 Java 编写的无界面浏览器,它可以模拟真实浏览器的行为,包括 JavaScript 执行、表单提交、页面导航等功能。

让我们通过一个实际场景来理解这个问题。

传统测试方式的痛点 😰

假设我们有一个支持 CRUD 操作的消息管理系统,包含消息的创建、查看和分页功能。

场景一:测试消息创建功能

使用传统的 MockMvc,我们可以轻松测试后端接口:

kotlin
@Test
fun testCreateMessage() {
    mockMvc.post("/messages/") {
        param("summary", "Spring Rocks")
        param("text", "In case you didn't know, Spring Rocks!")
    }.andExpect {
        status { is3xxRedirection() }
        redirectedUrl("/messages/123")
    }
}
html
<form id="messageForm" action="/messages/" method="post">
    <div class="pull-right">
        <a href="/messages/">Messages</a>
    </div>
    
    <label for="summary">Summary</label>
    <input type="text" class="required" id="summary" name="summary" value="" />
    
    <label for="text">Message</label>
    <textarea id="text" name="text"></textarea>
    
    <div class="form-actions">
        <input type="submit" value="Create" />
    </div>
</form>

问题来了:如何测试表单视图?

如果我们想测试表单页面是否正确渲染,传统方式可能是这样:

kotlin
@Test
fun testFormView() {
    mockMvc.get("/messages/form").andExpect {
        xpath("//input[@name='summary']") { exists() }
        xpath("//textarea[@name='text']") { exists() }
    }
}

WARNING

这种测试方式存在严重缺陷!如果控制器参数从 text 改为 message,表单测试依然会通过,但实际上前后端已经不匹配了。

尝试改进:组合测试

kotlin
@Test
fun testFormAndSubmission() {
    val summaryParamName = "summary"
    val textParamName = "text"
    
    // 测试表单字段存在
    mockMvc.get("/messages/form").andExpect {
        xpath("//input[@name='$summaryParamName']") { exists() }
        xpath("//textarea[@name='$textParamName']") { exists() }
    }
    
    // 测试表单提交
    mockMvc.post("/messages/") {
        param(summaryParamName, "Spring Rocks")
        param(textParamName, "In case you didn't know, Spring Rocks!")
    }.andExpect {
        status { is3xxRedirection() }
        redirectedUrl("/messages/123")
    }
}

仍然存在的问题

传统测试方式的局限性

  1. 复杂性增加:需要编写复杂的 XPath 表达式来验证表单字段
  2. 重复工作:先验证视图,再用相同参数测试提交,做了双倍的工作
  3. 无法测试 JavaScript:无法验证前端的 JavaScript 验证逻辑
  4. 缺乏真实性:无法模拟用户的真实交互流程

集成测试能解决问题吗?🤔

你可能会想:"那我们用端到端集成测试不就行了?"

让我们看看集成测试需要考虑的场景:

  • 当消息为空时,页面是否显示"无结果"提示?
  • 页面是否正确显示单条消息?
  • 分页功能是否正常工作?

集成测试的挑战

集成测试的痛点

  • 数据准备复杂:需要确保数据库中有正确的测试数据(考虑外键约束)
  • 测试速度慢:每个测试都需要确保数据库处于正确状态
  • 无法并行:由于数据库状态依赖,测试无法并行执行
  • 断言困难:自动生成的 ID、时间戳等难以进行断言

TIP

最佳实践是:减少端到端集成测试的数量,用 Mock 服务进行详细测试,然后实现少量真正的端到端测试来验证整体工作流程。

HtmlUnit 集成:最佳解决方案 ✨

HtmlUnit 集成为我们提供了一个完美的平衡点:既能测试页面交互,又能保持良好的测试性能

HtmlUnit 集成的优势

HtmlUnit 集成的核心价值

  1. 真实的浏览器行为:模拟真实用户的页面交互
  2. JavaScript 支持:可以测试前端 JavaScript 逻辑
  3. 高性能:无需启动真实服务器,基于内存运行
  4. 简化测试:一次测试覆盖表单渲染和提交的完整流程
  5. 代码复用:测试代码可以在集成测试和端到端测试之间复用

HtmlUnit 集成选项 🛠️

Spring 为我们提供了三种 HtmlUnit 集成方式:

1. MockMvc + HtmlUnit

kotlin
@Test
fun testCreateMessageWithHtmlUnit() {
    val webClient = MockMvcWebClientBuilder
        .mockMvcSetup(mockMvc)
        .build()
    
    // 获取表单页面
    val createMsgFormPage = webClient.getPage<HtmlPage>("/messages/form")
    
    // 填充表单
    val form = createMsgFormPage.getHtmlElementById<HtmlForm>("messageForm")
    val summaryInput = createMsgFormPage.getHtmlElementById<HtmlTextInput>("summary")
    val textArea = createMsgFormPage.getHtmlElementById<HtmlTextArea>("text")
    
    summaryInput.valueAttribute = "Spring Rocks"
    textArea.text = "In case you didn't know, Spring Rocks!"
    
    // 提交表单
    val resultPage = form.getInputByValue<HtmlSubmitInput>("Create").click<HtmlPage>()
    
    // 验证结果
    assertThat(resultPage.url.toString()).endsWith("/messages/123")
}

NOTE

适用于希望直接使用 HtmlUnit 原生 API 的场景,提供最大的灵活性。

2. MockMvc + WebDriver

kotlin
@Test
fun testCreateMessageWithWebDriver() {
    val driver = MockMvcHtmlUnitDriverBuilder
        .mockMvcSetup(mockMvc)
        .build()
    
    // 访问表单页面
    driver.get("/messages/form")
    
    // 填充表单
    driver.findElement(By.id("summary")).sendKeys("Spring Rocks")
    driver.findElement(By.id("text")).sendKeys("In case you didn't know, Spring Rocks!")
    
    // 提交表单
    driver.findElement(By.xpath("//input[@value='Create']")).click()
    
    // 验证结果
    assertThat(driver.currentUrl).endsWith("/messages/123")
}

TIP

推荐使用这种方式!WebDriver API 更加标准化,测试代码可以轻松在集成测试和端到端测试之间切换。

3. MockMvc + Geb(Groovy)

groovy
def "test create message with Geb"() {
    given:
    def driver = MockMvcHtmlUnitDriverBuilder
        .mockMvcSetup(mockMvc)
        .build()
    
    when:
    to CreateMessagePage
    
    and:
    summary = "Spring Rocks"
    text = "In case you didn't know, Spring Rocks!"
    submit.click()
    
    then:
    at ViewMessagePage
    message.summary == "Spring Rocks"
}

NOTE

适用于 Groovy 项目,提供了更简洁的 DSL 语法。

实际应用示例 💡

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

完整的消息管理系统测试示例
kotlin
@SpringBootTest
@AutoConfigureTestDatabase
class MessageControllerHtmlUnitTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    private lateinit var webClient: WebClient

    @BeforeEach
    fun setup() {
        webClient = MockMvcWebClientBuilder
            .mockMvcSetup(mockMvc)
            .build()
    }

    @Test
    fun `should create message through form interaction`() {
        // 1. 访问消息列表页面
        val messagesPage = webClient.getPage<HtmlPage>("/messages/")
        
        // 2. 点击"创建消息"链接
        val createLink = messagesPage.getAnchorByText("Create Message")
        val createFormPage = createLink.click<HtmlPage>()
        
        // 3. 验证表单页面加载正确
        assertThat(createFormPage.titleText).contains("Create Message")
        
        // 4. 填充表单
        val form = createFormPage.getHtmlElementById<HtmlForm>("messageForm")
        val summaryInput = createFormPage.getHtmlElementById<HtmlTextInput>("summary")
        val textArea = createFormPage.getHtmlElementById<HtmlTextArea>("text")
        
        summaryInput.valueAttribute = "Integration Test Message"
        textArea.text = "This message was created through HtmlUnit integration test"
        
        // 5. 提交表单
        val submitButton = form.getInputByValue<HtmlSubmitInput>("Create")
        val resultPage = submitButton.click<HtmlPage>()
        
        // 6. 验证重定向到消息详情页
        assertThat(resultPage.url.path).matches("/messages/\\d+")
        
        // 7. 验证消息内容显示正确
        val messageTitle = resultPage.querySelector("h1")
        assertThat(messageTitle.textContent).isEqualTo("Integration Test Message")
    }

    @Test
    fun `should handle form validation errors`() {
        // 访问创建表单页面
        val createFormPage = webClient.getPage<HtmlPage>("/messages/form")
        
        // 提交空表单
        val form = createFormPage.getHtmlElementById<HtmlForm>("messageForm")
        val submitButton = form.getInputByValue<HtmlSubmitInput>("Create")
        val resultPage = submitButton.click<HtmlPage>()
        
        // 验证表单验证错误显示
        val errorMessages = resultPage.querySelectorAll(".error-message")
        assertThat(errorMessages).hasSize(2) // summary 和 text 字段都是必填的
        
        val summaryError = errorMessages[0].textContent
        val textError = errorMessages[1].textContent
        
        assertThat(summaryError).contains("Summary is required")
        assertThat(textError).contains("Message text is required")
    }

    @Test
    fun `should support message pagination`() {
        // 创建多条测试消息
        repeat(25) { index ->
            createTestMessage("Message $index", "Content for message $index")
        }
        
        // 访问消息列表页面
        val messagesPage = webClient.getPage<HtmlPage>("/messages/")
        
        // 验证分页控件存在
        val paginationNav = messagesPage.querySelector(".pagination")
        assertThat(paginationNav).isNotNull()
        
        // 验证第一页显示10条消息
        val messageItems = messagesPage.querySelectorAll(".message-item")
        assertThat(messageItems).hasSize(10)
        
        // 点击下一页
        val nextPageLink = messagesPage.getAnchorByText("Next")
        val nextPage = nextPageLink.click<HtmlPage>()
        
        // 验证第二页内容
        val nextPageItems = nextPage.querySelectorAll(".message-item")
        assertThat(nextPageItems).hasSize(10)
        
        // 验证消息内容不同
        val firstPageFirstMessage = messageItems[0].querySelector(".message-title").textContent
        val secondPageFirstMessage = nextPageItems[0].querySelector(".message-title").textContent
        assertThat(firstPageFirstMessage).isNotEqualTo(secondPageFirstMessage)
    }

    private fun createTestMessage(summary: String, text: String) {
        mockMvc.post("/messages/") {
            param("summary", summary)
            param("text", text)
        }
    }
}

总结:为什么选择 HtmlUnit 集成? 🎯

HtmlUnit 集成为我们提供了一个理想的测试解决方案:

核心优势总结

  • 🚀 高性能:基于内存运行,无需启动真实服务器
  • 🎭 真实性:模拟真实浏览器行为,包括 JavaScript 执行
  • 🔄 完整性:一次测试覆盖从页面渲染到用户交互的完整流程
  • 🛡️ 可靠性:避免了传统测试中前后端不匹配的问题
  • ♻️ 可复用性:测试代码可以在不同测试层级间复用

通过 HtmlUnit 集成,我们可以编写既快速又可靠的 Web 应用测试,确保前端表单和后端控制器的完美协作。这种方式让我们在保持测试效率的同时,获得了接近真实用户体验的测试覆盖度。

IMPORTANT

选择合适的 HtmlUnit 集成方式:

  • 需要最大灵活性 → MockMvc + HtmlUnit
  • 希望代码可复用 → MockMvc + WebDriver(推荐)
  • 使用 Groovy 项目 → MockMvc + Geb