JavaRush /Курсы /Spring Boot /Легкий MockMvc smok...

Легкий MockMvc smoke для endpoint’ов

Spring Boot
27 уровень , 2 лекция
Открыта

1. Web smoke-тесты в Boot

Внутри приложения мы уже проверили, что конфигурация подхватывается, связывается и не разваливается на validation. Но у Boot-сервиса есть ещё внешний экзамен: жив ли web-слой и дойдёт ли запрос до контроллера так, как мы ожидаем.

Когда вы только начинаете, легко попасть в ловушку «если приложение стартует — значит, оно работает». У Spring Boot есть неприятная способность стартовать бодро и уверенно даже тогда, когда ваш контроллер не найден (например, из-за пакетов и component scanning), когда mapping не тот, когда путь отличается на один символ, или когда сериализация JSON внезапно сломалась из-за мелкого рефакторинга модели. Web smoke-тест — это как «проверка, что дверь действительно открывается, а не просто красиво нарисована на стене».

Важно понимать цель. Мы не делаем здесь «полный тест API», не проектируем контракт на 30 страниц и не превращаем день в отдельный курс по тестированию web-слоя. Smoke-тест — это минимальная проверка жизнеспособности. Он должен быть маленьким, быстрым и не слишком умным. Его задача — ловить грубые поломки вроде «endpoint 404», «контроллер не поднялся», «возвращаем не JSON», «вместо HTML отдаем что-то странное».

Для catalog-service такие smoke-точки очень естественны. У нас есть страница / (landing page), которая делает сервис «живым» для браузера, и есть главный read-only вход /api/catalog/courses, который отдает список курсов. Если эти две точки доступны, уже можно сказать, что web-слой в базовом смысле функционирует.

2. MockMvc: запрос без сети

С названием MockMvc у новичков часто конфликт в голове. Слово mock намекает на «подделку», и кажется, что это какой-то игрушечный тест, который не проверяет ничего реального. На практике это довольно честный симулятор прохождения запроса через Spring MVC. Он не поднимает Tomcat и не открывает порт, но внутри JVM вызывает тот же MVC-конвейер: DispatcherServlet, mapping, аргументы контроллера, конвертацию параметров, message converters (включая Jackson для JSON) и формирование ответа.

Если упростить, MockMvc делает вам «запрос без курьера». В реальном мире HTTP-запрос приносит сеть, сервер, сокеты, порты и куча мест, где можно споткнуться о конфигурацию окружения. В MockMvc все это убрано, а Spring MVC остается. Получается идеальная комбинация для smoke-тестов: проверяем именно наш web-слой, но без лишней нестабильности и медлительности.

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

flowchart TD
    T[JUnit test] --> M[MockMvc.perform]
    M --> D[DispatcherServlet]
    D --> C[Controller method]
    C --> J[Jackson / HttpMessageConverter]
    J --> R[MockHttpServletResponse]
    R --> A[Assertions: status, content-type, jsonPath...]

И ещё один важный момент для Boot-centric подхода. Когда мы используем @SpringBootTest вместе с @AutoConfigureMockMvc, мы поднимаем полный контекст приложения. Это значит, что controller получает реальный CourseCatalogService, тот — реальный repository, а тот — реальные данные из конфигурации. Да, это тяжелее, чем «чистый unit-тест», но для smoke-проверки это именно то, что нам нужно: убедиться, что куски приложения реально состыковались.

Подключаем MockMvc в @SpringBootTest

Подключение MockMvc обычно ломается не из-за сложной логики, а из-за банальной «не той аннотации» или «не того контекста». Здесь хорошая новость: в Spring Boot это решается буквально двумя строками на уровне класса теста. Плохая новость: если перепутать эти две строки, вы будете минут пять смотреть на NullPointerException и думать, что в жизни не существует справедливости.

Самый базовый «рецепт» для нашего случая выглядит так: мы используем @SpringBootTest, чтобы поднять Boot-контекст, и добавляем @AutoConfigureMockMvc, чтобы Boot создал и настроил MockMvc внутри тестового контекста. После этого MockMvc можно просто заинжектить через @Autowired.

Чаще всего такие тесты логично класть рядом с web-слоем. Например, если контроллер лежит в пакете com.example.catalogservice.catalog.web, то тест можно тоже положить туда же (или в соседний пакет в тестах). Это помогает мозгу: «web тесты живут рядом с web кодом».

Пример минимального каркаса класса:

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest // Поднимаем полный Spring Boot контекст (это именно smoke на wiring)
@AutoConfigureMockMvc // Просим Boot сконфигурировать MockMvc внутри контекста
class CatalogWebSmokeTest {
    // Тело может быть пустым: важнее аннотации и сам факт старта контекста
}

А вот как обычно выглядит поле с MockMvc (обратите внимание: никаких new MockMvc(...) руками, Spring делает это за нас):

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

@Autowired // Инжектим уже настроенный MockMvc из тестового Spring-контекста
private MockMvc mockMvc;

Если mockMvc внезапно null, почти всегда причина в том, что отсутствует @AutoConfigureMockMvc или тест вообще не поднимает web-контекст. В нашем курсе мы держимся простого правила: для smoke web-тестов — полный контекст.

3. Smoke-тесты API: статус и JSON

Начинать лучше с самого простого теста в мире. Да, он выглядит «слишком простым, чтобы быть полезным», но это обманчивое впечатление. Тест на 200 OK уже ловит такие ошибки, как «путь изменился», «контроллер не поднялся», «mapping конфликтует», «приложение не стартует» (потому что @SpringBootTest вообще не поднимет контекст). Для начинающего это огромная практическая ценность: меньше времени на гадание, больше времени на понимание.

Самый маленький тест обычно выглядит так:

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void coursesEndpointReturns200() throws Exception {
    // Делаем GET-запрос к endpoint’у (без сети, но через Spring MVC конвейер)
    mockMvc.perform(get("/api/catalog/courses"))
            // Smoke-проверка: endpoint жив и не падает
            .andExpect(status().isOk());
}

Теперь добавим второй слой, который почти всегда стоит проверять: тип контента. У новичков часто бывает ситуация «я думал, что это JSON, а там HTML со страницей ошибки» или «возвращается plain text, потому что метод контроллера не @RestController». Поэтому проверка Content-Type — это не занудство, а быстрая диагностика класса проблемы.

Проверять Content-Type можно двумя путями. Первый — «вручную» через MvcResult, он понятен мозгу, потому что похож на обычную работу с объектами:

import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void returnsJsonContentType() throws Exception {
    // Выполняем запрос и получаем "сырой" результат, чтобы проверить заголовки вручную
    MvcResult result = mockMvc.perform(get("/api/catalog/courses"))
            .andExpect(status().isOk()) // Сначала убеждаемся, что запрос вообще успешен
            .andReturn();

    // Проверяем, что Content-Type содержит application/json (возможны доп. параметры вроде charset)
    assertTrue(result.getResponse().getContentType().contains("application/json"));
}

Второй путь — через стандартные matchers Spring MVC test, он обычно короче и читается как «контракт ожиданий»:

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void returnsJson() throws Exception {
    mockMvc.perform(get("/api/catalog/courses"))
            .andExpect(status().isOk())
            // Проверяем, что это JSON (совместимый), а не точная строка заголовка
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
}

Почему я использую contentTypeCompatibleWith, а не contentType(MediaType.APPLICATION_JSON)? Потому что «строгое равенство» иногда делает тест хрупким. Где-то может появиться charset, где-то меняется точная строка, а смысл остаётся тот же: это JSON. Для smoke-теста лучше быть чуть терпимее к мелочам, если они не важны.

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

import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

mockMvc.perform(get("/api/catalog/courses"))
        .andDo(print()) // Печатаем статус/заголовки/тело ответа в консоль — удобно для диагностики
        .andExpect(status().isOk());

Да, print() иногда выглядит как «шум в консоли», но на этапе обучения это очень полезная лупа: вы сразу видите статус, заголовки и тело ответа.

4. Smoke-проверки формы JSON

Проверка статуса и Content-Type — это уже неплохо. Но у web smoke-теста есть ещё одна важная задача: убедиться, что тело ответа хотя бы похоже на то, что мы ожидаем. При этом нельзя скатываться в «мы проверим вообще каждое поле каждого элемента массива», потому что это перестает быть smoke-тестом и превращается в хрупкий контрактный тест (а это уже совсем другой уровень дисциплины).

Самый удачный формат для минимальной проверки JSON в Spring MVC tests — jsonPath. Это небольшие выражения, которые позволяют сказать «в JSON есть такое поле» или «значение поля равно…». Тут хороший момент: jsonPath читабельнее и стабильнее, чем contains("slug"), потому что проверяет структуру данных, а не случайное совпадение текста.

Допустим, endpoint GET /api/catalog/courses/{slug} возвращает одну карточку курса. Тогда smoke-проверка может быть такой: статус 200 и slug действительно тот, который мы запросили. Это максимально понятный и устойчивый сигнал.

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void returnsCourseBySlug() throws Exception {
    mockMvc.perform(get("/api/catalog/courses/{slug}", "spring-boot"))
            .andExpect(status().isOk())
            // Минимальная проверка формы/данных: в корне JSON есть поле slug нужного значения
            .andExpect(jsonPath("$.slug").value("spring-boot"));
}

Обратите внимание на два момента. Во‑первых, путь $.slug означает «в корне JSON-объекта есть поле slug». Во‑вторых, мы не проверяем остальные поля. Это всё ещё smoke: нам важно поймать «сломалось полностью», а не «поменяли shortDescription».

Теперь вернемся к списку курсов GET /api/catalog/courses. Обычно это массив JSON-объектов. Мы можем проверить, что корень ответа — именно массив. Это хорошая проверка формы и она не зависит от того, сколько элементов внутри:

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void returnsJsonArray() throws Exception {
    mockMvc.perform(get("/api/catalog/courses"))
            .andExpect(status().isOk())
            // Проверяем форму ответа: корень JSON — массив
            .andExpect(jsonPath("$").isArray());
}

Если вы хотите сделать шаг чуть «жёстче», можно проверить, что в массиве вообще есть элементы. Но здесь важно помнить: такая проверка завязывает тест на наличие данных. В нашем учебном проекте это обычно нормально (каталог редко бывает пустым), но в реальных проектах пустой список может быть валидным сценарием. Поэтому если вы решаете добавлять такую проверку, делайте это осознанно.

Пример (чуть более строгий, уже с Hamcrest):

import org.junit.jupiter.api.Test;

import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void returnsNonEmptyList() throws Exception {
    mockMvc.perform(get("/api/catalog/courses"))
            .andExpect(status().isOk())
            // Чуть более строго: в массиве хотя бы один элемент (зависит от тестовых данных)
            .andExpect(jsonPath("$", hasSize(greaterThan(0))));
}

И, наконец, маленькая, но полезная практическая деталь: как передавать query params в MockMvc. Даже если мы не тестируем всю фильтрацию (это уже далеко не smoke), иногда полезно убедиться, что endpoint «не падает» при параметрах.

import org.junit.jupiter.api.Test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void acceptsTrackFilter() throws Exception {
    mockMvc.perform(get("/api/catalog/courses").param("track", "SPRING"))
            // Smoke-уровень: проверяем, что binding параметра не ломает endpoint
            .andExpect(status().isOk());
}

Этот тест не доказывает корректность фильтрации. Он доказывает более простой и важный для smoke-взгляда факт: «параметр принимается, конвертация не упала, mapping работает».

Чтобы было проще ориентироваться, вот небольшая табличка «что проверяем в smoke-тесте и зачем». Это не чек-лист, а скорее карта здравого смысла.

Проверка Что она ловит Почему это smoke-уровень
status().isOk() 404/500, отсутствие контроллера, ошибки wiring Минимальная жизнеспособность endpoint’а
contentTypeCompatibleWith(APPLICATION_JSON) Возврат HTML/текста вместо JSON, неверные аннотации Проверяем тип ответа, не детали
jsonPath("$").isArray() «вообще не тот JSON», сломанная сериализация Проверяем форму, не все поля
.param("track", "SPRING") +
200
падение на query binding Проверяем, что endpoint переживает базовый ввод

5. Smoke-тест landing page: /

В backend-курсах landing page иногда воспринимают как «косметику». Но в нашем catalog-service она выполняет очень практическую роль: делает сервис самодокументируемым на базовом уровне. Открыли браузер — увидели имя приложения и ссылки на основные endpoint’ы. Поэтому тест для / имеет смысл как элемент минимальной «видимой поверхности сервиса».

Landing page часто отдается как статический ресурс (src/main/resources/static/index.html). Для MockMvc это не важно: он всё равно прогонит запрос через MVC слой и отдаст то, что реально поднялось. Мы проверим три вещи: страница доступна, это HTML (или совместимый тип) и внутри есть хотя бы одна ключевая ссылка, например на список курсов.

Пример smoke-теста:

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@Test
void landingPageHasCatalogLink() throws Exception {
    mockMvc.perform(get("/"))
            .andExpect(status().isOk())
            // Проверяем, что это HTML-страница (совместимый тип, без строгого равенства строки)
            .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
            // Якорная проверка: на странице есть ссылка/упоминание основного API endpoint’а
            .andExpect(content().string(containsString("/api/catalog/courses")));
}

Почему мы проверяем «кусочек строки», а не весь HTML целиком? Потому что HTML — штука, которую вы можете слегка поменять (переставить блоки, добавить текст, переписать стили), и это не должно ломать тесты. Нам нужна стабильная «якорная точка», которая действительно важна: ссылка на API, по которой пользователь (и вы сами) быстро найдёте основной endpoint.

Если вы чувствуете, что containsString("/api/catalog/courses") тоже может быть слишком хрупко, можно проверять что-то более «концептуальное», например наличие заголовка приложения. Но это уже зависит от того, как именно оформлен ваш index.html. В учебном проекте нормально держать одну-две устойчивые проверки.

6. Границы smoke-проверок

Самая распространенная ошибка новичка — сделать один тест «на всё». Внутри этого теста человек пытается проверить сразу статус, все поля JSON, все варианты query params, сортировку, фильтрацию, и где-то в середине добавляет проверку, что третья запись в массиве имеет конкретный title. Итог предсказуем: тест падает при любом небольшом изменении, а вы начинаете ненавидеть не только тесты, но и жизнь.

У smoke-теста есть очень чёткая граница: он должен отвечать на вопрос «сервис жив и выглядит ожидаемо в самом грубом смысле». Это означает, что нам обычно достаточно статуса, типа контента и пары проверок формы. В тот момент, когда вы начинаете проверять «точные значения всех полей», вы, сами того не заметив, переходите в мир контрактных тестов и дизайна API. А у нас сейчас другая задача: минимальная защита от грубых поломок Boot-шаблона.

Полезно держать в голове простую шкалу «насколько хрупкая проверка». Я люблю формулировать это так: чем больше ваша проверка зависит от конкретных тестовых данных, тем больше вероятность ложного падения при безобидных изменениях.

Вот пример сравнения в формате таблицы, чтобы вы могли буквально глазами увидеть разницу:

Проверка Насколько она хрупкая Почему
status().isOk() низкая endpoint либо жив, либо нет
contentTypeCompatibleWith(APPLICATION_JSON) низкая тип ответа редко меняется случайно
jsonPath("$.slug").value("spring-boot") средняя зависит от тестовых данных и slug
jsonPath("$[3].title").value("Spring Boot") высокая зависит от порядка, данных и текста
сравнение всего JSON целиком строкой очень высокая ломается от форматирования и мелочей

Если вы всё-таки хотите «якорь» в данных (например, конкретный slug), это не запрещено. Просто осознайте: вы берете на себя обязанность держать тестовые данные стабильными. В учебном catalog-service это часто нормально: вы можете договориться с собой, что курс spring-boot всегда существует. Но не превращайте это в привычку «все тесты завязаны на конкретный набор курсов».

И ещё одна практическая граница: smoke-тест не должен заменять вам тестирование бизнес-логики. Если вы хотите проверить сложную фильтрацию, это лучше делать ближе к сервис-слою, где вы тестируете именно алгоритм, а не весь MVC конвейер. Web smoke — это проверка «трубы», а не проверка всего завода.

7. Типичные ошибки при MockMvc smoke-тестах

Ошибка №1: забыли @AutoConfigureMockMvc, а потом удивляются, что mockMvc не инжектится.
Это классическая ситуация «две аннотации, одна забыта». Если вы используете @SpringBootTest и хотите получить MockMvc через DI, @AutoConfigureMockMvc практически обязателен. Иначе Spring Boot поднимет контекст, но MockMvc вам никто не подготовит.

Ошибка №2: слишком строгая проверка Content-Type.
Многие начинают с content().contentType(MediaType.APPLICATION_JSON) и получают падение теста из-за мелочей вроде параметров charset или чуть отличающейся строки. Для smoke-тестов лучше использовать contentTypeCompatibleWith, чтобы проверять смысл, а не точное написание заголовка.

Ошибка №3: тесты проверяют «весь JSON целиком», а потом ломаются от любой мелочи.
Сравнение тела ответа строкой (assertEquals(expectedJson, actualJson)) выглядит соблазнительно: «ну зато точно!» Но в реальности это превращается в борьбу с форматированием и случайными изменениями. Для smoke-уровня почти всегда достаточно пары jsonPath проверок формы.

Ошибка №4: smoke-тест превращается в проверку всей фильтрации и всех query params.
Проверить один параметр на «не упало» — нормально. Проверять все комбинации track/level/featuredOnly/launchedAfter/limit — это уже не smoke, а полноценный набор функциональных тестов. Такие тесты становятся длинными, медленными, и вы перестаете понимать, что именно сломалось, когда они падают.

Ошибка №5: хрупкие проверки, завязанные на порядок элементов в списке.
Проверка типа «четвертый элемент массива должен иметь slug X» почти гарантированно начнет падать при любом изменении данных, сортировки или дефолтной фильтрации. Если вам очень хочется что-то проверить в массиве, лучше проверять общую форму (isArray) или существование поля, а не позицию.

1
Задача
Spring Boot, 27 уровень, 2 лекция
Недоступна
Smoke-тест JSON endpoint через `MockMvc`
Smoke-тест JSON endpoint через `MockMvc`
1
Задача
Spring Boot, 27 уровень, 2 лекция
Недоступна
Smoke-тест стартовой страницы
Smoke-тест стартовой страницы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ