1. Мало дорогих full-context MockMvc-тестов
Полный контекст — это как поход в большой супермаркет за одной булочкой. Можно, конечно, сделать так каждый раз, но в какой-то момент вы начнёте ненавидеть и булочки, и супермаркет, и саму идею еды. В тестах то же самое: full-context MockMvc-тесты полезны, но они по определению дороже, потому что поднимают больше инфраструктуры и требуют более «взрослого» управления данными.
Цена здесь не только во времени старта контекста. Дороже становится подготовка состояния (нужно реальное состояние БД, миграции, seed-данные), диагностика падений (сломаться может стык слоёв, а не один класс), и поддержка (маленькое изменение конфигурации может задеть много тестов). Если вы превратите suite в «всё через @SpringBootTest», команда довольно быстро выработает стратегию психологической защиты: «ну локально я тесты не гоняю, в CI посмотрим». И это тот момент, когда тесты перестают быть быстрым ремнём безопасности и превращаются в редкий техосмотр раз в полгода.
Чтобы не попадать в эту ловушку, полезно держать в голове простую экономику уровней — без фанатизма и религиозных войн:
| Уровень теста | Типичный вопрос | Что доказывает | Цена |
|---|---|---|---|
| Unit (без Spring) | «Правило верное?» | Чистую бизнес-логику одного/пары классов | Низкая |
@WebMvcTest, , |
«Граница слоя верна?» | Контракт слоя без всего приложения | Средняя |
| 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ