1. Поддерживаемость тестов
Когда проект маленький, тесты пишутся «на вдохновении»: создал объект, замокал зависимость, проверил status().isOk() — и побежал дальше. Но в реальном Spring Boot приложении (и у нас в ContentHub это уже видно) тестов становится десятки и сотни, и самая дорогая операция — не написать новый тест, а понять старый, который упал в CI в пятницу вечером.
Проблема обычно не в том, что тест «сложный». Проблема в том, что он нечитабельный: данные размазаны по методу, магические строки повторяются, разные слои (DTO, entity, security, web) смешаны в одну кашу, и любой рефакторинг превращается в игру «найди 27 мест, где захардкожен slug». Поэтому мы и говорим о трёх вещах, которые буквально спасают suite: fixture factories (читаемые тестовые данные), небольшой test DSL (читаемое действие) и naming conventions (предсказуемая навигация).
Чтобы зафиксировать мысль, полезно представить «цену» теста не только как время выполнения, но и как время понимания:
flowchart TD A["Тест упал"] --> B["Найти тест"] B --> C["Понять сценарий"] C --> D["Понять данные"] D --> E["Понять слой (unit/web/data/integration)"] E --> F["Починить причину"]
Если шаги C–E занимают больше времени, чем F, то suite технически есть, но как инженерный инструмент он превращается в декоративное растение: красиво зелёное, но пользы мало.
2. Fixture factory: данные на языке домена
Fixture factory — это простой тестовый код, который создаёт тестовые данные так, чтобы они читались как смысл, а не как «вот тут у нас 12 полей конструктора, и мы надеемся, что порядок правильный». В идеале по названию метода фикстуры вы понимаете состояние: draftArticle(), publishedArticle(), validCreateRequest(), missingTitleRequest().
Очень важно, что fixture factory — не «универсальный генератор всего на свете», а аккуратная библиотечка для вашего test suite. Она должна быть детерминированной (никаких Random и «сегодняшней даты» без причины), должна избегать глобального mutable состояния и должна быть разделена по слоям. И да, это тот редкий случай, когда копипаста иногда лучше, чем один «супер-метод» на 200 параметров.
Сравним три подхода к тестовым данным на одной картинке — чтобы у мозга появился внутренний «детектор плохих фикстур»:
| Подход | Как выглядит | Что хорошего | Где обычно больно |
|---|---|---|---|
| Inline данные прямо в тесте | new CreateArticleRequest("...", "...", ...) | Быстро начать | Повторы, магические строки, трудно менять |
| Builder (test data builder) | CreateArticleRequestBuilder .aRequest().withTitle(...) | Гибкость, читаемость | Легко уйти в «мини-фреймворк» |
| Fixture factory (Object Mother / factory) | ArticleRequestFixtures.valid() | Очень читаемо, мало шума | Нужна дисциплина, чтобы не превратить в свалку |
В нашем курсе мы чаще держимся за fixture factory, потому что она проще для junior-уровня: меньше абстракций, больше прямого смысла.
Мини-пример для ContentHub: фикстура для request DTO на web-границе.
import com.acme.contenthub.api.dto.request.CreateArticleRequest;
public final class ArticleRequestFixtures {
public static CreateArticleRequest valid() {
// Важно: значения фиксированные и читаемые — так тест становится «мини-историей», а не набором случайных строк.
// Если DTO поменяется, вы правите одно место, а не 15 тестов.
return new CreateArticleRequest("Intro to tests", "Short summary", "Body", "JAVA");
}
}
Сама по себе эта штука выглядит почти смешно — «ради чего файл?». Но как только вы используете это в 15 MVC-тестах, вы внезапно понимаете, что файл стоил своих 30 секунд. И вы ещё сильнее понимаете это, когда меняется структура DTO: вместо «правим 15 тестов» вы правите одно место.
3. Фикстуры по слоям: DTO, Entity, Flow
Самая частая причина деградации test suite — фикстуры, которые начинают протекать между слоями. Сегодня вы берёте entity Article в @WebMvcTest, потому что «так проще». Завтра вы начинаете сериализовать entity в JSON, потому что «оно же работает». Послезавтра вы плачете, потому что поменяли mapping или добавили ленивую связь — и внезапно ваш controller test падает из-за JPA-нюанса. Тест-то был про HTTP-контракт, но страдает как будто он data-layer.
Чтобы не сделать suite «универсально страдающим», фикстуры обычно делят как минимум на три семейства: для web-layer DTO, для data-layer entities, и для integration-сценариев (где вы создаёте состояние через API/репозиторий/SQL). Это не бюрократия, это способ держать границы ответственности.
Представим это как «правило трёх коробок» — и пусть каждая коробка остаётся в своём шкафу:
flowchart TD A["@WebMvcTest"] --> B["DTO fixtures (request/response)"] C["@DataJpaTest"] --> D["Entity fixtures (Article/Category/Attachment)"] E["@SpringBootTest"] --> F["Flow fixtures (draft->submit->approve)"]
Мини-пример: отдельно фикстуры для сущностей. Да, это может быть другой пакет, другое имя и другой стиль — и это нормально.
import com.acme.contenthub.entity.Article;
import com.acme.contenthub.entity.ArticleStatus;
public final class ArticleEntityFixtures {
public static Article draft() {
// Entity-фикстуры должны использоваться в data-тестах (например, @DataJpaTest),
// чтобы не тащить JPA-детали в MVC слой.
return new Article("Draft title", "draft-title", ArticleStatus.DRAFT, "alice");
}
}
Тут важно не то, что конструктор «реальный» (у вас он может быть другим), а сама мысль: entity-фабрика живёт там, где живут data-тесты, и не тащится в MVC слой как универсальная палочка-выручалочка.
4. Детерминированные фикстуры: время, slug, id
На ранних этапах кажется, что Instant.now() в тесте — безобидная мелочь. Потом вы внезапно получаете тест, который «иногда падает на границе суток», и выясняется, что это не мистика, а банальная временная зависимость. То же самое с UUID.randomUUID() и случайными slug: тест становится трудно читать (невозможно глазами понять, что ожидается), а отладка превращается в охоту на невидимого гремлина.
Правило простое: если значение важно для смысла теста, оно должно быть фиксированным и читабельным. Если значение неважно — лучше вообще его не проверять. Фикстуры как раз дают место, где эти fixed values можно хранить централизованно.
Например, фиксированное время для тестов. Даже если в production вы используете Clock, в тестовом коде удобно иметь один «канонический момент».
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
public final class TestClocks {
public static Clock fixedUtc() {
// Фиксируем "текущее время" для стабильных тестов.
// Важно: никаких now() — иначе получите падения «иногда», особенно на границах суток и таймзон.
return Clock.fixed(Instant.parse("2026-03-21T10:15:30Z"), ZoneOffset.UTC);
}
}
Это не «религия фиксированного времени». Это способ сделать тесты читаемыми и стабильными. И, что важно, это ещё один сигнал дизайна: если вы не можете подставить Clock в нужной слой — значит, где-то осталась скрытая глобальная зависимость, а такие зависимости обычно плохо тестируются.
Та же идея с slug и пользовательскими именами. В ContentHub у нас есть роли и пользователи (EDITOR, ADMIN, alice, bob). Если вы будете генерировать имена случайно, вы убьёте читаемость. Гораздо приятнее, когда test data выглядит как мини-история.
public final class TestUsers {
// Имена «героев» тестов: читаемо в логах и понятно в правилах доступа.
public static final String ALICE = "alice";
public static final String BOB = "bob";
private TestUsers() {
}
}
Честно: да, это «константы в тестах». Но они превращают логику access rules в читаемый сюжет, а не в user_84f1.
5. Test DSL: меньше шума, больше действия
Когда тестов становится много, повторяется один и тот же «шум»: построить запрос, поставить заголовки, засунуть body, проверить статус, иногда распарсить ответ. И тут появляется соблазн написать «универсальный DSL», который сделает всё за вас. Это опасный момент. Хороший test DSL — тонкий. Он экономит повторение, но не скрывает границу слоя. Плохой DSL превращает тест в загадку: «кто тут что вызвал и что вообще проверяем?».
Практическое правило: DSL должен описывать действие (act), а не утащить в себя весь тест (arrange+assert тоже). Действие у нас часто одно: «вызвать endpoint» или «сохранить сущность». А вот assertions лучше держать рядом с тестом или в маленьких assertion helpers (о них ниже).
Здесь это сознательно другой поток, чем в прямом assertThat(mvc.get()... примере: helper делает только действие через .exchange(), а проверка остаётся в тесте.
Мини-пример тонкого DSL для MVC-тестов на MockMvcTester. Обратите внимание: он не делает assertions, он только выполняет запрос и возвращает результат.
import org.springframework.test.web.servlet.assertj.MockMvcTester;
class EditorApi {
private final MockMvcTester mvc;
EditorApi(MockMvcTester mvc) { this.mvc = mvc; }
MockMvcTester.Result createDraft(Object body) {
// DSL делает только действие: выполняет запрос.
// Важно: не прячем assert-ы сюда, чтобы тест оставался читаемым «снаружи».
return mvc.post().uri("/api/editor/articles").body(body).exchange();
}
}
Теперь тест становится короче, но всё ещё прозрачный — вы видите URL и видите, что отправляете body.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.assertj.MockMvcTester.assertThat;
class EditorArticleControllerWebMvcTest {
private EditorApi editor; // допустим, инициализировали в setup
@Test
void shouldCreateDraft() {
// Arrange спрятан в фикстуре: читаемые и детерминированные данные.
var res = editor.createDraft(ArticleRequestFixtures.valid());
// Assert рядом с тестом: по упавшей проверке сразу видно ожидание.
assertThat(res).hasStatusCreated();
}
}
Это и есть «маленький DSL»: он делает тест короче и менее шумным, но не превращает его в магию.
6. Naming conventions: навигация в suite
Когда в проекте десятки тестов, навигация становится проблемой уровня IDE: вы ищете «тесты про public API», а находите ControllerTest1, PublicTest, ArticleTestNew2. Это смешно ровно до того момента, пока вы не тратите 20 минут на поиск нужного файла.
Naming conventions решают это простым способом: по имени класса (и часто по пакету) сразу видно слой и ответственность. У нас в ContentHub это особенно важно, потому что слоёв много: unit, json, web slice, data slice, integration, security, outbound, async, containers, docs. И когда имя выдержано, мозгу легче: он не «угадывает», а распознаёт паттерн.
Одна из удобных моделей — суффиксы по типу теста (она уже зафиксирована в проектной документации курса):
| Суффикс/имя | Что это значит | Пример |
|---|---|---|
| Test без аннотаций Spring | unit | PublicationPolicyTest |
| JsonTest | JSON-contract слой | ApiProblemJsonTest |
| WebMvcTest | MVC slice | PublicArticleControllerWebMvcTest |
| DataJpaTest | data slice | ArticleRepositoryDataJpaTest |
| IntegrationTest | full context/live server | ArticlePublicationFlowIntegrationTest |
| RestDocsTest | REST Docs поверх MVC | PublicArticleRestDocsTest |
Вам не нужно делать «идеальную классификацию всего мира». Вам нужно, чтобы открыв тестовый класс, вы за 2 секунды отвечали на три вопроса: какой слой, что проверяет, чем питается (моки/БД/сервер).
Имена методов тоже важны. Мы уже говорили про @DisplayName и AAA, но здесь добавим маленькое правило: имя теста должно звучать как ожидание поведения, а не как «кликнуть кнопку». В бэкенде кнопок нет, поэтому лучше сразу писать как про контракт или правило.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PublicArticleControllerWebMvcTest {
@Test
@DisplayName("GET /api/public/articles возвращает только PUBLISHED статьи")
void shouldReturnOnlyPublishedArticles() {
// Здесь название — часть документации: что именно гарантирует контракт.
// Идея: по одному DisplayName уже понятно, почему тест существует.
// ...
}
}
Да, это чуть длиннее, чем test1(). Но test1() вы будете проклинать. А эту строку — нет.
7. Assertion helpers: переиспользование проверок
Когда у вас появляется стабильный error contract (ApiProblem с status, errorCode, violations), рука тянется вынести проверки в helper. Это хорошая идея, если helper не превращается в «огромный метод, который проверяет всё подряд и непонятно что именно». Хороший helper группирует повторяемые низкоуровневые проверки, оставляя смысл сценария в тесте.
Например, у вас часто повторяется «access denied» и вы хотите проверять минимум: статус и код ошибки. Вынесем это в helper для DTO, а не для MVC результата — так он будет применим в разных слоях.
import com.acme.contenthub.api.error.ApiProblem;
import static org.assertj.core.api.Assertions.assertThat;
public final class ApiProblemAssertions {
public static void assertAccessDenied(ApiProblem p) {
// Держим helper маленьким: только то, что действительно повторяется.
// Смысл сценария («почему именно access denied») остаётся в тесте.
assertThat(p.status()).isEqualTo(403);
assertThat(p.errorCode()).isEqualTo("ACCESS_DENIED");
}
}
А в тесте вы оставляете контекст: где и почему вы ожидаете эту ошибку. Helper не должен заменять сюжет.
Важное ограничение: если helper начинает сам создавать данные, делать HTTP-вызов и ещё и проверять JSON — он стирает границы слоя и превращается в микросервис внутри тестов. Это почти всегда плохой знак. Небольшое дублирование в тестах иногда полезнее, чем «универсальный комбайн».
8. Типичные ошибки: фикстуры, DSL и имена
Ошибка №1: fixture factory превращается в «свалку всего».
Обычно это начинается невинно: вы добавили ArticleFixtures.valid(), потом ArticleFixtures.validWithLongTitle(), потом ArticleFixtures.forMvc(), ArticleFixtures.forJpa(), ArticleFixtures.forIntegration() — и всё это в одном классе. Через месяц никто не понимает, чем отличаются методы, и люди снова начинают писать inline-данные. Лечится это простым разделением по слоям: DTO фикстуры отдельно, entity фикстуры отдельно, flow-фикстуры отдельно.
Ошибка №2: тестовые данные начинают жить случайной жизнью.
Это классика Instant.now(), UUID.randomUUID() и «пусть slug будет title + "-" + random». Иногда это прокатывает, но читабельность умирает мгновенно: вы не можете глазами понять, что ожидаете, и любые отладочные сообщения становятся бессмысленными. Детерминированные значения не «делают тесты скучными», они делают их инженерными.
Ошибка №3: DSL прячет HTTP-контракт или состояние БД.
Если ваш DSL-метод называется publishArticle() и внутри делает три HTTP-запроса, подменяет пользователя, ещё и проверяет JSON — тест превращается в одну строчку, но вместе с ней исчезает понимание того, что реально проверяется. Особенно опасно это в курсе про тестирование: вы вроде бы «сократили код», но на самом деле спрятали от себя же механику слоя.
Ошибка №4: naming conventions «примерно такие», но везде разные.
Один тест у вас ArticleControllerTest, другой PublicArticleControllerWebMvcTest, третий publicArticlesTest. В итоге поиск и навигация по IDE не помогают, и любой новый участник команды снова тратит время на ориентацию. Лучше выбрать скучный, предсказуемый паттерн и соблюдать его, чем каждый раз «называть как чувствуется».
Ошибка №5: assertion helpers вытесняют смысл из теста.
Если в тесте остаётся только assertEverythingIsOk(res), а внутри helper-а 40 проверок, тест перестаёт быть документацией. Особенно неприятно, когда падает одна из 40 проверок, и по стектрейсу непонятно, какой бизнес-смысл вообще нарушен. Helper должен быть маленьким и тематическим: «проверить errorCode», «проверить базовые поля pagination», а не «проверить вселенную».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ