Spring обеспечивает интеграцию между MockMvc и HtmlUnit. Это упрощает проведение сквозного тестирования при использовании представлений на основе HTML. Такая интеграция позволяет вам:

  • Легко тестировать HTML-страницы с помощью таких инструментов, как HtmlUnit, WebDriver и Geb, без необходимости развертывания в контейнере сервлетов.

  • Тестировать JavaScript внутри страниц.

  • Опционально использовать имитируемые службы для ускорения тестирования.

  • Обеспечивать совместное использование логики сквозными тестами в контейнере и интеграционными тестами вне контейнера.

MockMvc работает на технологиях шаблонизации, которые не зависят от контейнера сервлетов (например, Thymeleaf, FreeMarker и другие), но он не работает с технологией JSP, поскольку она зависит от контейнера сервлетов.

Зачем нужна интеграция HtmlUnit?

Самый очевидный вопрос, который приходит на ум: "Зачем это нужно?". Ответ проще всего найти, если изучить крайне наглядный пример приложения. Предположим, что у вас есть веб-приложение на Spring MVC, которое поддерживает CRUD-операции над объектом Message. Приложение также поддерживает страничную организацию всех сообщений. Как его тестировать?

С помощью Spring MVC Test можно легко проверить, можем ли мы создать Message, следующим образом:

Java
MockHttpServletRequestBuilder createMessage = post("/messages/")
        .param("summary", "Spring Rocks")
        .param("text", "In case you didn't know, Spring Rocks!");
mockMvc.perform(createMessage)
        .andExpect(status().is3xxRedirection())
        .andExpect(redirectedUrl("/messages/123"));
Kotlin
@Test
fun test() {
    mockMvc.post("/messages/") {
        param("summary", "Spring Rocks")
        param("text", "In case you didn't know, Spring Rocks!")
    }.andExpect {
        status().is3xxRedirection()
        redirectedUrl("/messages/123")
    }
}

Что если нам нужно протестировать представление формы, позволяющее создать сообщение? Например, допустим, наша форма выглядит как следующий фрагмент:

<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>

Как сделать так, чтобы наша форма выдавала правильный запрос на создание нового сообщения? Наивная попытка может выглядеть следующим образом:

Java
mockMvc.perform(get("/messages/form"))
        .andExpect(xpath("//input[@name='summary']").exists())
        .andExpect(xpath("//textarea[@name='text']").exists());
Kotlin
mockMvc.get("/messages/form").andExpect {
    xpath("//input[@name='summary']") { exists() }
    xpath("//textarea[@name='text']") { exists() }
}

У этого теста есть несколько очевидных недостатков. Если мы обновим наш контроллер, чтобы использовать параметр message вместо text, наш тест формы будет пройден, даже если HTML-форма не синхронизирована с контроллером. Чтобы решить эту проблему, можно объединить два наших теста следующим образом:

Java
String summaryParamName = "summary";
String textParamName = "text";
mockMvc.perform(get("/messages/form"))
        .andExpect(xpath("//input[@name='" + summaryParamName + "']").exists())
        .andExpect(xpath("//textarea[@name='" + textParamName + "']").exists());
MockHttpServletRequestBuilder createMessage = post("/messages/")
        .param(summaryParamName, "Spring Rocks")
        .param(textParamName, "In case you didn't know, Spring Rocks!");
mockMvc.perform(createMessage)
        .andExpect(status().is3xxRedirection())
        .andExpect(redirectedUrl("/messages/123"));
Kotlin
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")
}

Это снизит риск некорректного прохождения нашего теста, но все равно остаются некоторые проблемы:

  • Что делать, если на странице несколько форм? Конечно, мы можем обновить наши выражения XPath, но они становятся всё сложнее, поскольку мы учитываем всё больше факторов: Являются ли поля правильного типа? Активированы ли поля? И так далее.

  • Другая проблема заключается в том, что мы выполняем вдвое больше работы, чем ожидалось. Нам нужно сначала проверить представление, а затем отправить это представление с теми же параметрами, которые мы только что проверили. В идеале, всё это можно сделать сразу.

  • Наконец, мы всё еще не можем учесть некоторые вещи. Например, что если форма имеет валидацию JavaScript, которую мы также хотим протестировать?

Общая проблема заключается в том, что тестирование веб-страницы не предполагает однократного взаимодействия. Наоборот, это сочетание того, как пользователь взаимодействует с веб-страницей и как эта веб-страница взаимодействует с другими ресурсами. Например, результат представления формы используется в качестве входных данных для пользователя при создании сообщения. Кроме того, наше представление формы может потенциально использовать дополнительные ресурсы, которые влияют на логику работы страницы, например, валидацию JavaScript.

Интеграционное тестирование в помощь?

Для решения упомянутых выше проблем можно провести сквозное интеграционное тестирование, но оно имеет ряд недостатков. Рассмотрим тестирование представления, которое позволяет нам просматривать сообщения. Нам может понадобиться протестировать следующее:

  • Отображает ли наша страница уведомление для пользователя о том, что результаты отсутствуют, если сообщения пусты?

  • Правильно ли наша страница отображает одно отдельное сообщение?

  • Поддерживает ли наша страница должным образом страничную организацию (пейджинг)?

Чтобы настроить эти тесты, нужно убедиться, что наша база данных содержит необходимые сообщения. Это приводит к возникновению ряда дополнительных проблем:

  • Обеспечение наличия в базе данных нужных сообщений может быть трудоёмким процессом. (Учитывая ограничения внешнего ключа).

  • Тестирование может замедлиться, поскольку каждому тесту нужно убедиться, что база данных находится в надлежащем состоянии.

  • Поскольку наша база данных должна находиться в определенном состоянии, нельзя запускать тесты параллельно.

  • Выполнение утверждений для таких элементов, как автоматически генерируемые идентификаторы, временные метки и др., может быть затруднено.

Эти проблемы не означают, что необходимо полностью отказаться от сквозного интеграционного тестирования. Вместо этого мы можем сократить количество сквозных интеграционных тестов, произведя рефакторинг наших детализированных тестов, чтобы использовать имитируемые службы, которые работают намного быстрее, надежнее и без побочных эффектов. Затем можно провести небольшое количество настоящих сквозных интеграционных тестов, которые проверят простые рабочие процессы, чтобы убедиться, что все работает надлежащим образом.

Ввод интеграции HtmlUnit

Как же достичь баланса между тестированием взаимодействия наших страниц и сохранением надлежащей производительности в нашем тестовом комплекте? Ответ: "Путем интеграции MockMvc с HtmlUnit".

Параметры интеграции HtmlUnit

Если необходимо интегрировать MockMvc с HtmlUnit, есть несколько вариантов:

  • MockMvc и HtmlUnit: Используйте этот вариант, если хотите использовать сырые библиотеки HtmlUnit.

  • MockMvc и WebDriver: Используйте этот вариант для облегчения разработки и повторного использования кода между фазами интеграции и сквозного тестирования.

  • MockMvc и Geb: Используйте этот вариант, если необходимо использовать Groovy для тестирования, облегчить разработку и повторно использовать код между фазами интеграции и сквозного тестирования.

MockMvc и HtmlUnit

В данном разделе описано, как интегрировать MockMvc и HtmlUnit. Используйте этот вариант, если хотите задействовать сырые библиотеки HtmlUnit.

Настройка MockMvc и HtmlUnit

Во-первых, убедитесь, что вы активировали тестовую зависимость от net.sourceforge.htmlunit:htmlunit. Чтобы использовать HtmlUnit с Apache HttpComponents 4.5+, необходимо использовать HtmlUnit 2.18 или выше.

Можно легко создать WebClient из HtmlUnit, который будет интегрирован с MockMvc, используя MockMvcWebClientBuilder, как это показано ниже:

Java
WebClient webClient;
@BeforeEach
void setup(WebApplicationContext context) {
    webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context)
            .build();
}
Kotlin
lateinit var webClient: WebClient
@BeforeEach
fun setup(context: WebApplicationContext) {
    webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context)
            .build()
}
Это простой пример использования MockMvcWebClientBuilder. Для расширенного использования см. раздел "Расширенное использованиеMockMvcWebClientBuilder".

Таким образом можно гарантировать, что любой URL-адрес, ссылающийся на localhost в качестве сервера, будет направлен на наш экземпляр MockMvc без необходимости в реальном HTTP-соединении. Запрос любого другого URL-адреса происходит с помощью сетевого подключения, как обычно. Это позволяет нам легко тестировать использование сетей доставки (и дистрибуции) содержимого (CDN).

Использование MockMvc и HtmlUnit

Теперь мы можем стандартно использовать HtmlUnit, но без необходимости развертывания нашего приложения в контейнере сервлетов. Например, можно запросить представление создать сообщение следующим образом:

Java
HtmlPage createMsgFormPage = webClient.getPage("http://localhost/messages/form");
Kotlin
val createMsgFormPage = webClient.getPage("http://localhost/messages/form")
Путь к контексту по умолчанию - "". Кроме того, можно указать путь к контексту, как это описано в разделе "Расширенное использование MockMvcWebClientBuilder".

Получив ссылку на HtmlPage, мы сразу же можем заполнить форму и отправить ее для создания сообщения, как это показано в следующем примере:

Java
HtmlForm form = createMsgFormPage.getHtmlElementById("messageForm");
HtmlTextInput summaryInput = createMsgFormPage.getHtmlElementById("summary");
summaryInput.setValueAttribute("Spring Rocks");
HtmlTextArea textInput = createMsgFormPage.getHtmlElementById("text");
textInput.setText("In case you didn't know, Spring Rocks!");
HtmlSubmitInput submit = form.getOneHtmlElementByAttribute("input", "type", "submit");
HtmlPage newMessagePage = submit.click();
Kotlin
val form = createMsgFormPage.getHtmlElementById("messageForm")
val summaryInput = createMsgFormPage.getHtmlElementById("summary")
summaryInput.setValueAttribute("Spring Rocks")
val textInput = createMsgFormPage.getHtmlElementById("text")
textInput.setText("In case you didn't know, Spring Rocks!")
val submit = form.getOneHtmlElementByAttribute("input", "type", "submit")
val newMessagePage = submit.click()

Наконец, можно убедиться, что новое сообщение было успешно создано. Следующие утверждения используют библиотеку AssertJ:

Java
assertThat(newMessagePage.getUrl().toString()).endsWith("/messages/123");
String id = newMessagePage.getHtmlElementById("id").getTextContent();
assertThat(id).isEqualTo("123");
String summary = newMessagePage.getHtmlElementById("summary").getTextContent();
assertThat(summary).isEqualTo("Spring Rocks");
String text = newMessagePage.getHtmlElementById("text").getTextContent();
assertThat(text).isEqualTo("In case you didn't know, Spring Rocks!");
Kotlin
assertThat(newMessagePage.getUrl().toString()).endsWith("/messages/123")
val id = newMessagePage.getHtmlElementById("id").getTextContent()
assertThat(id).isEqualTo("123")
val summary = newMessagePage.getHtmlElementById("summary").getTextContent()
assertThat(summary).isEqualTo("Spring Rocks")
val text = newMessagePage.getHtmlElementById("text").getTextContent()
assertThat(text).isEqualTo("In case you didn't know, Spring Rocks!")

Предыдущий код улучшает наш тест MockMvc несколькими способами. Во-первых, больше не нужно явным образом проверять нашу форму, а затем создавать запрос, который выглядит как форма. Вместо этого запрашиваем форму, заполняем ее и отправляем, тем самым значительно снижая задержку из-за обработки.

Еще одним важным фактором является то, что HtmlUnit использует движок Mozilla Rhino для вычисления результатов работы JavaScript. Это означает, что можно также тестировать логику работы JavaScript на наших страницах.

Дополнительную информацию об использовании HtmlUnit см. в документации по использованию HtmlUnit.

Расширенное использование MockMvcWebClientBuilder

До сих пор в примерах мы использовали MockMvcWebClientBuilder самым простым способом, создавая WebClient на основе WebApplicationContext, загруженного для нас Spring TestContext Framework. Этот подход повторен в следующем примере:

Java
WebClient webClient;
@BeforeEach
void setup(WebApplicationContext context) {
    webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context)
            .build();
}
Kotlin
lateinit var webClient: WebClient
@BeforeEach
fun setup(context: WebApplicationContext) {
    webClient = MockMvcWebClientBuilder
            .webAppContextSetup(context)
            .build()
}

Мы также можем указать дополнительные параметры конфигурации, как показано в следующем примере:

Java
WebClient webClient;
@BeforeEach
void setup() {
    webClient = MockMvcWebClientBuilder
        // демонстрирует применение MockMvcConfigurer (Spring Security)
        .webAppContextSetup(context, springSecurity())
        // только для примера – по умолчанию ""
        .contextPath("")
        // По умолчанию MockMvc используется только для localhost;
        // следующая часть будет использовать MockMvc также для example.com и example.org
        .useMockMvcForHosts("example.com","example.org")
        .build();
}
Kotlin
lateinit var webClient: WebClient
@BeforeEach
fun setup() {
    webClient = MockMvcWebClientBuilder
        // демонстрирует применение MockMvcConfigurer (Spring Security)
        .webAppContextSetup(context, springSecurity())
        // только для примера – по умолчанию ""
        .contextPath("")
        // По умолчанию MockMvc используется только для localhost;
        // следующая часть будет использовать MockMvc также для example.com и example.org
        .useMockMvcForHosts("example.com","example.org")
        .build()
}

В качестве альтернативы можно выполнить точно такую же настройку, сконфигурировав экземпляр MockMvc отдельно и передав его в MockMvcWebClientBuilder, как показано ниже:

Java
MockMvc mockMvc = MockMvcBuilders
        .webAppContextSetup(context)
        .apply(springSecurity())
        .build();
webClient = MockMvcWebClientBuilder
        .mockMvcSetup(mockMvc)
        // только для примера – по умолчанию ""
        .contextPath("")
        // По умолчанию MockMvc используется только для localhost;
        // следующая часть будет использовать MockMvc также для example.com и example.org
        .useMockMvcForHosts("example.com","example.org")
        .build();
Kotlin
// Невозможно в Kotlin,пока https://youtrack.jetbrains.com/issue/KT-22208 не будет исправлено

Это более перегруженный код, но, создавая WebClient с помощью экземпляра MockMvc, мы получаем весь функционал MockMvc в свое распоряжение.

Дополнительную информацию о создании экземпляра MockMvc можно найти в разделе "Параметры настройки".

MockMvc и WebDriver

В предыдущих разделах мы ознакомились с использованием MockMvc в сочетании с сырыми API-интерфейсами HtmlUnit. В этом разделе использованы дополнительные абстракции в Selenium WebDriver, что делает все еще проще.

Почему WebDriver и MockMvc?

Мы уже можем использовать HtmlUnit и MockMvc, так зачем нам WebDriver? Selenium WebDriver предоставляет крайне изящный API-интерфейс, который позволит нам легко организовать наш код. Чтобы лучше показать, как это работает, в этом разделе мы рассмотрим пример.

Несмотря на то, что WebDriver является частью Selenium, он не требует наличия Selenium Server для запуска ваших тестов.

Предположим, нам нужно убедиться, что сообщение создано правильно. Тесты включают в себя поиск элементов ввода HTML-формы, их заполнение и выполнение различных утверждений.

Такой подход выливается в многочисленные отдельные тесты, поскольку нам нужно проверить также и условия возникновения ошибок. Например, нам нужно, чтобы при заполнении только части формы выдавалась ошибка. Если мы заполним всю форму, то после этого должно появиться вновь созданное сообщение.

Если бы одно из полей было названо "summary", мы могли бы получить нечто похожее на то, что приведено ниже, повторяющееся в нескольких местах в наших тестах:

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

Что произойдет, если мы изменим id на smmry? Это заставит нас обновить все наши тесты, чтобы учесть данное изменение. Это нарушает принцип "не повторяйся" (DRY), поэтому в идеале мы должны выделить этот код в отдельный метод, как показано ниже:

Java
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);
}
Kotlin
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)
}

Так нам гарантированно не придется обновлять все наши тесты, если мы изменим пользовательский интерфейс.

Можно даже пойти еще дальше и поместить эту логику в Object, представляющий HtmlPage, на которой мы сейчас находимся, как показано в следующем примере:

Java
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());
    }
}
Kotlin
    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 предоставляет некоторые инструменты, значительно упрощающие реализацию этого шаблона, и мы рассмотрим их в следующих разделах.

Настройка MockMvc и WebDriver

Чтобы использовать Selenium WebDriver с фреймворком Spring MVC Test, удостоверьтесь, что ваш проект содержит тестовую зависимость от org.seleniumhq.selenium:selenium-htmlunit-driver.

Можно легко создать Selenium WebDriver, который интегрируется с MockMvc, используя MockMvcHtmlUnitDriverBuilder, как показано в следующем примере:

Java
WebDriver driver;
@BeforeEach
void setup(WebApplicationContext context) {
    driver = MockMvcHtmlUnitDriverBuilder
            .webAppContextSetup(context)
            .build();
}
Kotlin
lateinit var driver: WebDriver
@BeforeEach
fun setup(context: WebApplicationContext) {
    driver = MockMvcHtmlUnitDriverBuilder
            .webAppContextSetup(context)
            .build()
}
Это простой пример использования MockMvcHtmlUnitDriverBuilder. Для ознакомления с возможностями расширенного использования см. раздел "Расширенное использование MockMvcHtmlUnitDriverBuilder".

В предыдущем примере любой URL-адрес, ссылающийся на localhost в качестве сервера, гарантированно будет направлен на наш экземпляр MockMvc без необходимости в реальном HTTP-соединении. Запрос любого другого URL-адреса происходит с помощью сетевого подключения, как обычно. Это позволяет нам легко тестировать использование сетей доставки (и дистрибуции) содержимого (CDN).

Использование MockMvc и WebDriver

Теперь мы можем стандартно использовать WebDriver, но без необходимости развертывания нашего приложения в контейнере сервлетов. Например, можно запросить представление создать сообщение следующим образом:

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

Затем можно заполнить форму и отправить ее, чтобы создать сообщение, следующим образом:

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

Это улучшит структуру нашего теста HtmlUnit за счет использования шаблона Page Object Pattern. Как уже было упомянуто в подразделе "Почему WebDriver и MockMvc?", мы можем использовать шаблон Page Object Pattern с HtmlUnit, но с WebDriver это всё еще проще. Рассмотрим следующую реализацию CreateMessagePage:

Java
public class CreateMessagePage
        extends AbstractPage { 
    
    private WebElement summary;
    private WebElement text;
    
    @FindBy(css = "input[type=submit]")
    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. PageFactory из WebDriver позволяет нам убрать много лишнего кода из HtmlUnit-версии CreateMessagePage, автоматически разрешив каждый WebElement. Метод PageFactory#initElements(WebDriver,Class<T>) автоматически разрешает каждый WebElement, используя имя поля и осуществляя его поиск по id или name элемента в HTML-странице.
  3. Можно использовать аннотацию @FindBy чтобы переопределить логику работы поиска по умолчанию. В нашем примере показано, как использовать аннотацию @FindBy для поиска нашей кнопки отправки с помощью селектора css(input[type=submit]).
Kotlin
class CreateMessagePage(private val driver: WebDriver) : AbstractPage(driver) { 
    
    private lateinit var summary: WebElement
    private lateinit var text: WebElement
    
    @FindBy(css = "input[type=submit]")
    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. PageFactory из WebDriver позволяет нам убрать много лишнего кода из HtmlUnit-версии CreateMessagePage, автоматически разрешив каждый WebElement. Метод PageFactory#initElements(WebDriver,Class<T>) автоматически разрешает каждый WebElement, используя имя поля и осуществляя его поиск по id или name элемента в HTML-странице.
  3. Можно использовать аннотацию @FindBy чтобы переопределить логику работы поиска по умолчанию. В нашем примере показано, как использовать аннотацию @FindBy для поиска нашей кнопки отправки с помощью селектора css(input[type=submit]).

Наконец, можно убедиться, что новое сообщение было успешно создано. Следующие утверждения используют библиотеку утверждений AssertJ:

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

Мы видим, что наша страница ViewMessagePage позволяет нам взаимодействовать с нашей специальной моделью предметной области. Например, она открывает метод, который возвращает объект Message:

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

Затем можно использовать полнофункциональные объекты предметной области в наших утверждениях.

Наконец, не нужно забывать закрывать экземпляр WebDriver по завершении теста, как показано ниже:

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

Дополнительную информацию об использовании WebDriver см. в документации по Selenium WebDriver.

Расширенное использование MockMvcHtmlUnitDriverBuilder

До сих пор в примерах мы использовали MockMvcHtmlUnitDriverBuilder самым простым способом, создавая WebDriver на основе WebApplicationContext, загруженного для нас Spring TestContext Framework. Этот подход повторен здесь следующим образом:

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

Мы также можем указать дополнительные параметры конфигурации, как показано ниже:

Java
WebDriver driver;
@BeforeEach
void setup() {
    driver = MockMvcHtmlUnitDriverBuilder
            // демонстрирует применение MockMvcConfigurer (Spring Security)
            .webAppContextSetup(context, springSecurity())
            // только для примера – по умолчанию ""
            .contextPath("")
            // По умолчанию MockMvc используется только для localhost;
            // следующая часть будет использовать MockMvc также для example.com и example.org
            .useMockMvcForHosts("example.com","example.org")
            .build();
}
Kotlin
lateinit var driver: WebDriver
@BeforeEach
fun setup() {
    driver = MockMvcHtmlUnitDriverBuilder
            // демонстрирует применение MockMvcConfigurer (Spring Security)
            .webAppContextSetup(context, springSecurity())
            // только для примера – по умолчанию ""
            .contextPath("")
            // По умолчанию MockMvc используется только для localhost;
            // следующая часть будет использовать MockMvc также для example.com и example.org
            .useMockMvcForHosts("example.com","example.org")
            .build()
}

В качестве альтернативы можно выполнить точно такую же настройку, сконфигурировав экземпляр MockMvc отдельно и передав его в MockMvcHtmlUnitDriverBuilder следующим образом:

Java
MockMvc mockMvc = MockMvcBuilders
        .webAppContextSetup(context)
        .apply(springSecurity())
        .build();
driver = MockMvcHtmlUnitDriverBuilder
        .mockMvcSetup(mockMvc)
        // только для примера – по умолчанию ""
        .contextPath("")
        // По умолчанию MockMvc используется только для localhost;
        // следующая часть будет использовать MockMvc также для example.com и example.org
        .useMockMvcForHosts("example.com","example.org")
        .build();
Kotlin
// Невозможно в Kotlin,пока https://youtrack.jetbrains.com/issue/KT-22208 не будет исправлено

Это более перегруженный код, но, создавая WebDriver с помощью экземпляра MockMvc, мы получаем весь функционал MockMvc в свое распоряжение.

Дополнительную информацию о создании экземпляра MockMvc можно найти в разделе "Параметры настройки".

MockMvc и Geb

В предыдущем разделе мы рассмотрели, как использовать MockMvc с WebDriver. В этом разделе мы используем Geb, чтобы сделать наши тесты еще более Groovy-ориентированными.

Почему Geb и MockMvc?

Geb поддерживается WebDriver, поэтому он дает многие из тех же преимуществ, которые мы получаем от WebDriver. Однако Geb еще сильнее все упрощает благодаря тому, что работает со стереотипным кодом вместо нас.

Настройка MockMvc и Geb

Можно легко инициализировать Browser из Geb с помощью Selenium WebDriver, который использует MockMvc, следующим образом:

def setup() {
    browser.driver = MockMvcHtmlUnitDriverBuilder
        .webAppContextSetup(context)
        .build()
}
Это простой пример использования MockMvcHtmlUnitDriverBuilder. Для ознакомления с возможностями расширенного использования см. раздел "Расширенное использование MockMvcHtmlUnitDriverBuilder".

Таким образом можно гарантировать, что любой URL-адрес, ссылающийся на localhost в качестве сервера, будет направлен на наш экземпляр MockMvc без необходимости в реальном HTTP-соединении. Запрос любого другого URL-адреса происходит с помощью сетевого подключения, как обычно. Это позволяет нам легко тестировать использование сетей доставки (и дистрибуции) содержимого (CDN).

Использование MockMvc и Geb

Теперь мы можем стандартно использовать Geb, но без необходимости развертывания нашего приложения в контейнере сервлетов. Например, можно запросить представление создать сообщение следующим образом:

to CreateMessagePage

Затем можно заполнить форму и отправить ее, чтобы создать сообщение, следующим образом:

when:
form.summary = expectedSummary
form.text = expectedMessage
submit.click(ViewMessagePage)

Любые нераспознанные вызовы методов, обращения к свойствам или ссылки, которые не были найдены, передаются текущему объекту страницы. Это позволяет избавиться от большого объема стереотипного кода, необходимого при прямом использовании WebDriver.

Как и при прямом использовании WebDriver, так мы улучшаем структуру нашего теста HtmlUnit за счет использования шаблона Page Object Pattern. Как упоминалось ранее, можно использовать шаблон Page Object Pattern с HtmlUnit и WebDriver, но с Geb всё становится еще. Рассмотрим нашу новую реализацию CreateMessagePage на основе Groovy:

class CreateMessagePage extends Page {
    static url = 'messages/form'
    static at = { assert title == 'Messages : Create'; true }
    static content =  {
        submit { $('input[type=submit]') }
        form { $('form') }
        errors(required:false) { $('label.error, .alert-error')?.text() }
    }
}

Наш CreateMessagePage расширяет AbstractPage. Не будем подробно останавливаться на Page, но, если вкратце, она содержит общую функциональность для всех наших страниц. Мы определяем URL-адрес, по которому можно найти эту страницу. Это позволяет нам перейти на страницу, как показано ниже:

to CreateMessagePage

У нас также есть замыкание at, которое определяет, находимся ли мы на указанной странице. Оно должно возвращать true, если мы находимся на правильной странице. Вот поэтому мы можем подтвердить, что находимся на правильной странице, следующим образом:

then:
at CreateMessagePage
errors.contains('This field is required.')
Используем утверждение в замыкании, чтобы можно было определить, в каком месте что-то пошло не так, если мы окажемся на неправильной странице.

Далее мы создаем замыкание content, в котором указываются все представляющие интерес места на странице. Можно использовать jQuery-подобный API-интерфейс навигатора для выбора интересующего нас содержимого.

Наконец, мы можем убедиться, что новое сообщение было успешно создано, следующим образом:

then:
at ViewMessagePage
success == 'Successfully created a new message'
id
date
summary == expectedSummary
message == expectedMessage

Более подробную информацию о том, как выжать максимум из Geb, можно найти в руководстве пользователя под названием "The Book of Geb".