1. Decision tree против рефлекса @SpringBootTest
Мы уже разложили Boot-тесты по границам: JSON, MVC, JPA, outbound client и полный контекст. Но сама карта ещё не отвечает на практический вопрос: какой путь выбрать под конкретный дефект, чтобы не хвататься за @SpringBootTest по привычке.
Когда человек впервые видит Spring Boot тестирование, у него возникает вполне естественная мысль: «Если @SpringBootTest поднимает всё приложение, то это, наверное, самый надёжный способ. Поставлю его везде — и будет мне счастье». Это ровно тот момент, когда тестовая стратегия превращается в религиозную: «верю в полный контекст», а не «выбираю инструмент под риск».
Проблема не в том, что @SpringBootTest плох. Проблема в том, что он дорогой и широкоугольный. Он проверяет много связок сразу, но за это вы платите временем запуска, сложностью изоляции причин падения и склонностью к дублированию. Тесты начинают «всё трогать», и в итоге становится сложно понять, что именно вы доказали: бизнес-правило, JSON-контракт, wiring, поведение репозитория, настройки, фильтры, сериализацию или просто то, что Земля всё ещё вращается вокруг Солнца.
Decision tree (дерево решений) — это способ остановить этот рефлекс. Он заставляет начинать не с аннотации, а с формулировки проблемы. А потом, шаг за шагом, задавать себе короткие вопросы уровня «нужен ли Spring вообще?» и «какая граница системы сейчас под подозрением?». В результате вы почти физически чувствуете, где вам реально нужен полный контекст, а где вы случайно платите за него как за подписку на “all inclusive”, когда вам нужна только вода без газа.
2. Шаг 0: формулируем риск и слой
Перед тем как выбирать аннотацию, нужно научиться формулировать дефект или риск в терминах слоя. Это звучит абстрактно, но на практике это очень конкретная дисциплина: вы перестаёте говорить «что-то сломалось» и начинаете говорить «сломалась граница Х, а значит я буду проверять её такими-то средствами». В тестировании это почти суперсила.
Хорошая формулировка риска отвечает на три вопроса: что наблюдает клиент (или другой слой), что может пойти не так, и где это живёт. Например, «endpoint GET /api/public/articles/{slug} возвращает поле publishedAt в неправильном формате» — это не “проблема контроллера” и не “проблема базы”, это проблема JSON-контракта ответа. А «репозиторий перестал фильтровать статьи по статусу PUBLISHED» — это не проблема HTTP, а проблема query/persistence boundary. И, наконец, «при submit на ревью статья уходит в неправильный статус» — это либо чистая бизнес-логика (unit), либо сквозной wiring нескольких слоёв (full context), и тут уже надо уточнять.
На примере ContentHub удобно держать в голове, что у нас есть разные виды “внешнего наблюдателя”. В одном случае наблюдатель — это unit-тест, который смотрит на метод Java-класса. В другом — “клиент API”, которому важен JSON и HTTP-семантика. В третьем — база данных как система, которая должна реально enforce constraints и обеспечивать корректные выборки. В четвёртом — внешний HTTP-сервис модерации, с которым мы общаемся через RestClient. Decision tree начинается именно отсюда: сначала вы называете наблюдаемое поведение, а уже потом выбираете, какой «микроскоп» нужен, чтобы это поведение увидеть.
3. Шаг 1: когда хватает plain unit-test
Этот вопрос кажется обидным, потому что вы вроде пришли на курс про Spring Boot тестирование, а я предлагаю начать с «а может, не надо Spring». Но в этом и есть взрослая инженерная привычка: если инструмент дорогой, его нельзя включать по умолчанию. Unit-тест — это как велосипед: не самый крутой транспорт в мире, но на короткой дистанции и в городе он побеждает почти всё.
Если тестируемая логика живёт в обычном классе и не зависит от Spring-магии, контейнера, автоконфигурации, сериализации, транзакций и других “платных опций”, то правильный ответ чаще всего — unit-test. В ContentHub таких мест много: правила переходов статусов в PublicationPolicy, генерация slug в SlugService, локальная проверка вложений в AttachmentValidationService. Даже ArticleWorkflowService мы уже тестировали unit-уровнем, потому что стабилизировали время через Clock и текущего пользователя через провайдер, вместо того чтобы тянуть всё приложение.
Важно заметить: “нужен ли Spring” — это не вопрос “используется ли Spring где-то в проекте”. Это вопрос “нужен ли Spring для доказательства конкретного утверждения”. Если вы хотите доказать, что Spring Boot Testing превращается в spring-boot-testing, то Spring вам не помогает. Он просто будет сидеть рядом и потреблять ресурсы, как кот, который пришёл на кухню «просто посмотреть», но внезапно съел половину сметаны.
Небольшой пример, который напоминает, что unit-тест остаётся нормой даже в Spring Boot проекте:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceTest {
@Test
void convertsTitleToSlug() {
// Arrange: создаём тестируемый сервис без Spring-контекста.
SlugService service = new SlugService();
// Act + Assert: вызываем метод и проверяем результат.
assertThat(service.toSlug("Spring Boot Testing"))
.isEqualTo("spring-boot-testing");
}
}
Здесь нет ни одной Spring-аннотации — и это прекрасно. Тест дешёвый, быстрый, предсказуемый и падает ровно там, где сломалась логика.
4. Шаг 2: если Spring нужен — выбираем границу проверки
Когда ответ «без Spring не обойтись» уже прозвучал, не нужно снова разворачивать весь список аннотаций. Здесь важнее быстро сопоставить подозреваемую границу с тем, что тест вообще должен доказать.
Сломался JSON-контракт DTO → @JsonTest. Он фиксирует сериализацию и десериализацию, не таща MVC, security и БД.
Под подозрением HTTP-граница контроллера → @WebMvcTest. Он проверяет mapping, status codes, headers, binding и validation на уровне MVC.
Риск живёт в persistence boundary → @DataJpaTest. Он проверяет JPA-мэппинг, constraints, queries, ordering и работу репозиториев.
Проблема во внешнем HTTP-клиенте → @RestClientTest. Он фиксирует поведение адаптера и контракт общения наружу.
Дефект проявляется только на стыке нескольких слоёв → @SpringBootTest. Он проверяет wiring и несколько ключевых сквозных связок, а не локальную механику одного слоя.
Этого короткого набора достаточно, чтобы пройти по дереву решений дальше. Здесь нам важен не второй атлас аннотаций, а быстрый переход от риска к минимально достаточному контексту.
5. Шаг 3: slice или full context — про доказательство, а не удобство
Когда вы уже знаете, что Spring нужен, и помните цену контекста, очень легко перепутать мотивы. Самый опасный мотив звучит так: «Мне проще написать тест в полном контексте, потому что там всё уже есть». Да, проще. Но это “проще” обычно оплачивается позже: в виде медленного прогона, нестабильной диагностики и соблазна начать делать один большой тест вместо нескольких сильных.
Slice-тест — это узкий контекст, который поднимает только часть приложения. Он делает тест быстрее и точнее по смыслу, потому что вы тестируете именно границу. Full context — это тест “системной сборки” приложения, который полезен как страховка wiring’а и как несколько ключевых сквозных сценариев, но вреден как универсальный молоток.
Внутри decision tree этот вопрос можно сформулировать по-честному: “Сломаться могло внутри одного слоя, или только на стыке слоёв?”. Если внутри одного слоя, full context не повышает доказательную силу, он только добавляет шума. Если же дефект появляется только когда слои взаимодействуют, slice-тест может оказаться слишком узким и просто не воспроизвести проблему.
И вот тут появляется практическая хитрость: если вы сомневаетесь, попробуйте начать с узкого теста, который прямо соответствует подозреваемому слою. Если он не ловит проблему или вы не можете корректно выразить проверку в рамках слоя, это уже сигнал, что дефект системный, и тогда можно расширяться до полного контекста. То есть full context — это “вторая попытка”, а не первая реакция.
6. Схема decision tree и как ей пользоваться
Схемы любят за то, что они превращают «ощущения» в алгоритм. Но важно помнить: decision tree — не закон природы. Это инструмент, чтобы не делать глупости по инерции. Пользуйтесь им как навигатором: он не запретит вам съехать с маршрута, но хотя бы спросит “вы уверены?”.
Ниже — простая схема выбора уровня теста для ContentHub. Она начинается с риска, а не с аннотации. И это ключевой момент: пока вы не можете сформулировать риск, вы не выбираете тест — вы выбираете “настроение”.
flowchart TD
A["Есть дефект/риск. Что хотим доказать?"] --> B{"Можно доказать без Spring?"}
B -->|Да| U["Plain unit-test (JUnit 6 + AssertJ + Mockito при необходимости)"]
B -->|Нет| C{"Какая граница является предметом проверки?"}
C --> J["JSON контракт DTO"] --> JT["@JsonTest"]
C --> W["HTTP граница контроллера (MVC)"] --> WT["@WebMvcTest"]
C --> D["Persistence boundary: mapping/constraints/queries"] --> DT["@DataJpaTest"]
C --> R["Outbound HTTP client (RestClient adapter)"] --> RT["@RestClientTest"]
C --> F["Стыки слоёв / wiring всего приложения"] --> FT["@SpringBootTest"]
Как пользоваться этой схемой на практике? Очень просто: вы берёте конкретную “поломку”, формулируете её как утверждение, которое хотите доказать, и проходите по дереву. Если вы на первой развилке не можете честно ответить, нужен ли Spring, это обычно значит, что вы ещё не поняли, что именно тестируете. И лучше потратить 2 минуты на уточнение формулировки, чем 20 минут на запуск ненужного контекста.
7. Применяем decision tree к ContentHub: живые ситуации
Теория полезна ровно до того момента, пока не столкнулась с реальным багом. Поэтому давайте возьмём типичные ситуации ContentHub и посмотрим, как decision tree приводит нас к минимально достаточному тесту. Здесь важно не только “какая аннотация”, но и “почему именно эта аннотация даёт нужное доказательство”.
Сломались правила переходов статусов статьи
Представим, что кто-то случайно разрешил переход PUBLISHED → IN_REVIEW. В UI это может выглядеть как “статья уже опубликована, но её снова можно отправить на ревью”. Никаких контроллеров и БД тут не требуется, потому что это чистое бизнес-правило. Значит, decision tree скажет: Spring не нужен, берём unit-test.
Мини-скелет теста здесь будет максимально скучным — и это комплимент:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicationPolicyTest {
@Test
void doesNotAllowPublishedToReview() {
// Arrange: тестируем бизнес-правило как обычный Java-код.
PublicationPolicy policy = new PublicationPolicy();
// Assert: фиксируем запрет конкретного перехода статусов.
assertThat(policy.canMove(ArticleStatus.PUBLISHED, ArticleStatus.IN_REVIEW))
.isFalse();
}
}
Такой тест ловит дефект быстро и дешево. И, что приятно, он не сломается, если вы поменяете Spring-конфигурацию или контроллеры, потому что это вообще не его работа.
Клиент перестал понимать JSON: изменили имя поля в DTO
Допустим, в ArticleDetailsResponse поле authorUsername переименовали в author, потому что “так красивее”. С точки зрения Java-кода всё может быть идеально: объект строится, контроллер возвращает, статус 200, сервис работает. Но внешний клиент внезапно получает другой JSON и падает. Это не проблема web-mapping. Это именно проблема JSON-контракта. Значит, @JsonTest.
Здесь важно: мы не обязаны поднимать контроллер, чтобы проверить сериализацию DTO. Нам достаточно JSON-slice.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
@JsonTest
class ArticleDetailsResponseJsonTest {
@Test
void serializesAuthorUsernameField() {
// Здесь будет проверка наличия/имени поля в JSON (не пишем реализацию сейчас).
// Идея: тест падает, если контракт DTO меняется “случайно”.
}
}
Да, тест пустой, но выбор правильный: мы “нацелили” тест на контракт. И если контракт меняется случайно, это место загорится первым.
Endpoint возвращает неправильный статус или не принимает параметр
Теперь другой тип проблемы. Представим, что GET /api/public/articles/{slug} внезапно начал отдавать 404 даже на существующую опубликованную статью. Или контроллер перестал принимать sort/page параметры. Это web boundary: mapping, path variables, query params, статусная семантика. Здесь unit-тест сервиса может быть зелёным, потому что сервис вообще не знает, как из URL получается метод контроллера. Data-тест тоже не поможет, потому что БД может быть идеальна.
Значит, @WebMvcTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Test
void returnsOkForExistingSlug() {
// Здесь будет проверка HTTP поведения контроллера (статус, JSON, параметры).
// Важно: предмет теста — контроллер как “переводчик”, а не бизнес-логика сервиса.
}
}
Смысл выбора такой: мы тестируем “переводчика” между HTTP и сервисом. И это ровно то место, где подобные дефекты живут.
Репозиторий перестал фильтровать опубликованные статьи
Допустим, публичный список вдруг начал показывать DRAFT статьи. Это почти наверняка persistence/query issue: либо неправильный запрос, либо неправильная фильтрация в репозитории, либо неверный мэппинг статуса. Тестировать это через @WebMvcTest бессмысленно: в web-slice репозиториев нет. Тестировать это через @SpringBootTest можно, но это дорого и может замылить причину.
Значит, @DataJpaTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class PublishedArticleQueryDataJpaTest {
@Test
void findsOnlyPublishedArticles() {
// Здесь будет проверка query behavior (какие статьи попали в выборку).
// Идея: готовим данные в БД, выполняем запрос репозитория, проверяем состав результатов.
}
}
Это тот случай, когда data-slice даёт максимальную доказательную силу для конкретного риска: мы не проверяем HTTP, не проверяем сериализацию, мы проверяем “БД-границу говорит правду”.
Moderation client неправильно обрабатывает ответ внешнего сервиса
Ещё одна типичная история: moderation service начинает возвращать новый формат ответа или другой статус, а наш RestClientModerationClient не умеет это читать. В результате submit на ревью падает, хотя бизнес-логика и репозитории в порядке. Это outbound integration boundary: клиентский адаптер.
Правильный выбор — @RestClientTest.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
@RestClientTest(RestClientModerationClient.class)
class RestClientModerationClientTest {
@Test
void mapsBlockDecisionFromExternalService() {
// Здесь будет проверка: запрос/ответ/маппинг moderation payload.
// Важно: мы фиксируем контракт взаимодействия с внешним HTTP-сервисом.
}
}
Почему это минимально достаточно? Потому что мы не проверяем весь workflow, мы проверяем конкретный риск: “адаптер правильно общается по HTTP”. И это можно доказать без поднятия всего приложения.
Баг на стыке слоёв → @SpringBootTest
И наконец, самый неприятный класс дефектов: по отдельности всё выглядит правильно. Unit-тесты сервисов зелёные, JSON-контракт DTO вроде корректный, репозиторий по-своему работает. Но как только запускаешь приложение, реальный запрос падает: то ли из-за wiring’а, то ли из-за конфигурации, то ли из-за того, что один слой ожидает одно, а другой отдаёт другое.
Вот здесь появляется justification для @SpringBootTest. Потому что вам нужно доказать именно связку: controller → service → repository, плюс настройки и инфраструктура. Такой тест дорогой, но он ловит дефекты “между слоями”, которые невозможно поймать локально.
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class ArticlePublicationFlowIntegrationTest {
@Test
void fullContextWiringWorks() {
// Здесь будет сквозная проверка: контекст собирается, слои связаны.
// Обычно это место для нескольких ключевых happy-path/critical-path сценариев.
}
}
И это ключевой момент decision tree: full context — это ответ на конкретный тип риска, а не “универсальная привычка”.
8. Типичные ошибки при выборе типа Boot-теста
Ошибка №1: начинать с аннотации, а не с риска.
Самая частая ловушка звучит так: “Мне нужен тест… наверное, @SpringBootTest”. Это похоже на ситуацию “мне нужно покушать… наверное, заказать всё меню”. В результате вы пишете дорогой тест, который непонятно что доказал, и ещё непонятнее почему упал. Решение простое: сначала формулируйте утверждение, которое хотите доказать, и только потом выбирайте минимальный контекст.
Ошибка №2: поднимать Spring там, где достаточно unit-теста.
Это особенно больно, потому что выглядит “профессионально”: тест с Spring-аннотацией, контекст, автоконфигурация — красота. Но если вы тестируете SlugService или PublicationPolicy, Spring добавляет только цену, а не ценность. Такой тест будет медленнее, сложнее в отладке и в итоге чаще “не хочется запускать”. А тест, который не хочется запускать, в критический момент обычно не запускается.
Ошибка №3: выбирать слишком узкий slice для системного дефекта.
Бывает и обратная крайность: вы подозреваете, что проблема на стыке слоёв, но пытаетесь доказать её slice-тестом, где половины нужных компонентов просто нет. В итоге тест “не воспроизводит баг”, и вы делаете ложный вывод, что всё в порядке. Правильная дисциплина здесь такая: если дефект проявляется только в связке, вы тестируете связку. Slice хорош, когда риск действительно живёт внутри одного слоя.
Ошибка №4: дублировать одну и ту же проверку на трёх уровнях без новой доказательной силы.
Иногда хочется “для надёжности” проверить одно и то же и unit-тестом, и @WebMvcTest, и @SpringBootTest. На практике это часто превращается в тройную цену и тройную хрупкость, а не в тройную уверенность. Гораздо полезнее распределять проверки по слоям так, чтобы каждый тест доказывал что-то своё: unit — бизнес-правило, slice — контракт границы, full context — wiring.
Ошибка №5: пытаться “сложить” несколько slice-аннотаций в один супер-тест.
Интуиция подсказывает: “а если я поставлю @WebMvcTest и @DataJpaTest вместе, то получу web+data тест”. Но так это не работает: slices специально сделаны как взаимоисключающие узкие контексты, а не как конструктор Lego. Если вам нужен полный набор слоёв, это уже full context история. Если вам нужен только один слой — выбирайте один slice.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ