Skip to content

MockMvc 与 Geb 集成:让 Web 测试更加 Groovy-er 🎉

在现代 Web 应用开发中,测试是确保代码质量的重要环节。当我们需要测试 Web 层时,传统的单元测试往往无法覆盖完整的 HTTP 请求-响应流程。而启动完整的服务器进行集成测试又显得过于笨重。这时,MockMvc 与 Geb 的组合为我们提供了一个优雅的解决方案。

什么是 Geb?为什么要与 MockMvc 结合?

Geb 的核心价值

Geb 是一个基于 Groovy 的浏览器自动化测试框架,它构建在 WebDriver 之上,但提供了更加简洁和表达力强的 API。

NOTE

Geb 的名字来源于 "Groovy Browser",它将 WebDriver 的强大功能与 Groovy 语言的简洁性完美结合。

解决的核心痛点

groovy
// 繁琐的WebDriver代码
WebDriver driver = new HtmlUnitDriver();
driver.get("http://localhost/messages/form");

WebElement summaryField = driver.findElement(By.name("summary"));
summaryField.sendKeys("测试摘要");

WebElement textField = driver.findElement(By.name("text"));
textField.sendKeys("测试内容");

WebElement submitButton = driver.findElement(By.cssSelector("input[type=submit]"));
submitButton.click();

// 验证结果
assertEquals("Messages : View", driver.getTitle());
groovy
// 简洁的Geb代码
to CreateMessagePage

form.summary = "测试摘要"
form.text = "测试内容"
submit.click(ViewMessagePage)

then:
at ViewMessagePage
success == "Successfully created a new message"

环境配置与初始化

基础配置

在 Kotlin/Spring Boot 项目中,我们需要进行以下配置:

kotlin
@SpringBootTest
@AutoConfigureTestDatabase
@TestMethodOrder(OrderAnnotation::class)
class BaseWebTest {
    
    @Autowired
    lateinit var webApplicationContext: WebApplicationContext
    
    lateinit var browser: Browser
    
    @BeforeEach
    fun setup() {
        // 初始化Geb Browser,使用MockMvc作为驱动
        val driver = MockMvcHtmlUnitDriverBuilder
            .webAppContextSetup(webApplicationContext) 
            .build()
        
        browser = Browser().apply {
            this.driver = driver
        }
    }
    
    @AfterEach
    fun cleanup() {
        browser.quit()
    }
}
groovy
def setup() {
    browser.driver = MockMvcHtmlUnitDriverBuilder
        .webAppContextSetup(context) 
        .build()
}

TIP

MockMvcHtmlUnitDriverBuilder.webAppContextSetup() 是关键配置,它确保所有指向 localhost 的URL都会被重定向到我们的 MockMvc 实例,而不需要真实的HTTP连接。

高级配置选项

对于更复杂的测试场景,我们可以进行更详细的配置:

kotlin
@BeforeEach
fun setupAdvanced() {
    val driver = MockMvcHtmlUnitDriverBuilder
        .webAppContextSetup(webApplicationContext)
        .configureWebClient { webClient ->
            // 配置WebClient选项
            webClient.options.apply {
                isJavaScriptEnabled = true
                isCssEnabled = true
                timeout = 30000
            }
        }
        .useMockMvcForHosts("localhost", "127.0.0.1") 
        .build()
    
    browser = Browser().apply {
        this.driver = driver
    }
}

IMPORTANT

通过 useMockMvcForHosts() 方法,我们可以指定哪些主机名应该使用 MockMvc,其他URL仍然通过网络连接访问,这对于测试CDN集成非常有用。

页面对象模式的实现

创建页面对象

Geb 的页面对象模式让测试代码更加清晰和可维护:

groovy
class CreateMessagePage extends Page {
    static url = 'messages/form'
    static at = { 
        assert title == 'Messages : Create'
        true 
    }
    
    static content = {
        // 定义页面元素
        submit { $('input[type=submit]') }  
        form { $('form') }
        summaryField { $('input[name=summary]') }
        textField { $('textarea[name=text]') }
        errors(required: false) { 
            $('label.error, .alert-error')?.text() 
        }
    }
}

Kotlin 版本的页面对象

kotlin
class CreateMessagePage : Page() {
    companion object {
        const val URL = "messages/form"
    }
    
    override fun isAt(): Boolean {
        return title == "Messages : Create"
    }
    
    // 页面元素定义
    val submitButton by lazy { find("input[type=submit]") }
    val messageForm by lazy { find("form") }
    val summaryField by lazy { find("input[name=summary]") }
    val textField by lazy { find("textarea[name=text]") }
    
    fun fillAndSubmit(summary: String, text: String) {
        summaryField.sendKeys(summary)
        textField.sendKeys(text)
        submitButton.click()
    }
}

实际测试用例编写

完整的测试流程

kotlin
@Test
fun `should create message successfully`() {
    // Given: 准备测试数据
    val expectedSummary = "重要通知"
    val expectedMessage = "这是一条测试消息的详细内容"
    
    // When: 执行页面操作
    browser.to(CreateMessagePage::class.java)
    
    val createPage = browser.page as CreateMessagePage
    createPage.fillAndSubmit(expectedSummary, expectedMessage)
    
    // Then: 验证结果
    assertTrue(browser.isAt(ViewMessagePage::class.java))
    
    val viewPage = browser.page as ViewMessagePage
    assertEquals("Successfully created a new message", viewPage.successMessage)
    assertEquals(expectedSummary, viewPage.summary)
    assertEquals(expectedMessage, viewPage.messageText)
}

错误处理测试

kotlin
@Test
fun `should show validation errors for empty form`() {
    // When: 提交空表单
    browser.to(CreateMessagePage::class.java)
    
    val createPage = browser.page as CreateMessagePage
    createPage.submitButton.click()
    
    // Then: 应该显示验证错误并停留在当前页面
    assertTrue(browser.isAt(CreateMessagePage::class.java)) 
    assertTrue(createPage.hasValidationErrors())
    assertThat(createPage.errorMessages)
        .contains("This field is required.") 
}

与传统测试方式的对比

测试复杂度对比

WARNING

传统的 MockMvc 测试虽然快速,但在处理复杂页面交互时会变得冗长难维护。

kotlin
@Test
fun testCreateMessage() {
    mockMvc.perform(post("/messages")
        .param("summary", "测试摘要")
        .param("text", "测试内容")
        .with(csrf()))
        .andExpect(status().is3xxRedirection()) 
        .andExpect(redirectedUrlPattern("/messages/*"))
    
    // 需要额外的请求来验证创建结果
    val result = mockMvc.perform(get("/messages/1"))
        .andExpected(status().isOk())
        .andReturn()
    
    // 手动解析HTML内容进行验证
    val content = result.response.contentAsString
    assertTrue(content.contains("测试摘要")) 
    assertTrue(content.contains("测试内容"))
}
kotlin
@Test
fun `should create message with better readability`() {
    // 更接近用户实际操作流程
    browser.to(CreateMessagePage::class.java)
    
    val createPage = browser.page as CreateMessagePage
    createPage.apply {
        summaryField.sendKeys("测试摘要") 
        textField.sendKeys("测试内容")
        submitButton.click()
    }
    
    // 自然的页面跳转验证
    assertTrue(browser.isAt(ViewMessagePage::class.java))
    
    val viewPage = browser.page as ViewMessagePage
    assertEquals("测试摘要", viewPage.summary) 
    assertEquals("测试内容", viewPage.messageText)
}

最佳实践与注意事项

1. 页面对象设计原则

TIP

遵循单一职责原则,每个页面对象只负责一个页面的元素定义和基本操作。

kotlin
// ✅ 好的设计
class LoginPage : Page() {
    val usernameField by lazy { find("#username") }
    val passwordField by lazy { find("#password") }
    val loginButton by lazy { find("#login-btn") }
    
    fun login(username: String, password: String) {
        usernameField.sendKeys(username)
        passwordField.sendKeys(password)
        loginButton.click()
    }
}

// ❌ 避免的设计
class LoginPage : Page() {
    // 不要在页面对象中包含业务逻辑验证
    fun loginAndVerifySuccess(username: String, password: String): Boolean {
        // 复杂的业务逻辑应该在测试方法中处理
        login(username, password)
        return browser.isAt(DashboardPage::class.java) 
    }
}

2. 测试数据管理

kotlin
class TestDataBuilder {
    companion object {
        fun createValidMessage() = MessageData(
            summary = "测试摘要_${System.currentTimeMillis()}",
            text = "这是测试消息内容"
        )
        
        fun createInvalidMessage() = MessageData(
            summary = "", 
            text = ""
        )
    }
}

3. 等待策略

IMPORTANT

在处理异步操作时,合理使用等待策略避免测试不稳定。

kotlin
@Test
fun `should handle async operations`() {
    browser.to(CreateMessagePage::class.java)
    
    val createPage = browser.page as CreateMessagePage
    createPage.fillAndSubmit("异步测试", "测试异步消息创建")
    
    // 等待页面跳转完成
    browser.waitFor(10) { 
        browser.isAt(ViewMessagePage::class.java)
    }
    
    val viewPage = browser.page as ViewMessagePage
    assertEquals("异步测试", viewPage.summary)
}

总结

MockMvc 与 Geb 的结合为我们提供了一个强大而优雅的 Web 测试解决方案:

优势总结

  • 🚀 性能优异:无需启动完整服务器,测试执行速度快
  • 📝 代码简洁:Geb 的 DSL 让测试代码更加易读易维护
  • 🎯 接近真实:模拟真实浏览器行为,但在隔离环境中运行
  • 🔧 易于调试:出错时可以快速定位问题所在

NOTE

Geb + MockMvc 特别适合于需要测试复杂页面交互,但又不想承担完整集成测试开销的场景。它在单元测试的速度和端到端测试的完整性之间找到了完美的平衡点。

通过合理运用页面对象模式和 Geb 的强大功能,我们可以构建出既高效又可维护的 Web 测试套件,为应用的质量保驾护航! 🎉