对于最新稳定版本,请使用 Spring Framework 7.0.6spring-doc.cadn.net.cn

MockMvc 和 WebDriver

在前面的章节中,我们已经了解了如何将 MockMvc 与原始的 HtmlUnit API 结合使用。在本节中,我们将使用 Selenium 中的额外抽象层——WebDriver,使操作变得更加简便。spring-doc.cadn.net.cn

为什么使用 WebDriver 和 MockMvc?

我们已经可以使用 HtmlUnit 和 MockMvc,那为什么还要使用 WebDriver 呢? Selenium WebDriver 提供了一个非常优雅的 API,使我们能够轻松地组织代码。为了更好地展示其工作原理, 我们在本节中探讨一个示例。spring-doc.cadn.net.cn

尽管是 Selenium 的一部分,WebDriver 运行测试时并不需要 Selenium Server。

假设我们需要确保一条消息被正确创建。这些测试包括查找 HTML 表单输入元素、填写它们,并进行各种断言。spring-doc.cadn.net.cn

这种方法会导致大量独立的测试,因为我们还想测试错误情况。 例如,我们希望确保在仅填写表单部分内容时会收到错误提示。 如果完整填写了整个表单,则应在之后显示新创建的消息。spring-doc.cadn.net.cn

如果其中一个字段名为“summary”,我们可能会在测试中的多个地方重复出现类似以下的内容:spring-doc.cadn.net.cn

HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary");
summaryInput.setValueAttribute(summary);
val summaryInput = currentPage.getHtmlElementById("summary")
summaryInput.setValueAttribute(summary)

那么,如果我们把 id 改为 smmry 会发生什么呢?这样做将迫使我们更新所有测试代码以适应这一更改。这违反了 DRY(Don't Repeat Yourself,不要重复自己)原则,因此我们最好将这段代码提取到一个独立的方法中,如下所示:spring-doc.cadn.net.cn

public HtmlPage createMessage(HtmlPage currentPage, String summary, String text) {
	setSummary(currentPage, summary);
	// ...
}

public void setSummary(HtmlPage currentPage, String summary) {
	HtmlTextInput summaryInput = currentPage.getHtmlElementById("summary");
	summaryInput.setValueAttribute(summary);
}
fun createMessage(currentPage: HtmlPage, summary:String, text:String) :HtmlPage{
	setSummary(currentPage, summary);
	// ...
}

fun setSummary(currentPage:HtmlPage , summary: String) {
	val summaryInput = currentPage.getHtmlElementById("summary")
	summaryInput.setValueAttribute(summary)
}

这样做可以确保在我们更改用户界面时,无需更新所有的测试。spring-doc.cadn.net.cn

我们甚至可以更进一步,将此逻辑放入一个代表当前所处 ObjectHtmlPage 中,如下例所示:spring-doc.cadn.net.cn

public class CreateMessagePage {

	final HtmlPage currentPage;

	final HtmlTextInput summaryInput;

	final HtmlSubmitInput submit;

	public CreateMessagePage(HtmlPage currentPage) {
		this.currentPage = currentPage;
		this.summaryInput = currentPage.getHtmlElementById("summary");
		this.submit = currentPage.getHtmlElementById("submit");
	}

	public <T> T createMessage(String summary, String text) throws Exception {
		setSummary(summary);

		HtmlPage result = submit.click();
		boolean error = CreateMessagePage.at(result);

		return (T) (error ? new CreateMessagePage(result) : new ViewMessagePage(result));
	}

	public void setSummary(String summary) throws Exception {
		summaryInput.setValueAttribute(summary);
	}

	public static boolean at(HtmlPage page) {
		return "Create Message".equals(page.getTitleText());
	}
}
	class CreateMessagePage(private val currentPage: HtmlPage) {

		val summaryInput: HtmlTextInput = currentPage.getHtmlElementById("summary")

		val submit: HtmlSubmitInput = currentPage.getHtmlElementById("submit")

		fun <T> createMessage(summary: String, text: String): T {
			setSummary(summary)

			val result = submit.click()
			val error = at(result)

			return (if (error) CreateMessagePage(result) else ViewMessagePage(result)) as T
		}

		fun setSummary(summary: String) {
			summaryInput.setValueAttribute(summary)
		}

		fun at(page: HtmlPage): Boolean {
			return "Create Message" == page.getTitleText()
		}
	}
}

以前,这种模式被称为 页面对象模式(Page Object Pattern)。虽然我们当然可以使用 HtmlUnit 来实现这一点,但 WebDriver 提供了一些工具(我们将在以下章节中探讨),使该模式的实现变得更加容易。spring-doc.cadn.net.cn

MockMvc 和 WebDriver 设置

要在 Spring MVC 测试框架中使用 Selenium WebDriver,请确保您的项目包含对 org.seleniumhq.selenium:selenium-htmlunit-driver 的测试依赖。spring-doc.cadn.net.cn

我们可以轻松地使用 MockMvcHtmlUnitDriverBuilder 创建一个与 MockMvc 集成的 Selenium WebDriver,如下例所示:spring-doc.cadn.net.cn

WebDriver driver;

@BeforeEach
void setup(WebApplicationContext context) {
	driver = MockMvcHtmlUnitDriverBuilder
			.webAppContextSetup(context)
			.build();
}
lateinit var driver: WebDriver

@BeforeEach
fun setup(context: WebApplicationContext) {
	driver = MockMvcHtmlUnitDriverBuilder
			.webAppContextSetup(context)
			.build()
}
这是一个使用 MockMvcHtmlUnitDriverBuilder 的简单示例。如需更高级的用法,请参阅 高级 MockMvcHtmlUnitDriverBuilder

前面的示例确保任何引用 localhost 作为服务器的 URL 都会被定向到我们的 MockMvc 实例,而无需建立真实的 HTTP 连接。其他所有 URL 则会像平常一样通过网络连接发起请求。这使我们可以轻松地测试 CDN 的使用。spring-doc.cadn.net.cn

MockMvc 和 WebDriver 的使用

现在我们可以像平常一样使用 WebDriver,而无需将我们的应用程序部署到 Servlet 容器中。例如,我们可以发送请求以创建一条消息,如下所示:spring-doc.cadn.net.cn

CreateMessagePage page = CreateMessagePage.to(driver);
val page = CreateMessagePage.to(driver)

然后我们可以填写表单并提交以创建一条消息,如下所示:spring-doc.cadn.net.cn

ViewMessagePage viewMessagePage =
		page.createMessage(ViewMessagePage.class, expectedSummary, expectedText);
val viewMessagePage =
	page.createMessage(ViewMessagePage::class, expectedSummary, expectedText)

通过利用页面对象模式(Page Object Pattern),这改进了我们HtmlUnit 测试的设计。正如我们在为什么使用 WebDriver 和 MockMvc?中提到的,我们可以将页面对象模式与 HtmlUnit 一起使用,但与 WebDriver 配合使用则要简单得多。请考虑以下CreateMessagePage的实现:spring-doc.cadn.net.cn

public class CreateMessagePage extends AbstractPage { (1)

	(2)
	private WebElement summary;
	private WebElement text;

	@FindBy(css = "input[type=submit]") (3)
	private WebElement submit;

	public CreateMessagePage(WebDriver driver) {
		super(driver);
	}

	public <T> T createMessage(Class<T> resultPage, String summary, String details) {
		this.summary.sendKeys(summary);
		this.text.sendKeys(details);
		this.submit.click();
		return PageFactory.initElements(driver, resultPage);
	}

	public static CreateMessagePage to(WebDriver driver) {
		driver.get("http://localhost:9990/mail/messages/form");
		return PageFactory.initElements(driver, CreateMessagePage.class);
	}
}
1 CreateMessagePage 继承自 AbstractPage。我们不会详细讨论 AbstractPage 的细节,但简而言之,它包含了我们所有页面的通用功能。 例如,如果我们的应用程序具有导航栏、全局错误消息以及其他功能,我们可以将这些逻辑放在一个共享的位置。
2 我们为 HTML 页面中感兴趣的每个部分都定义了一个成员变量。这些变量的类型为 WebElement。WebDriver 的 PageFactory 让我们能够从 CreateMessagePage 的 HtmlUnit 版本中移除大量代码,因为它会自动解析每个 WebElementPageFactory#initElements(WebDriver,Class<T>) 方法通过使用字段名称,并根据 HTML 页面中元素的 idname 进行查找,从而自动解析每个 WebElement
3 我们可以使用 @FindBy 注解 来覆盖默认的查找行为。我们的示例展示了如何使用 @FindBy 注解,通过 css 选择器(input[type=submit])来查找我们的提交按钮。
class CreateMessagePage(private val driver: WebDriver) : AbstractPage(driver) { (1)

	(2)
	private lateinit var summary: WebElement
	private lateinit var text: WebElement

	@FindBy(css = "input[type=submit]") (3)
	private lateinit var submit: WebElement

	fun <T> createMessage(resultPage: Class<T>, summary: String, details: String): T {
		this.summary.sendKeys(summary)
		text.sendKeys(details)
		submit.click()
		return PageFactory.initElements(driver, resultPage)
	}
	companion object {
		fun to(driver: WebDriver): CreateMessagePage {
			driver.get("http://localhost:9990/mail/messages/form")
			return PageFactory.initElements(driver, CreateMessagePage::class.java)
		}
	}
}
1 CreateMessagePage 继承自 AbstractPage。我们不会详细讨论 AbstractPage 的细节,但简而言之,它包含了我们所有页面的通用功能。 例如,如果我们的应用程序具有导航栏、全局错误消息以及其他功能,我们可以将这些逻辑放在一个共享的位置。
2 我们为 HTML 页面中感兴趣的每个部分都定义了一个成员变量。这些变量的类型为 WebElement。WebDriver 的 PageFactory 让我们能够从 CreateMessagePage 的 HtmlUnit 版本中移除大量代码,因为它会自动解析每个 WebElementPageFactory#initElements(WebDriver,Class<T>) 方法通过使用字段名称,并根据 HTML 页面中元素的 idname 进行查找,从而自动解析每个 WebElement
3 我们可以使用 @FindBy 注解 来覆盖默认的查找行为。我们的示例展示了如何使用 @FindBy 注解,通过 css 选择器(input[type=submit])来查找我们的提交按钮。

最后,我们可以验证一条新消息是否已成功创建。以下断言使用了AssertJ断言库:spring-doc.cadn.net.cn

assertThat(viewMessagePage.getMessage()).isEqualTo(expectedMessage);
assertThat(viewMessagePage.getSuccess()).isEqualTo("Successfully created a new message");
assertThat(viewMessagePage.message).isEqualTo(expectedMessage)
assertThat(viewMessagePage.success).isEqualTo("Successfully created a new message")

我们可以看到,我们的 ViewMessagePage 允许我们与自定义的领域模型进行交互。例如,它公开了一个返回 Message 对象的方法:spring-doc.cadn.net.cn

public Message getMessage() throws ParseException {
	Message message = new Message();
	message.setId(getId());
	message.setCreated(getCreated());
	message.setSummary(getSummary());
	message.setText(getText());
	return message;
}
fun getMessage() = Message(getId(), getCreated(), getSummary(), getText())

然后我们就可以在断言中使用这些丰富的领域对象了。spring-doc.cadn.net.cn

最后,我们不能忘记在测试完成后关闭 WebDriver 实例,如下所示:spring-doc.cadn.net.cn

@AfterEach
void destroy() {
	if (driver != null) {
		driver.close();
	}
}
@AfterEach
fun destroy() {
	if (driver != null) {
		driver.close()
	}
}

有关使用 WebDriver 的更多信息,请参阅 Selenium WebDriver 文档spring-doc.cadn.net.cn

高级MockMvcHtmlUnitDriverBuilder

在迄今为止的示例中,我们以最简单的方式使用了 MockMvcHtmlUnitDriverBuilder,即基于 Spring TestContext 框架为我们加载的 WebDriver 来构建一个 WebApplicationContext。此方法在此再次展示如下:spring-doc.cadn.net.cn

WebDriver driver;

@BeforeEach
void setup(WebApplicationContext context) {
	driver = MockMvcHtmlUnitDriverBuilder
			.webAppContextSetup(context)
			.build();
}
lateinit var driver: WebDriver

@BeforeEach
fun setup(context: WebApplicationContext) {
	driver = MockMvcHtmlUnitDriverBuilder
			.webAppContextSetup(context)
			.build()
}

我们还可以指定额外的配置选项,如下所示:spring-doc.cadn.net.cn

WebDriver driver;

@BeforeEach
void setup() {
	driver = MockMvcHtmlUnitDriverBuilder
			// demonstrates applying a MockMvcConfigurer (Spring Security)
			.webAppContextSetup(context, springSecurity())
			// for illustration only - defaults to ""
			.contextPath("")
			// By default MockMvc is used for localhost only;
			// the following will use MockMvc for example.com and example.org as well
			.useMockMvcForHosts("example.com","example.org")
			.build();
}
lateinit var driver: WebDriver

@BeforeEach
fun setup() {
	driver = MockMvcHtmlUnitDriverBuilder
			// demonstrates applying a MockMvcConfigurer (Spring Security)
			.webAppContextSetup(context, springSecurity())
			// for illustration only - defaults to ""
			.contextPath("")
			// By default MockMvc is used for localhost only;
			// the following will use MockMvc for example.com and example.org as well
			.useMockMvcForHosts("example.com","example.org")
			.build()
}

作为替代方案,我们也可以通过单独配置 MockMvc 实例,并将其提供给 MockMvcHtmlUnitDriverBuilder 来完成完全相同的设置,如下所示:spring-doc.cadn.net.cn

MockMvc mockMvc = MockMvcBuilders
		.webAppContextSetup(context)
		.apply(springSecurity())
		.build();

driver = MockMvcHtmlUnitDriverBuilder
		.mockMvcSetup(mockMvc)
		// for illustration only - defaults to ""
		.contextPath("")
		// By default MockMvc is used for localhost only;
		// the following will use MockMvc for example.com and example.org as well
		.useMockMvcForHosts("example.com","example.org")
		.build();
// Not possible in Kotlin until https://youtrack.jetbrains.com/issue/KT-22208 is fixed

这种方式更为冗长,但通过使用 WebDriver 实例来构建 MockMvc,我们可以充分利用 MockMvc 的全部功能。spring-doc.cadn.net.cn

有关创建 MockMvc 实例的更多信息,请参阅 设置选项