1. Запах плохого setup в unit‑тестах
Когда пишешь первые 3–5 unit-тестов, кажется, что всё отлично: пару строк Arrange, одна строка Act, несколько Assert — и жизнь удалась. Но как только тестов становится больше, возникает неприятный перекос: подготовка данных начинает занимать 80% кода. В итоге тест проверяет бизнес-правило как бы «между делом», а основную часть текста занимает сборка объекта, чтобы он просто не развалился.
Представьте, что в Article со временем неизбежно появляются новые поля (в реальном проекте так и бывает): summary, body, timestamps, категория, версия, причина отклонения. В какой-то момент вы попадаете в очень жизненную сцену: хотите протестировать «статья из DRAFT уходит в IN_REVIEW», а в каждом тесте вынуждены заполнять ещё пять полей, которые к сценарию вообще не относятся. И если завтра в Article добавят ещё одно обязательное поле, ваш тестовый пакет начнёт падать… хотя бизнес-правила не менялись.
Вот очень узнаваемый фрагмент теста, который ещё почти хороший, но уже начинает утомлять:
import java.time.Instant;
// Явный конструктор с кучей параметров: в одном месте читается нормально,
// но при росте числа полей начинает распухать каждый тест.
Article article = new Article(
42L, // id
"Spring Testing", // title
"spring-testing", // slug
ArticleStatus.IN_REVIEW, // status
"alice", // author
Instant.parse("2026-03-18T10:00:00Z") // createdAt
);
Пока это 6 строк — терпимо. Но дальше обычно начинается: «а ещё summary… а ещё category… а ещё submittedAt… а ещё publishedAt…». И вот вы уже листаете тест и думаете: «Где тут вообще смысл?»
Нам хочется, чтобы тест читался как сценарий. Примерно так, как мы сами хотим о нём думать:
// В тесте видны только значимые для сценария отличия.
Article article = ArticleFixtures.inReview().withAuthor("alice").build();
Здесь сразу видно главное: статья на ревью, автор — alice. Всё остальное — шум, который не должен мешать чтению.
Чтобы прийти к такому уровню, мы вводим два инструмента:
1) Test Data Builder — «сборщик» доменного объекта с понятными значениями по умолчанию и явной настройкой нужных полей.
2) Fixture Factory — набор именованных заготовок типовых состояний (черновик, на ревью, опубликована), чтобы каждый раз не вспоминать, какие поля нужны.
И ещё один маленький, но важный вывод: иногда проблема не в тестах. Иногда production-код устроен так, что ему самому хочется сказать: «Я не тестируюсь. И не проси». Тогда нужен небольшой refactoring for testability.
2. Test Data Builder для Article
Test Data Builder — это, по сути, конструктор тестовых данных, который позволяет создавать доменные объекты пошагово и читаемо. У него есть стабильные значения по умолчанию, а вы меняете только то, что важно для текущего сценария. Это как собирать бургер: булка и котлета по умолчанию уже на месте, а вот «добавить сыр» — осознанное решение, которое видно в заказе.
Ключевая идея builder’а в unit-тестах — уменьшить шум Arrange-части, но не спрятать смысл. Поэтому builder особенно хорош, когда доменный объект становится «толстым», а его конструктор превращается в поезд из 12 вагонов, где вы вечно путаете, где submittedAt, а где publishedAt.
Давайте создадим test-only builder для Article. Его логичное место — в src/test/java, чтобы не «засорять» production-код учебными удобствами.
Пример структуры в проекте (это просто ориентир, а не закон вселенной):
src/test/java/com/example/contenthub
└─ testdata
├─ ArticleBuilder.java
├─ ArticleFixtures.java
└─ TestInstants.java
Начнём с одной важной практики: детерминированные константы времени. Если builder внутри себя делает Instant.now(), это моментально превращает тесты в лотерею. Поэтому лучше один раз завести тестовые «временные точки».
import java.time.Instant;
public final class TestInstants {
// Фиксированные значения: тесты не зависят от реального времени.
public static final Instant T0 = Instant.parse("2026-03-18T10:00:00Z");
public static final Instant T1 = Instant.parse("2026-03-18T11:00:00Z");
// Утилитный класс: инстансы не создаём.
private TestInstants() {}
}
Теперь сам ArticleBuilder. Сделаем его максимально простым: defaults, fluent-методы и build().
import java.time.Instant;
public class ArticleBuilder {
// Дефолты должны давать валидный объект: тесты меняют только важное.
private String title = "Spring Testing";
private String slug = "spring-testing";
private String author = "alice";
private ArticleStatus status = ArticleStatus.DRAFT;
private Instant createdAt = TestInstants.T0;
// Статическая фабрика для читаемости в тесте: anArticle().withX(...).build()
public static ArticleBuilder anArticle() { return new ArticleBuilder(); }
}
Сами with... и build() — лаконично, но без магии:
// Fluent API: каждый with-метод возвращает builder, чтобы можно было цепочкой.
public ArticleBuilder withStatus(ArticleStatus status) { this.status = status; return this; }
public ArticleBuilder withAuthor(String author) { this.author = author; return this; }
public ArticleBuilder withTitle(String title) { this.title = title; return this; }
public ArticleBuilder withCreatedAt(Instant createdAt) { this.createdAt = createdAt; return this; }
// build() должен собирать корректный доменный объект.
// Важно: здесь мы сознательно НЕ добавляем "умную" бизнес-логику — только сборку данных.
public Article build() { return new Article(title, slug, status, author, createdAt); }
По тому же шаблону без сюрпризов добавляются withSubmittedAt(...), withPublishedAt(...) и другие поля, если ваша модель их реально хранит. Здесь нам важнее сам паттерн: defaults + явные переопределения без рандома и скрытой логики.
Тут я намеренно сделал build() очень прямолинейным. В реальном ContentHub Article наверняка содержит больше полей. Тогда build() может:
1) либо вызывать фабричный метод Article.draft(...)/Article.inReview(...),
2) либо использовать «тестовый» конструктор / package-private сеттеры,
3) либо собирать объект через production API, если оно удобно и не делает тесты хрупкими.
Главный критерий: builder должен выдавать валидный объект по умолчанию, чтобы вы не ловили странные падения из-за того, что «в фикстуре забыли обязательное поле».
Теперь использование builder’а в тесте выглядит именно так, как нам и хочется: коротко и по делу.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleBuilderTest {
@Test
void buildsArticleWithOverriddenFields() {
Article article = ArticleBuilder.anArticle()
// В тесте явно видно, что переопределяем (это и есть смысл builder'а).
.withStatus(ArticleStatus.IN_REVIEW)
.withAuthor("bob")
.build();
// Проверяем наблюдаемый результат, без привязки к внутренностям билдера.
assertThat(article.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
}
}
Обратите внимание на психологический эффект: вы читаете тест и сразу видите важные отличия — IN_REVIEW, автор bob. Не нужно «раскрывать» конструктор на 10 параметров и глазами искать, где там статус.
Точно так же можно сделать builder для метаданных вложения, потому что это тоже типичный кандидат на копипасту: contentType, размер, имя файла. В unit-тестах нам почти всегда достаточно метаданных.
public class AttachmentMetaBuilder {
// Дефолты: "реалистичные", но стабильные (без рандома).
private String filename = "guide.pdf";
private String contentType = "application/pdf";
private long size = 1_000_000L;
public static AttachmentMetaBuilder anAttachment() { return new AttachmentMetaBuilder(); }
// Прямая сборка объекта: минимум "магии" в тестовых данных.
public AttachmentMeta build() { return new AttachmentMeta(filename, contentType, size); }
}
Да, этот builder простенький. И это нормально: в тестовых данных лучше «простенько и прозрачно», чем «универсально и непонятно».
3. Fixture Factory: типовые состояния
Fixture Factory — это следующий уровень удобства, когда вы понимаете, что builder всё равно приходится в 70% случаев настраивать одинаково. Если в вашем домене есть типовые состояния — черновик, статья на ревью, опубликованная статья, — их полезно назвать и вынести в одно место. Тогда тесты начинают напоминать нормальный текст: «берём опубликованную статью», а не «создаём статью со статусом PUBLISHED и publishedAt не null».
Важно: fixture factory не должна превращаться в «тайный язык», который понимает только её автор. Это частая беда: люди пишут ArticleFixtures.magicArticle(), а через неделю уже никто не помнит, что там «магического». Хорошая фикстура обычно говорит человеческими словами: draft(), inReview(), published().
Есть два распространённых стиля fixture factory:
— Первый стиль: методы возвращают готовые объекты. Это максимально просто, но иногда не хватает гибкости: вы хотите «черновик, но с другим автором».
— Второй стиль: методы возвращают заготовку builder’а, уже настроенную под типовое состояние. Это часто удобнее: имя состояния сохраняется, а нужные поля по-прежнему можно переопределить без плясок.
Мы выберем второй стиль, потому что он одновременно поддерживает и читаемость, и гибкость.
public final class ArticleFixtures {
// Только статические фабрики: экземпляры фикстур не нужны.
private ArticleFixtures() {}
public static ArticleBuilder draft() {
// "Именованное состояние": черновик.
return ArticleBuilder.anArticle().withStatus(ArticleStatus.DRAFT);
}
}
Статус IN_REVIEW — хороший пример того, как фикстура кодирует именованное состояние. В упрощённом примере ниже мы фиксируем сам статус. Если вашей модели ещё нужен обязательный submittedAt, builder расширяется тем же паттерном через withSubmittedAt(...).
public static ArticleBuilder inReview() {
return ArticleBuilder.anArticle()
.withStatus(ArticleStatus.IN_REVIEW);
}
Использование в тестах получается очень естественным:
// Читается как сценарий: "статья на ревью, автор alice".
Article article = ArticleFixtures.inReview()
.withAuthor("alice")
.build();
Пара важных нюансов, которые резко повышают качество фикстур:
Если поле важно для сценария, лучше явно задать его в тесте, а не надеяться на default. Например, если вы тестируете slug-генерацию, название статьи — часть правила, значит в тесте лучше написать .withTitle(" Spring Boot "), а не оставлять дефолт Spring Testing.
Если поле не важно для сценария, но объект обязан быть валидным, оставляем его внутри builder/fixture. Например, если category обязательна по инвариантам домена, а тест вообще про другое, пусть дефолт живёт в фикстуре. Тест не должен превращаться в заполнение анкеты ради самого заполнения.
Чтобы не запутаться, полезно держать в голове простое сравнение — без войны религий:
| Инструмент | Что даёт | Когда удобен | Типичный запах, что вы переборщили |
|---|---|---|---|
| Inline setup | Максимальная прозрачность | Очень простой объект или один тест | Копипаста полей и дат по 20 раз |
| Test Data Builder | Мало шума, видно важные поля | «Толстые» доменные объекты | Builder прячет бизнес-логику или становится «умнее» самого домена |
| Fixture Factory | Имена типовых состояний | Много тестов с одинаковыми состояниями | Появились загадочные методы superValidArticle() и articleForCase42() |
4. Builders/fixtures в тестах ContentHub
Теперь давайте сделаем самое полезное: посмотрим, как builders и fixtures реально «лечат» тесты. Понять идею можно за минуту, а вот почувствовать пользу — только когда переписываешь реальный кусок кода и видишь, что тест наконец начинает дышать.
Возьмём типичный сценарий из ArticleWorkflowServiceTest: мы хотим проверить, что при создании черновика сервис записывает автора, slug и время. Раньше мы могли ловить аргумент через ArgumentCaptor. Это рабочий подход, но часть шаблонного кода можно заменить на fake repository — в unit-тестах иногда это даже проще и честнее, чем моки.
Мини-фейковый репозиторий (это тот самый fake из прошлой темы про test doubles):
public class InMemoryArticleRepository implements ArticleRepository {
// Храним то, что было сохранено: это позволит тесту сделать assert по факту.
private Article lastSaved;
public Article save(Article article) {
// Фейк не должен быть "умным": просто запоминаем и возвращаем.
this.lastSaved = article;
return article;
}
// Удобный "шлюз" для проверки в тесте.
public Article lastSaved() { return lastSaved; }
}
Теперь тест читается ровнее: мы меньше говорим о Mockito и больше — о результате.
import java.time.Clock;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
// Фиксируем время: никаких Instant.now() в тесте.
Clock clock = Clock.fixed(TestInstants.T0, ZoneOffset.UTC);
// Фиксируем пользователя: тест не зависит от системных свойств.
CurrentUserProvider user = () -> "alice";
// Фиксируем slug: тест не про алгоритм slugify, а про оркестрацию.
SlugService slugService = title -> "spring-testing";
// Подменяем хранилище на фейк: проверим, что именно было сохранено.
InMemoryArticleRepository repo = new InMemoryArticleRepository();
ArticleWorkflowService service = new ArticleWorkflowService(repo, new PublicationPolicy(), slugService, user, clock);
service.createDraft("Spring Testing", "short", "body", "java");
assertThat(repo.lastSaved().getAuthorUsername()).isEqualTo("alice");
Здесь видно несколько важных моментов.
Во-первых, зависимости детерминированы: clock фиксированный, user фиксированный, slugService фиксированный. Тест не зависит от текущего времени и не угадывает пользователя по системным настройкам.
Во-вторых, мы проверяем наблюдаемое поведение: что реально было сохранено. В этом и состоит прямой смысл unit-теста оркестрации.
А где здесь builders/fixtures? В createDraft() они нужны меньше: объект создаётся внутри сервиса. Но как только мы тестируем методы, которые принимают готовую статью, — они начинают экономить очень много кода.
Например, submitForReview(article) требует «черновик». И вот тут фикстура идеальна:
// Исходная точка сценария: "черновик".
Article draft = ArticleFixtures.draft()
.withAuthor("alice")
.build();
service.submitForReview(draft);
// Проверяем ключевое правило: статус изменился.
assertThat(draft.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
Тест очень короткий и при этом не превращается в загадку. Сразу видно, какая исходная точка — draft, — и что именно мы ожидаем — IN_REVIEW.
А если нам важны timestamps, builder делает это читаемо:
Article draft = ArticleFixtures.draft()
// В тесте явно задаём время, важное для сценария.
.withCreatedAt(TestInstants.T0)
.build();
service.submitForReview(draft);
// Сравниваем с фиксированной "контрольной" точкой времени.
assertThat(draft.getSubmittedAt()).isEqualTo(TestInstants.T1);
Здесь подразумевается, что сервис использует фиксированный Clock и выставляет submittedAt через него.
Главная мысль этого раздела: builders и fixtures помогают тесту выглядеть как история, а не как «разминка пальцев по клавиатуре». Но они не должны превращать Arrange в «чёрный ящик». Если вы читаете тест и вам приходится прыгать по файлам в testdata, чтобы понять, что происходит, — вы уже переборщили.
5. Refactoring for testability в production-коде
На этом месте обычно происходит озарение: иногда тест некрасивый не потому, что вы «плохо пишете тесты». Иногда тест некрасивый потому, что production-код спрятал зависимости и стал недетерминированным. И вы можете сколько угодно строить builders, но если внутри метода живёт Instant.now() и new SomeService(), тест всё равно будет страдать.
Refactoring for testability — это не переписывание приложения и не «давайте внедрим архитектуру на 40 диаграмм». В учебном проекте ContentHub это чаще всего несколько предсказуемых шагов: сделать зависимости явными, убрать скрытые глобальные вызовы, отделить правила от оркестрации, дать возможность подменить источники времени и пользователя.
Самый узнаваемый пример «нетестабельного» кода — такой:
public Article createDraft(String title) {
// Проблема: реальное время => тесты недетерминированы.
Instant now = Instant.now();
// Проблема: глобальное состояние машины => тесты зависят от окружения.
String author = System.getProperty("user.name");
// Проблема: new внутри метода => невозможно контролировать поведение зависимости в тесте.
String slug = new DefaultSlugService().toSlug(title);
Article article = Article.draft(title, slug, author, now);
return repository.save(article);
}
Это всё ещё работает. Но unit-тестировать такое неприятно: время всегда разное, автор зависит от машины, а slug-сервис создаётся внутри метода, поэтому его поведение нельзя контролировать.
Мини-рефакторинг делает код дружелюбным к unit-слою:
public Article createDraft(String title) {
// Время приходит извне (Clock можно зафиксировать в тесте).
Instant now = Instant.now(clock);
// Текущий пользователь — порт, который в тесте легко подменить стабом/лямбдой.
String author = currentUserProvider.currentUsername();
// SlugService — зависимость, которую можно заменить тестовой реализацией.
String slug = slugService.toSlug(title);
Article article = Article.draft(title, slug, author, now);
return repository.save(article);
}
Заметьте, бизнес-смысл не изменился. Мы просто сделали зависимости явными: clock, currentUserProvider, slugService.
А чтобы это работало, зависимости должны приходить через конструктор (constructor injection — правило проекта, и в unit-layer оно тоже прекрасно живёт):
public ArticleWorkflowService(ArticleRepository repository,
PublicationPolicy policy,
SlugService slugService,
CurrentUserProvider currentUserProvider,
Clock clock) {
// Явные зависимости: в unit-тесте можно собрать сервис вручную без контейнера.
this.repository = repository;
this.policy = policy;
this.slugService = slugService;
this.currentUserProvider = currentUserProvider;
this.clock = clock;
}
С точки зрения unit-теста это магия без магии: вы создаёте сервис руками и полностью контролируете окружение. Никаких «а почему сегодня тест упал, а вчера нет».
Иногда полезно буквально нарисовать себе схему зависимостей. Даже простая диаграмма помогает увидеть, что является правилом (policy), а что — инфраструктурой (время, пользователь, репозиторий).
flowchart TD Test["Unit-тест"] --> Svc["ArticleWorkflowService"] Svc --> Policy["PublicationPolicy"] Svc --> Repo["ArticleRepository (port)"] Svc --> Clock["Clock"] Svc --> User["CurrentUserProvider (port)"] Svc --> Slug["SlugService"]
Если вы видите в сервисе стрелочку на «мир наружу» — время, пользователь, хранилище, генераторы, — и эта стрелочка реализована через new или static-вызов, это почти всегда кандидат на рефакторинг. В unit-layer такие вещи бьют сразу по двум местам: по детерминированности и по ясности теста.
И да, небольшая самоирония: если вы когда-нибудь ловили себя на мысли «а давайте замокаем Instant.now()», не ругайте себя. Это просто момент, когда код шепчет: «передай мне Clock, и мы оба будем счастливы».
6. Порты и адаптеры для unit-layer
Слова «порты и адаптеры» иногда звучат как начало лекции по архитектурной магии, после которой хочется молча уйти в лес с пледом. Но в контексте unit-тестов это очень приземлённая идея: бизнес-логика должна зависеть от интерфейсов, а не от конкретных способов «ходить в мир». Тогда в unit-тесте вы подменяете внешний мир простым стабом или фейком — и тест остаётся быстрым и локальным.
Мы уже фактически использовали этот подход, когда вводили CurrentUserProvider. Это типичный порт: «дай мне имя текущего пользователя», но не рассказывай, откуда ты его взял.
@FunctionalInterface
public interface CurrentUserProvider {
// Порт: бизнес-логика не знает, откуда берётся username (SecurityContext, HTTP, etc.).
String currentUsername();
}
В тесте это превращается в одну строку — и это прекрасно:
// Стаб: фиксированное значение, тест не зависит от окружения.
CurrentUserProvider user = () -> "alice";
То же самое можно сделать с генерацией slug, если вы хотите в тесте полностью контролировать результат:
// Стаб: тест управляет результатом и не тестирует алгоритм slugify.
SlugService slugService = title -> "spring-testing";
А ArticleRepository — это тоже порт: «сохрани статью». В unit-тесте вам не нужна база данных. Вам нужно доказать, что сервис правильно собрал объект и попытался его сохранить. Для этого достаточно мока или фейка, а выбор зависит от того, что читается проще.
Если вы ловите себя на том, что verify(repo).save(captor.capture()) выглядит тяжеловато, фейк действительно может быть чище. Мы уже показали InMemoryArticleRepository, и это хороший пример того, что test doubles — это не только Mockito.
Важно только не переборщить и не превратить фейки в мини-базу данных с кучей логики. Фейк должен оставаться тупым: «запомнил последний сохранённый объект и отдал его». Как только он начинает «умно валидировать» или «генерировать id», вы случайно начинаете тестировать фейк вместо сервисной логики.
Именно поэтому связка «порты + явные зависимости через конструктор» — это основа testability. Builders и fixtures упрощают подготовку данных. Порты и адаптеры упрощают контроль внешнего мира. Вместе они дают unit-слой, который не живёт «по удаче».
7. Типичные ошибки при builders и fixtures
В этом месте обычно хочется радостно побежать и написать 15 builders, 25 фикстур и маленький DSL, чтобы тесты выглядели как стихотворение. Остановимся на минуту и поговорим о граблях. Они почти всегда одни и те же, и лучше наступить на них в учебном тексте, чем в продакшене в пятницу вечером.
Ошибка №1: builder создаёт случайные данные (рандом, now(), UUID) «для реализма».
Реализм в unit-тестах часто переоценён. Если defaults меняются от запуска к запуску, вы получаете недетерминированность: тест может падать «иногда». Builder должен быть скучным и стабильным: фиксированные даты, предсказуемые строки, никаких сюрпризов.
Ошибка №2: builder по умолчанию строит невалидный объект.
Если ArticleBuilder.anArticle().build() даёт статью без обязательного поля, часть тестов начнёт падать по причинам, не связанным со сценарием. В итоге вы будете чинить «фикстуры», а не тестировать бизнес-правила. Хороший builder по умолчанию должен строить минимально корректное состояние, чтобы вы меняли только то, что важно.
Ошибка №3: fixture factory превращается в набор загадочных заклинаний.
Методы типа ArticleFixtures.articleForCase42() или superValidArticle() часто работают ровно до тех пор, пока автор помнит, что внутри. Потом команда начинает бояться менять фикстуры, потому что «сломаем неизвестно что». Фикстуры должны называться по доменному смыслу: draft(), inReview(), published(), а не по внутренним деталям реализации.
Ошибка №4: хелперы прячут бизнес-смысл сценария вместо того, чтобы прятать шум.
Сокращать «шум» — это убрать лишние поля и повторяющиеся значения. Но если хелпер скрывает ключевое условие сценария — например, автора статьи или статус, — тест перестаёт быть документацией поведения. Хороший тест должен отвечать на вопрос «что проверяем?» без прыжков по файлам.
Ошибка №5: production-код продолжает создавать зависимости внутри методов.
Можно написать идеальный builder, но если сервис всё равно делает new DefaultSlugService() или тянет пользователя из глобального состояния, тест будет мучительным. В unit-layer мы хотим управлять окружением. Поэтому refactoring for testability почти всегда начинается с простого: зависимости в конструктор, глобальные вызовы — в адаптеры.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ