Тестовый фреймворк Spring MVC Test, также известный как MockMvc, обеспечивает поддержку тестирования приложений Spring MVC. Он выполняет полную обработку запросов Spring MVC через имитируемые объекты-запросы и объекты-ответов вместо работающего сервера.

MockMvc можно использовать отдельно для выполнения запросов и проверки ответов. Его также можно использовать через WebTestClient, где MockMvc подключен в качестве сервера для обработки запросов. Преимуществом WebTestClient является возможность работы с объектами более высокого уровня вместо сырых данных, а также возможность перехода к полноценным, сквозным HTTP-тестам на реальном сервере с использованием того же API-интерфейса тестирования.

Краткое описание

Можно писать простые модульные тесты для Spring MVC, создавая экземпляры контроллера, подключая к нему зависимости и вызывая его методы. Однако такие тесты не проверяют сопоставления запросов, привязку данных, преобразование сообщений, преобразование типов, валидацию, а также не задействуют ни один из вспомогательных методов, аннотированных @InitBinder, @ModelAttribute или @ExceptionHandler.

Фреймворк Spring MVC Test, также известный как MockMvc, призван обеспечить более полное тестирование контроллеров Spring MVC без работающего сервера. Он делает это, вызывая DispatcherServlet и передавая имитируемые реализации API-интерфейса сервлетов из модуля spring-test, который воспроизводит полную обработку запросов Spring MVC без работающего сервера.

MockMvc – это тестовый фреймворк на стороне сервера, который позволяет проверять большинство функциональных возможностей приложения Spring MVC с помощью облегченных и целевых тестов. Можно использовать его отдельно для выполнения запросов и проверки ответов, а также можете использовать его через API-интерфейс WebTestClient с подключенным MockMvc в качестве сервера для обработки запросов.

Статическое импортирование

При использовании MockMvc непосредственно для выполнения запросов вам необходимо будет выполнить статическое импортирование:

  • MockMvcBuilders.*

  • MockMvcRequestBuilders.*

  • MockMvcResultMatchers.*

  • MockMvcResultHandlers.*

Простой способ запомнить их – осуществлять поиск по MockMvc*. Если вы пользуетесь Eclipse, не забудьте также добавить вышеуказанные статические члены в "избранные" в настройках Eclipse.

При использовании MockMvc через WebTestClient вам не понадобится статическое импортирование. WebTestClient предоставляет текучий (в значении "плавный") API-интерфейс без статического импортирования.

Параметры настройки

MockMvc может быть настроен одним из двух способов. Один из них – указать непосредственно на контроллеры, которые нужно протестировать, и программно настроить инфраструктуру Spring MVC. Второй – указать на конфигурацию Spring при использовании фреймворка Spring MVC и инфраструктуры контроллера в нем.

Чтобы настроить MockMvc для тестирования конкретного контроллера, используйте следующее:

Java
class MyWebTests {
    MockMvc mockMvc;
    @BeforeEach
    void setup() {
        this.mockMvc = MockMvcBuilders.standaloneSetup(new AccountController()).build();
    }
    // ...
}
Kotlin
class MyWebTests {
    lateinit var mockMvc : MockMvc
    @BeforeEach
    fun setup() {
        mockMvc = MockMvcBuilders.standaloneSetup(AccountController()).build()
    }
    // ...
}

Или вы также можете использовать эту настройку при тестировании через WebTestClient, который делегирует полномочия тому же средству сборки, как это показано выше.

Чтобы настроить MockMvc через конфигурацию Spring, используйте следующее:

Java
@SpringJUnitWebConfig(locations = "my-servlet-context.xml")
class MyWebTests {
    MockMvc mockMvc;
    @BeforeEach
    void setup(WebApplicationContext wac) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
    }
    // ...
}
Kotlin
@SpringJUnitWebConfig(locations = ["my-servlet-context.xml"])
class MyWebTests {
    lateinit var mockMvc: MockMvc
    @BeforeEach
    fun setup(wac: WebApplicationContext) {
        mockMvc = MockMvcBuilders.webAppContextSetup(wac).build()
    }
    // ...
}

Или вы также можете использовать эту настройку при тестировании через WebTestClient который делегирует полномочия тому же средству сборки, как это показано выше.

Какой вариант настройки следует использовать?

В webAppContextSetup загружается реальная конфигурация Spring MVC, что позволяет получить более полный интеграционный тест. Поскольку фреймворк TestContext кэширует загруженную конфигурацию Spring, он помогает осуществлять быстрое выполнение тестов, даже если вы вводите все больше тестов в свой тестовый комплект. Более того, можно внедрять имитируемые службы в контроллеры с помощью конфигурации Spring, чтобы сосредоточиться на тестировании веб-уровня. В следующем примере имитируемая служба объявлена с помощью Mockito:

<bean id="accountService" class="org.mockito.Mockito" factory-method="mock">
    <constructor-arg value="org.example.AccountService"/>
</bean>

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

Java
@SpringJUnitWebConfig(locations = "test-servlet-context.xml")
class AccountTests {
    @Autowired
    AccountService accountService;
    MockMvc mockMvc;
    @BeforeEach
    void setup(WebApplicationContext wac) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();
    }
    // ...
}
Kotlin
@SpringJUnitWebConfig(locations = ["test-servlet-context.xml"])
class AccountTests {
    @Autowired
    lateinit var accountService: AccountService
    lateinit mockMvc: MockMvc
    @BeforeEach
    fun setup(wac: WebApplicationContext) {
        mockMvc = MockMvcBuilders.webAppContextSetup(wac).build()
    }
    // ...
}

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

Как и в большинстве споров на тему "интеграционное тестирование против модульного тестирования", здесь нет правильного или неправильного ответа. Однако использование standaloneSetup подразумевает необходимость дополнительных тестов с использованием webAppContextSetup для проверки конфигурации Spring MVC. Кроме того, можно написать все тесты с использованием webAppContextSetup, чтобы всегда проводить тестирование на реальной конфигурации Spring MVC.

Особенности настройки

Независимо от того, какое средство сборки из MockMvc вы используете, все реализации MockMvcBuilder предоставят некоторые общие и крайне полезные функции. Например, вы сможете объявить заголовок Accept для всех запросов и принять статус 200, а также заголовок Content-Type во всех ответах, как показано ниже:

Java
// статический импорт MockMvcBuilders.standaloneSetup
MockMvc mockMvc = standaloneSetup(new MusicController())
    .defaultRequest(get("/").accept(MediaType.APPLICATION_JSON))
    .alwaysExpect(status().isOk())
    .alwaysExpect(content().contentType("application/json;charset=UTF-8"))
    .build();
Kotlin
// Невозможно в Kotlin,пока не будет исправлено

Кроме того, сторонние фреймворки (и приложения) могут предварительно упаковывать инструкции по настройке, например, в MockMvcConfigurer. Spring Framework имеет одну из таких встроенных реализаций, которая помогает сохранять и повторно использовать HTTP-сессию во всех запросах. Вы можете использовать её следующим образом:

Java
// статический импорт SharedHttpSessionConfigurer.sharedHttpSession
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
        .apply(sharedHttpSession())
        .build();
// Импортируем mockMvc для выполнения запросов...
Kotlin
// Невозможно в Kotlin,пока не будет исправлено

См. javadoc по ConfigurableMockMvcBuilder для ознакомления со списком всех возможностей средства сборки из MockMvc или используйте IDE для изучения доступных опций.

Выполнение запросов

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

Чтобы выполнить запросы, использующие любой метод HTTP, опирайтесь на пример, показанный ниже:

Java
// статический импорт MockMvcRequestBuilders.*.
mockMvc.perform(post("/hotels/{id}", 42).accept(MediaType.APPLICATION_JSON));
Kotlin
import org.springframework.test.web.servlet.post
mockMvc.post("/hotels/{id}", 42) {
    accept = MediaType.APPLICATION_JSON
}

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

Java
mockMvc.perform(multipart("/doc").file("a1", "ABC".getBytes("UTF-8")));
Kotlin
import org.springframework.test.web.servlet.multipart
mockMvc.multipart("/doc") {
    file("a1", "ABC".toByteArray(charset("UTF8")))
}

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

Java
mockMvc.perform(get("/hotels?thing={thing}", "somewhere"));
Kotlin
mockMvc.get("/hotels?thing={thing}", "somewhere")

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

Java
mockMvc.perform(get("/hotels").param("thing", "somewhere"));
Kotlin
import org.springframework.test.web.servlet.get
mockMvc.get("/hotels") {
    param("thing", "somewhere")
}

Если код приложения использует параметры запроса сервлета и не проверяет строку запроса явным образом (что чаще всего и происходит), то не имеет значения, какой вариант вы используете. Однако следует помнить, что параметры запроса, предоставленные с помощью шаблона URI-идентификатора, декодируются, в то время как параметры запроса, предоставленные через метод param(…), как ожидается, уже декодированы.

В большинстве случаев предпочтительнее не указывать в URI-идентификаторе запроса путь к контексту и путь к сервлету. Если вам требуется проводить тестирование с полным URI-идентификатором запроса, не забудьте установить contextPath и servletPath соответствующим образом, чтобы сопоставление запросов работало, как показано в следующем примере:

Java
mockMvc.perform(get("/app/main/hotels/{id}").contextPath("/app").servletPath("/main"))
Kotlin
import org.springframework.test.web.servlet.get
mockMvc.get("/app/main/hotels/{id}") {
    contextPath = "/app"
    servletPath = "/main"
}

В вышеприведенном примере было бы неудобно устанавливать contextPath и servletPath при каждом выполненном запросе. Вместо этого можно установить свойства запроса по умолчанию, как показано в следующем примере:

Java
class MyWebTests {
    MockMvc mockMvc;
    @BeforeEach
    void setup() {
        mockMvc = standaloneSetup(new AccountController())
            .defaultRequest(get("/")
            .contextPath("/app").servletPath("/main")
            .accept(MediaType.APPLICATION_JSON)).build();
    }
}
Kotlin
// Невозможно в Kotlin,пока не будет исправлено

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

Определение ожидаемых событий

Вы можете определить ожидаемые события, добавив один или несколько вызовов andExpect(..) после выполнения запроса, как показано в следующем примере. Если какое-либо одно ожидаемое событие не произойдет, другие ожидаемые события не будут подтверждены.

Java
// статический импорт MockMvcRequestBuilders.*. and MockMvcResultMatchers.*
mockMvc.perform(get("/accounts/1")).andExpect(status().isOk());
Kotlin
import org.springframework.test.web.servlet.get
mockMvc.get("/accounts/1").andExpect {
    status { isOk() }
}

Вы можете определять несколько ожидаемых событий, добавив andExpectAll(..) после выполнения запроса, как показано в следующем примере. В отличие от andExpect(..), andExpectAll(..) гарантирует, что все указанные ожидаемые события будут подтверждены, а все несостоявшиеся будут отслежены и сообщены.

Java
// статический импорт MockMvcRequestBuilders.*. and MockMvcResultMatchers.*
mockMvc.perform(get("/accounts/1")).andExpectAll(
    status().isOk(),
    content().contentType("application/json;charset=UTF-8"));

MockMvcResultMatchers.* указывает ряд ожидаемых событий, некоторые из которых далее вложены в более подробные ожидаемые события.

Ожидаемые события делятся на две общие категории. Первая категория утверждений проверяет свойства ответа (например, статус ответа, заголовки и содержимое). Это самые важные результаты, которые можно подтверждать.

Вторая категория утверждений выходит за рамки ответа. Эти утверждения позволяют проверять специфические аспекты Spring MVC, такие как то, какой метод контроллера обработал запрос, было ли вызвано и обработано исключение, каково содержимое модели, какое представление было выбрано, какие flash-атрибуты были добавлены и так далее. Они также позволяют проверять специфические аспекты сервлетов, такие как атрибуты запроса и сессии.

Следующий тест подтверждает, что привязка или валидация не удались:

Java
mockMvc.perform(post("/persons"))
    .andExpect(status().isOk())
    .andExpect(model().attributeHasErrors("person"));
Kotlin
import org.springframework.test.web.servlet.post
mockMvc.post("/persons").andExpect {
    status { isOk() }
    model {
        attributeHasErrors("person")
    }
}

Зачастую при написании тестов полезно выгрузить результаты выполненного запроса. Сделать это можно следующим способом, где print() – это статический импортируемый элемент из MockMvcResultHandlers:

Java
mockMvc.perform(post("/persons"))
    .andDo(print())
    .andExpect(status().isOk())
    .andExpect(model().attributeHasErrors("person"));
Kotlin
import org.springframework.test.web.servlet.post
mockMvc.post("/persons").andDo {
        print()
    }.andExpect {
        status { isOk() }
        model {
            attributeHasErrors("person")
        }
    }

До тех пор, пока обработка запроса не вызовет необрабатываемое исключение, метод print() будет выводить все доступные данные результата в System.out. Существует также метод log() и два дополнительных варианта метода print(), один из которых принимает OutputStream, а другой - Writer. Например, вызов print(System.err) выводит данные результата в System.err, а вызов print(myWriter) выводит данные результата в специальный метод записи (writer). Если нужно, чтобы данные результата не выводились, а записывались в журнал, вы можете вызвать метод log(), который записывает данные результата в виде одного сообщения DEBUG в категории журналирования org.springframework.test.web.servlet.result.

В некоторых случаях вам может понадобиться получить прямой доступ к результату и проверить то, что невозможно проверить иным способом. Это можно осуществить, добавив .andReturn() после всех остальных ожидаемых событий, как показано в следующем примере:

Java
MvcResult mvcResult = mockMvc.perform(post("/persons")).andExpect(status().isOk()).andReturn();
// ...
Kotlin
var mvcResult = mockMvc.post("/persons").andExpect { status { isOk() } }.andReturn()
// ...

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

Java
standaloneSetup(new SimpleController())
    .alwaysExpect(status().isOk())
    .alwaysExpect(content().contentType("application/json;charset=UTF-8"))
    .build()
Kotlin
// Невозможно в Kotlin,пока не будет исправлено

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

Если содержимое JSON-ответа содержит гипермедийные ссылки, созданные с помощью Spring HATEOAS, вы можете проверить полученные ссылки с помощью выражений JsonPath, как показано в следующем примере:

Java
mockMvc.perform(get("/people").accept(MediaType.APPLICATION_JSON))
    .andExpect(jsonPath("$.links[?(@.rel == 'self')].href").value("http://localhost:8080/people"));
Kotlin
mockMvc.get("/people") {
    accept(MediaType.APPLICATION_JSON)
}.andExpect {
    jsonPath("$.links[?(@.rel == 'self')].href") {
        value("http://localhost:8080/people")
    }
}

Если содержимое XML-ответа содержит гипермедийные ссылки, созданные с помощью Spring HATEOAS, вы можете проверить полученные ссылки с помощью выражений XPath:

Java
Map<String, String> ns = Collections.singletonMap("ns", "http://www.w3.org/2005/Atom");
mockMvc.perform(get("/handle").accept(MediaType.APPLICATION_XML))
    .andExpect(xpath("/person/ns:link[@rel='self']/@href", ns).string("http://localhost:8080/people"));
Kotlin
val ns = mapOf("ns" to "http://www.w3.org/2005/Atom")
mockMvc.get("/handle") {
    accept(MediaType.APPLICATION_XML)
}.andExpect {
    xpath("/person/ns:link[@rel='self']/@href", ns) {
        string("http://localhost:8080/people")
    }
}

Асинхронные запросы

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

Асинхронные запросы Servlet 3.0, поддерживаемые в Spring MVC, работают путем выхода из потока контейнера сервлетов, тем самым позволяя приложению вычислить ответ асинхронно, после чего выполняется асинхронная диспетчеризация для завершения обработки в потоке контейнера сервлетов.

В Spring MVC Test асинхронные запросы можно протестировать, сначала подтвердив созданное асинхронное значение, затем вручную выполнив асинхронную диспетчеризацию и, наконец, проверив ответ. Ниже приведен пример теста для методов контроллера, которые возвращают DeferredResult, Callable или реактивный тип, такой как Mono из Reactor:

Java
// статический импорт MockMvcRequestBuilders.*. and MockMvcResultMatchers.*
@Test
void test() throws Exception {
    MvcResult mvcResult = this.mockMvc.perform(get("/path"))
            .andExpect(status().isOk()) 
            .andExpect(request().asyncStarted()) 
            .andExpect(request().asyncResult("body")) 
            .andReturn();
    this.mockMvc.perform(asyncDispatch(mvcResult)) 
            .andExpect(status().isOk()) 
            .andExpect(content().string("body"));
}
  1. Статус проверки ответа остается неизменным
  2. Должна начаться асинхронная обработка
  3. Ждем и подтверждаем результат асинхронной обработки
  4. Вручную выполняем асинхронную диспетчеризацию (поскольку нет запущенного контейнера)
  5. Проверяем окончательный ответ
Kotlin
@Test
fun test() {
    var mvcResult = mockMvc.get("/path").andExpect {
        status { isOk() } 
        request { asyncStarted() } 
        // TODO Remove unused generic parameter
        request { asyncResult<Nothing>("body") } 
    }.andReturn()
    mockMvc.perform(asyncDispatch(mvcResult)) 
            .andExpect {
                status { isOk() } 
                content().string("body")
            }
}
  1. Статус проверки ответа остается неизменным
  2. Должна начаться асинхронная обработка
  3. Ждем и подтверждаем результат асинхронной обработки
  4. Вручную выполняем асинхронную диспетчеризацию (поскольку нет запущенного контейнера)
  5. Проверяем окончательный ответ

Потоковые ответы

Лучшим способом тестирования потоковых ответов, таких как события, посылаемые сервером (Server-Sent Events), является WebTestClient, который можно использовать в качестве тестового клиента, чтобы подключить к экземпляру MockMvc для выполнения тестов на контроллерах Spring MVC без работающего сервера. Например:

Java
WebTestClient client = MockMvcWebTestClient.bindToController(new SseController()).build();
FluxExchangeResult<Person> exchangeResult = client.get()
        .uri("/persons")
        .exchange()
        .expectStatus().isOk()
        .expectHeader().contentType("text/event-stream")
        .returnResult(Person.class);
// Используем StepVerifier из Project Reactor для тестирования потокового ответа
StepVerifier.create(exchangeResult.getResponseBody())
        .expectNext(new Person("N0"), new Person("N1"), new Person("N2"))
        .expectNextCount(4)
        .consumeNextWith(person -> assertThat(person.getName()).endsWith("7"))
        .thenCancel()
        .verify();

WebTestClient также может подключаться к реальному серверу и выполнять полные сквозные интеграционные тесты. Подобное так же поддерживается в Spring Boot, где вы можете тестировать запущенный сервер.

Регистрация фильтров

При настройке экземпляра MockMvc можно зарегистрировать один или несколько экземпляров Filter сервлетов, как показано в следующем примере:

Java
mockMvc = standaloneSetup(new PersonController()).addFilters(new CharacterEncodingFilter()).build();
Kotlin
// Невозможно в Kotlin,пока не будет исправлено

Зарегистрированные фильтры вызываются через MockFilterChain из spring-test, а последний фильтр делегируется DispatcherServlet.

MockMvc против сквозных тестов

MockMvc построен на основе имитационных реализаций Servlet API из модуля spring-test и не зависит от запущенного контейнера. Поэтому существуют некоторые различия в сравнении с полными сквозными интеграционными тестами с реальным клиентом и реальным сервером.

Самый простой способ понять это – начать с пустого запроса MockHttpServletRequest. Что бы вы ни добавили к нему, это становится запросом. Вещи, которые могут застать вас врасплох: по умолчанию контекстного пути нет; нет cookie файла jsessionid; отсутствует перенаправление, ошибки или асинхронная диспетчеризация; и, следовательно, фактически отсутствует JSP-рендеринг (визуализация). Вместо этого "перенаправленные" и "переадресованные" URL-адреса сохраняются в MockHttpServletResponse, после чего их можно подтвердить ожидаемыми событиями.

Это значит, что если используется JSP, то можно проверить JSP-страницу, на которую был перенаправлен запрос, но HTML не будет визуализироваться. Другими словами, JSP не вызывается. Обратите внимание, однако, что любые другие технологии рендеринга, которые не полагаются на перенаправление, такие как Thymeleaf и Freemarker, передают HTML в тело ответа, как и ожидается. То же самое справедливо для рендеринга JSON, XML и других форматов с помощью методов, аннотированных @ResponseBody.

Кроме того, вы можете подумать над полной поддержкой сквозного интеграционного тестирования Spring Boot при помощи аннотации @SpringBootTest. См. "Справочное руководство по Spring Boot".

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

Еще одно важное различие при использовании теста в фреймворке Spring MVC Test заключается в том, что концептуально такие тесты являются серверными, поэтому у вас есть возможность проверить, какой дескриптор был использован, было ли исключение обработано с помощью HandlerExceptionResolver, каково содержимое модели, какие были ошибки привязывания и другие детали. Это означает, что написать ожидаемые события становится легче, поскольку сервер не непрозрачный ящик, как это бывает при тестировании через фактический HTTP-клиент. В целом преимущество классического модульного тестирования состоит в том, что: Такое тестирование легче писать, обосновывать и отлаживать, но оно не отменяет необходимость в полноценных интеграционных тестах. В то же время важно не упускать из виду, что ответ – это самое важное, что необходимо проверять. Одним словом, здесь есть простор для нескольких стилей и стратегий тестирования даже в рамках одного проекта.

Дополнительные примеры

Собственные тесты фреймворка включают множество тестовых примеров, призванных показать, как использовать MockMvc отдельно или через WebTestClient. Просмотрите эти примеры, чтобы почерпнуть новые идеи.