Spring Mock-MVC предоставляет отличные методы тестирования Spring Boot REST API. Mock-MVC позволяет нам тестировать обработку запросов Spring-MVC без запуска реального сервера.

Я использовал тесты Mock-MVC в различных проектах, и по моему опыту они часто бывают довольно многословными. Это не обязательно плохо. Однако это часто требует копирования/вставки однотипных фрагментов кода в тестовые классы. В этом посте мы рассмотрим несколько способов усовершенствования тестов Spring Mock-MVC.

Решите, что тестировать с Mock-MVC

Первый вопрос, который нам нужно задать, - это то, что мы хотим протестировать с помощью Mock-MVC. Вот некоторые примеры тестовых сценариев:

  • Тестирование только веб-слоя и эмулирование всех зависимостей контроллера.

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

  • Тестирование полного пути от веб-слоя до базы данных с помощью замены сторонних зависимостей встроенными альтернативами, если это возможно (например, H2 или embedded-Kafka )

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

  • Протестируйте как можно больше в стандартных JUnit тестах (без Spring). Это значительно улучшает производительность тестов и упрощает написание тестов.

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

При максимальном использовании стандартных JUnit тестов последний упомянутый выше сценарий часто подходит. После того, как мы протестировали всю логику с помощью быстрых модульных тестов, мы можем использовать несколько тестов Mock-MVC, чтобы убедиться, что все части работают вместе, от контроллера до базы данных.

Упрощение тестовой конфигурации с помощью пользовательских аннотаций

Spring позволяет нам объединять несколько аннотаций Spring в одну настраиваемую аннотацию.

Например, мы можем создать собственную аннотацию @MockMvcTest:

@SpringBootTest
@TestPropertySource(locations = "classpath:test.properties")
@AutoConfigureMockMvc(secure = false)
@Retention(RetentionPolicy.RUNTIME)
public @interface MockMvcTest {}

Теперь нашему тесту нужна только одна аннотация:

@MockMvcTest
public class MyTest {
    ...
}

Таким образом, мы можем очистить тесты от различных аннотаций. Это также полезно для стандартизации конфигурации Spring для наших тестовых сценариев.

Улучшение запросов Mock-MVC

Давайте рассмотрим следующий пример запроса Mock-MVC и посмотрим, как мы можем его улучшить:

mockMvc.perform(put("/products/42")
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaType.APPLICATION_JSON)
        .content("{\"name\": \"Cool Gadget\", \"description\": \"Looks cool\"}")
        .header("Authorization", getBasicAuthHeader("John", "secr3t")))
        .andExpect(status().isOk());

Он отправляет запрос PUT с некоторыми JSON данными и заголовком авторизации в /products/42.

Первое, что бросается в глаза - это фрагмент JSON в строке Java. Это, очевидно, проблема, поскольку экранирование двойных кавычек, требуемое строками Java, делает его трудно читаемым.

Обычно мы должны использовать объект, который затем конвертируется в JSON. Прежде чем мы рассмотрим этот подход, стоит упомянуть текстовые блоки. Блоки Java Text были представлены в JDK 13/14 в качестве функции предварительного просмотра. Текстовые блоки - это строки, которые занимают несколько строк и не требуют экранирования двойных кавычек.

С помощью текстового блока мы можем отформатировать встроенный JSON более красивым способом. Например:

mvc.perform(put("/products/42")
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaType.APPLICATION_JSON)
        .content("""
            {
                "name": "Cool Gadget",
                "description": "Looks cool"
            }
            """)
        .header("Authorization", getBasicAuthHeader("John", "secr3t")))
        .andExpect(status().isOk()); 

В определенных ситуациях это может быть полезно.

Однако мы, по-прежнему, должны отдать предпочтение объектам, преобразованным в JSON вместо того, чтобы вручную писать и поддерживать строки JSON.

Например:

Product product = new Product("Cool Gadget", "Looks cool");
mvc.perform(put("/products/42")
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaType.APPLICATION_JSON)
        .content(objectToJson(product))
        .header("Authorization", getBasicAuthHeader("John", "secr3t")))
        .andExpect(status().isOk());

Далее мы создаем объект product и конвертируем его в JSON с помощью небольшого вспомогательного метода objectToJson(..). Это немного помогает. Тем не менее, мы можем добиться большего.

В нашем запросе много элементов, которые можно сгруппировать. При создании JSON REST-API, вероятно, нам часто придется отправлять аналогичный запрос PUT. Поэтому мы создаем небольшой статический метод для быстрого доступа:

public static MockHttpServletRequestBuilder putJson(String uri, Object body) {
    try {
        String json = new ObjectMapper().writeValueAsString(body);
        return put(uri)
                .contentType(MediaType.APPLICATION_JSON)
                .accept(MediaType.APPLICATION_JSON)
                .content(json);
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
}

Этот метод преобразует параметр body в JSON с помощью Jackson ObjectMapper . Затем он создает запрос PUT и устанавливает заголовки Accept и Content-Type .

Этот метод значитеьно упрощает наш тестовый запрос:

Product product = new Product("Cool Gadget", "Looks cool");
mvc.perform(putJson("/products/42", product)
        .header("Authorization", getBasicAuthHeader("John", "secr3t")))
        .andExpect(status().isOk())

Хорошо то, что при этом мы не теряем гибкости. Наш метод putJson(..) возвращает MockHttpServletRequestBuilder. Это позволяет нам, при необходимости, добавлять дополнительные свойства запроса в тесты (например, заголовок авторизации в этом примере).

Заголовки аутентификации - еще один вопрос, с которым мы часто сталкиваемся при написании тестов Spring Mock-MVC. Однако мы не должны добавлять заголовки аутентификации к нашему предыдущему методу putJson(..). Даже если все запросы PUT требуют аутентификации, мы сохраним большую гибкость, если будем работать с аутентификацией по-другому.

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

Например:

public static RequestPostProcessor authentication() {
    return request -> {
        request.addHeader("Authorization", getBasicAuthHeader("John", "secr3t"));
        return request;
    };
} 

Метод authentication() возвращает RequestPostProcessor, который добавляет к запросу базовую аутентификацию. Мы можем применить этот RequestPostProcessor в нашем тесте с помощью метода with(..):

Product product = new Product("Cool Gadget", "Looks cool");
mvc.perform(putJson("/products/42", product).with(authentication()))
        .andExpect(status().isOk())

Это не только упрощает наш тестовый запрос. Если мы изменим формат заголовка запроса, нам теперь нужно изменить только один метод, чтобы исправить тесты. Кроме того, putJson(url, data).with(authentication()) также довольно удобен для чтения.

Улучшение проверки ответа

Теперь рассмотрим, как можно улучшить проверку ответов.

Начнем со следующего примера:

mvc.perform(get("/products/42"))
        .andExpect(status().isOk())
        .andExpect(header().string("Cache-Control", "no-cache"))
        .andExpect(jsonPath("$.name").value("Cool Gadget"))
        .andExpect(jsonPath("$.description").value("Looks cool"));

Здесь мы проверяем код состояния HTTP, убеждаемся, что для заголовка Cache-Control установлено значение no-cache, и используем выражения JSON-Path для проверки полезной содержания ответа.

В Cache-Control выглядит как заголовок, который, вероятно, понадобится для проверки различных ответов. В этом случае неплохо было бы придумать небольшой метод быстрого доступа:

public ResultMatcher noCacheHeader() {
    return header().string("Cache-Control", "no-cache");
}

Теперь мы можем применить проверку, передав noCacheHeader() в andExpect(..):

mvc.perform(get("/products/42"))
        .andExpect(status().isOk())
        .andExpect(noCacheHeader())
        .andExpect(jsonPath("$.name").value("Cool Gadget"))
        .andExpect(jsonPath("$.description").value("Looks cool"));

Тот же подход можно использовать для проверки тела ответа.

Например, мы можем создать небольшой метод product(..), который сравнивает ответ JSON с заданным объектом Product:

public static ResultMatcher product(String prefix, Product product) {
    return ResultMatcher.matchAll(
            jsonPath(prefix + ".name").value(product.getName()),
            jsonPath(prefix + ".description").value(product.getDescription())
    );
}

Теперь наш тест выглядит так:

Product product = new Product("Cool Gadget", "Looks cool");
mvc.perform(get("/products/42"))
        .andExpect(status().isOk())
        .andExpect(noCacheHeader())
        .andExpect(product("$", product));

Обратите внимание, что параметр prefix обеспечит нам гибкость. Объект, который мы хотим проверить, не всегда может находиться на корневом уровне JSON ответа.

Предположим, что запрос может вернуть коллекцию продуктов. Затем мы можем использовать параметр prefix для выбора каждого продукта в коллекции. Например:

Product product0 = ..
Product product1 = ..
mvc.perform(get("/products"))
        .andExpect(status().isOk())
        .andExpect(product("$[0]", product0))
        .andExpect(product("$[1]", product1));

С помощью методов ResultMatcher вы избегаете разброса точной структуры данных ответа по множеству тестов. Это снова поддерживает рефакторинг.

Резюме

Мы рассмотрели несколько способов уменьшения многословия в тестах Spring Mock-MVC. Прежде чем мы даже начнем писать тесты Mock-MVC, мы должны решить, что мы хотим протестировать и какие части приложения следует заменить имитаторами. Часто рекомендуется протестировать как можно больше с помощью стандартных модульных тестов (без Spring и Mock-MVC).

Мы можем использовать настраиваемые тестовые аннотации для стандартизации нашей тестовой установки Spring Mock-MVC. С помощью небольших методов быстрого доступа и RequestPostProcessor мы можем убрать повторно используемый код запроса из методов тестирования. Обычно ResultMatcher можно использовать для улучшения проверки ответов.

Вы можете найти код примеров на GitHub.