JavaRush /Курсы /Spring Test /Full-context MockMvc

Full-context MockMvc: сильный набор

Spring Test
20 уровень , 4 лекция
Открыта

1. Мало дорогих full-context MockMvc-тестов

Полный контекст — это как поход в большой супермаркет за одной булочкой. Можно, конечно, сделать так каждый раз, но в какой-то момент вы начнёте ненавидеть и булочки, и супермаркет, и саму идею еды. В тестах то же самое: full-context MockMvc-тесты полезны, но они по определению дороже, потому что поднимают больше инфраструктуры и требуют более «взрослого» управления данными.

Цена здесь не только во времени старта контекста. Дороже становится подготовка состояния (нужно реальное состояние БД, миграции, seed-данные), диагностика падений (сломаться может стык слоёв, а не один класс), и поддержка (маленькое изменение конфигурации может задеть много тестов). Если вы превратите suite в «всё через @SpringBootTest», команда довольно быстро выработает стратегию психологической защиты: «ну локально я тесты не гоняю, в CI посмотрим». И это тот момент, когда тесты перестают быть быстрым ремнём безопасности и превращаются в редкий техосмотр раз в полгода.

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

Уровень теста Типичный вопрос Что доказывает Цена
Unit (без Spring) «Правило верное?» Чистую бизнес-логику одного/пары классов Низкая
@WebMvcTest,
@DataJpaTest
,
@JsonTest
«Граница слоя верна?» Контракт слоя без всего приложения Средняя
Full-context + MockMvc «Сцепление слоёв работает?» Реальную связку бинов, advice, сериализацию, filters Высокая

Именно поэтому full-context subset должен быть маленьким. Он отлично закрывает внутреннюю сцепку приложения, но не должен подменять собой unit/slice слой и не обязан доказывать реальный server/client path.

Смысл сегодняшней лекции — научиться отбирать дорогие тесты так, чтобы они были «маленькой элитной группой спецназа», а не «всем населением страны, мобилизованным на одну задачу».

2. Сильный full-context тест и ROI

Когда разработчики говорят «ROI», они звучат так, будто сейчас будут продавать вам франшизу кофейни. Но в тестировании это слово внезапно очень полезное, если перевести его с бизнес-языка на человеческий. ROI теста — это «сколько уверенности он приносит» по сравнению с тем, сколько он стоит в запуске и поддержке. И да, у теста тоже бывает «окупаемость»: один тест может ловить редкий, но очень дорогой баг, а другой — проверять то, что и так уже железобетонно доказано дешёвыми слоями.

В примерах ниже подразумевается JUnit 6 (Jupiter API).

Чтобы понять, заслуживает ли сценарий full-context режима, удобно задавать себе несколько вопросов. Я специально формулирую их как «разговор с самим собой», потому что так ими реально пользуются в жизни, а не в теории.

Вопрос, который вы себе задаёте Если ответ «да» Что это означает
«Сценарий реально проходит через несколько слоёв: controller → service → repository → advice?» Это кандидат Вероятен дефект на стыке слоёв, который slice не увидит
«Мне важно проверить реальную сериализацию/настройки Jackson, а не “как я замокал сервис”?» Это кандидат Контракт формируется реальными бинами, а не заглушками
«Ошибка должна превратиться в стабильный ApiProblem, и я хочу увидеть всю цепочку “исключение → advice → JSON”?» Это кандидат Вы тестируете не “404”, а “404 + правильный payload”
«Этот endpoint бизнес-критичен, и регрессия будет больной?» Это кандидат Высокая ценность теста даже при высокой цене
«Это правило уже доказано unit-тестом, и я просто хочу “на всякий случай” повторить?» Скорее не кандидат Дублирование дорогого покрытия без добавочной уверенности

Если хочется ещё более механического подхода, можно держать мини-дерево решений. Оно не заменяет мышление, но помогает быстро остановиться, когда рука тянется к @SpringBootTest по привычке:

flowchart TD
    A[Есть риск/дефект] --> B{Можно проверить без Spring?}
    B -->|Да| U[Unit-тест]
    B -->|Нет| C{Это чистый JSON-контракт DTO?}
    C -->|Да| J["@JsonTest"]
    C -->|Нет| D{Нужен только web-слой?}
    D -->|Да| W["@WebMvcTest"]
    D -->|Нет| E{Нужен только persistence?}
    E -->|Да| P["@DataJpaTest"]
    E -->|Нет| F[Full-context + MockMvc]

В сегодняшней точке курса мы как раз в ветке F, но важная мысль: туда попадают не все сценарии подряд, а только те, где действительно нужен «стык слоёв».

3. «Одна история поломки — один тест»: как не выращивать мегатесты

Дорогостоящие тесты особенно сильно провоцируют на мегатест. Логика понятна: «Раз уж мы подняли весь контекст, давайте одним тестом проверим вообще всё: create draft → submit → approve → проверить публичную выдачу → проверить ещё пять ошибок». Это звучит экономно, но на практике это как попытка одним чеком закрыть недельную закупку на 20 человек: да, вы купили всё, но теперь разбирать пакетики будете до пенсии.

Проблема мегатеста не в том, что он длинный, хотя и в этом тоже. Проблема в том, что он плохо диагностируется. Он падает — и вы сначала не понимаете, где именно сломалось: валидация? сериализация? репозиторий? security filter? совет (@ControllerAdvice) не сработал? А ещё он почти неизбежно становится хрупким: в нём слишком много шагов, и любое изменение в середине сценария ломает весь «поезд».

В full-context MockMvc-тестах здоровый стиль обычно такой: тест защищает одну конкретную «историю поломки», которую вы реально можете представить. Например, «если slug не найден, должен вернуться ApiProblem с ARTICLE_NOT_FOUND», или «после approve статья появляется в public выдаче». Это истории, которые можно объяснить словами человеку и себе через месяц.

Хороший признак — если имя теста похоже на предложение, а не на план военной операции. Например, вот такой unit-тест вообще не обязан существовать в full-context режиме, потому что он защищает локальное правило:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class PublicationPolicyTest {

    @Test
    void draft_canMoveToInReview() {
        // Проверяем одно локальное правило (без Spring и без базы)
        assertThat(PublicationPolicy.canMove("DRAFT", "IN_REVIEW")).isTrue();
    }
}

А вот такой сценарий уже похож на «стык слоёв», и он может быть кандидатом для full-context MockMvc: контроллер ищет статью, сервис бросает доменное исключение, @ControllerAdvice превращает его в ApiProblem, Jackson сериализует, и всё это — реальными бинами, а не моками.

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 missingSlug_returnsApiProblem() throws Exception {
    // mockMvc предполагается поднятым в full-context тесте через @AutoConfigureMockMvc
    mockMvc.perform(
                    // Важно: идём по реальному URL, как это делает клиент
                    get("/api/public/articles/missing-slug")
                            .accept(MediaType.APPLICATION_JSON)
            )
            // Несущая балка: правильный HTTP-статус
            .andExpect(status().isNotFound())
            // Несущая балка: стабильный error contract (ключевое поле)
            .andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}

Обратите внимание на философию ожиданий: мы не проверяем десяток полей просто потому, что можем. Мы проверяем те поля, которые являются смыслом истории: статус и errorCode.

4. Данные для full-context: @Sql vs репозитории

Когда вы переходите от slice к full-context, у вас меняется главный источник боли. В @WebMvcTest боль — это wiring зависимостей и моки. В full-context MockMvc чаще всего боль — это данные. Потому что зависимости реальные, а значит, endpoint будет читать из реальной базы, пусть и тестовой, применять реальные миграции, реальные маппинги и реальные queries.

Для happy-path full-context тестов дальше удобно держаться одного и того же dataset’а: @Sql("/sql/public/published-article.sql") подгружает категорию и опубликованную статью, а уже поверх этого идут GET-сценарии. Тогда полный контекст проверяет именно HTTP-историю, а не борьбу со случайным состоянием данных.

Самая популярная ошибка новичка здесь выглядит так: «Я в начале теста через репозитории создам категорию, потом создам статью, потом вложения, потом обновлю статус…». С одной стороны, это честно. С другой — это быстро превращается в тест, который проверяет всё сразу, а ещё вы случайно начинаете повторять логику приложения в тесте. Плюс добавляется JPA-магия: managed-сущности, flush, скрытые каскады и вся та радость, которую вы уже видели на data-уровне.

Поэтому в full-context тестах очень часто выигрывает SQL-подготовка: она короткая, прозрачная и не делает вид, что это «часть приложения». Вы говорите напрямую: «в базе есть категория и опубликованная статья», и переходите к сути — HTTP-сценарию.

Пример минимального каркаса full-context теста с подготовкой данных:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Sql("/sql/public/published-article.sql") // Подготовка данных одним понятным файлом перед тестами класса
class PublicArticleFullContextTest {

    @Autowired
    MockMvc mockMvc; // Точка входа для HTTP-запросов в full-context режиме
}

А вот пример самого SQL-фикстура, обычно лежащего в src/test/resources/sql/.... Я специально делаю его «читаемым», без лишней магии. В реальном проекте таблицы и колонки будут совпадать с миграциями.

-- Минимальная подготовка: категория для статьи
INSERT INTO category(id, code, name) VALUES (1, 'JAVA', 'Java');

-- Минимальная подготовка: опубликованная статья, которую отдаёт публичный API
INSERT INTO article(id, title, slug, summary, body, status, author_username, category_id, published_at)
VALUES (100, 'Spring Basics', 'spring-basics', '...', '...', 'PUBLISHED', 'alice', 1, now());

Да, здесь есть now(), то есть точное значение времени будет разное. Но это нормально, если в тесте вы проверяете не конкретную дату до миллисекунды, а факт, что publishedAt вообще присутствует. Если вам нужно точное время — тогда стоит опираться на фиксированный Clock в test-профиле.

5. Минимальный full-context набор: 3–4 теста

Идея «маленького набора» — не в том, чтобы быть аскетом и писать 2 теста на всё приложение. И не в том, чтобы быть героем и покрыть full-context тестами весь API. Идея в том, чтобы оставить ровно те сценарии, которые реально ловят поломки на стыке слоёв и при этом защищают важные пользовательские истории ContentHub.

Ниже — пример такого портфеля. Это не догма, а ориентир: вам важно уловить логику выбора и научиться объяснять, зачем каждый тест существует.

Тест (класс/сценарий) Что проверяет Почему это full-context
Public details: GET /api/public/articles/{slug} Реальная сборка ответа из БД + сериализация + статус + важные поля Ценность в полной цепочке «данные → DTO → JSON»
Public missing slug → ApiProblem Реальный перевод доменной ошибки в стабильный error contract Важен стык service/repository → advice → JSON
Public list (page wrapper) Реальная пагинация/фильтрация и метаданные страницы Стык data/query → mapping → page response wrapper
Admin approve делает статью публичной Смена статуса + появление в public API Один endpoint меняет состояние, другой демонстрирует эффект

Public details: «витрина» чтения

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

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 publicDetails_returnsPublishedArticle() throws Exception {
    mockMvc.perform(
                    // Запрос как у реального клиента: GET + Accept: application/json
                    get("/api/public/articles/spring-basics")
                            .accept(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isOk())
            // Важно проверять «несущие балки» контракта, а не весь JSON подряд
            .andExpect(jsonPath("$.slug").value("spring-basics"))
            .andExpect(jsonPath("$.status").value("PUBLISHED"));
}

Здесь мы проверяем «несущие балки» ответа: slug и status. Можно добавить publishedAt.exists(), если это часть контракта.

Missing slug → ApiProblem: ошибка как контракт

Стабильный error contract — одна из тех вещей, которые ломаются «тихо». Приложение всё ещё отвечает 404, но payload внезапно стал другим, и клиент начинает грустить. Full-context режим хорош тем, что вы видите всю цепочку: где ошибка возникла и как она превратилась в JSON.

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 publicDetails_missingSlug_returnsApiProblem() throws Exception {
    mockMvc.perform(
                    // Невалидный/несуществующий slug — ожидаем доменную ошибку, превращённую в ApiProblem
                    get("/api/public/articles/nope")
                            .accept(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isNotFound())
            // Ключевое поле контракта ошибки (на него обычно завязан клиент)
            .andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"))
            // Тип ответа тоже часть контракта (важно для клиентов и для дебага)
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
}

Обратите внимание на последнюю проверку: тип ответа — это часть контракта. И да, это мелочь, которая в реальности экономит время при разборе проблем у клиента.

Public list + page metadata

Списки с пагинацией часто ломаются «не драматично»: кто-то поменял дефолтный размер страницы, кто-то переименовал поле метаданных, кто-то случайно начал отдавать черновики. И именно поэтому тест на список нередко даёт высокий ROI.

Если вам нравится MockMvcTester, для короткой smoke-проверки списка держитесь того же ритма: запрос → exchange() → assertions. Главное — не устраивать в одном классе хаотичную смесь стилей.

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

class PublicArticleListFullContextTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void publicList_returnsOk() {
        var result = mvc.get()
                .uri("/api/public/articles?page=0&size=10")
                .accept(MediaType.APPLICATION_JSON)
                .exchange();

        Assertions.assertThat(result).hasStatusOk();
    }
}

А если вы хотите зафиксировать важный кусок контракта, то лучше явно проверить метаданные:

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 publicList_containsPageMetadata() throws Exception {
    mockMvc.perform(
                    // Здесь важна стабильность метаданных страницы: page/size и т.п.
                    get("/api/public/articles?page=0&size=10")
            )
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.page").value(0))
            .andExpect(jsonPath("$.size").value(10));
}

Approve делает статью публичной

Если выбирать один write-сценарий для full-context режима, я бы выбирал тот, где эффект виден снаружи. Одно дело — проверить, что approve endpoint вернул 200. Другое — доказать, что после approve статья реально становится видимой в public API. Это как раз «стык слоёв», который стоит дорого, но приносит настоящую уверенность.

Здесь удобно использовать @Sql, чтобы подготовить статью в статусе IN_REVIEW. Мы не обязаны в этом тесте проходить весь workflow от черновика: это другая история поломки и другой тест.

import org.junit.jupiter.api.Test;
import org.springframework.security.test.context.support.WithMockUser;

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

@WithMockUser(username = "admin", roles = "ADMIN")
@Test
void approve_makesArticlePublic() throws Exception {
    mockMvc.perform(post("/api/admin/articles/100/approve"))
            .andExpect(status().isOk());

    mockMvc.perform(get("/api/public/articles/reviewed-article"))
            .andExpect(status().isOk());
}

Да, тут два запроса в одном тесте. И это нормально, потому что это всё ещё одна история: «после approve статья становится публичной». Если в вашем проекте slug/ID другие — подставляются из SQL-фикстура.

6. Типичные ошибки при отборе full-context MockMvc-тестов

Ошибка №1: превращать full-context MockMvc в “дефолтный способ тестирования всего”.
Это самый частый сценарий: режим кажется удобным, потому что «почти как настоящий» и «меньше моков». Но дальше вы получаете suite, который запускается слишком долго, а значит реже запускается. В итоге реальная уверенность падает, хотя тестов стало больше — классическая ловушка количества без качества.

Ошибка №2: дублировать дорогими тестами то, что уже доказано unit/slice слоями.
Если переходы статусов надёжно закрыты unit-тестами PublicationPolicy, то full-context тест не должен повторять матрицу переходов. Если @WebMvcTest фиксирует, что validation ошибки превращаются в ApiProblem с violations, то full-context тест не обязан проверять все те же поля на каждом endpoint. Дорогой тест должен добавлять новую уверенность, а не повторять старую.

Ошибка №3: писать мегатесты на 30 шагов и потом героически “чинить” их каждую неделю.
Мегатесты создают иллюзию экономии, но платите вы позже: плохая диагностика, хрупкость, непонятные падения и вечная правка фикстур. Когда тест падает, вы тратите время не на исправление дефекта, а на раскапывание, где именно он случился.

Ошибка №4: делать “случайный” setup данных и надеяться, что оно как-нибудь будет стабильно.
Full-context тесты очень не любят хаос. Если данные создаются в тесте через десяток вызовов репозитория, и при этом где-то есть авто-генерация времени/slug/ID, тест становится недетерминированным. @Sql-фикстуры, фиксированный Clock в test-профиле и минимальные проверки по смыслу обычно дают куда более спокойную жизнь.

Ошибка №5: проверять слишком много “косметики” в JSON и ловить ложные падения.
Как только вы начинаете сравнивать весь JSON целиком или проверять поля, которые не несут смысловой нагрузки (порядок элементов, лишние поля, внутренние технические значения), тест превращается в сигнализацию, которая срабатывает от ветерка. В full-context режиме особенно важно проверять несущие элементы контракта: статус, errorCode, ключевые поля DTO, критичные headers.

1
Задача
Spring Test, 20 уровень, 4 лекция
Недоступна
Маленький full-context набор для карточки статьи
Маленький full-context набор для карточки статьи
1
Задача
Spring Test, 20 уровень, 4 лекция
Недоступна
Маленький full-context набор для страницы статей через MockMvcTester
Маленький full-context набор для страницы статей через MockMvcTester
1
Опрос
Spring тесты, 20 уровень, 4 лекция
Недоступен
Spring тесты
Full-context и MockMvc
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ