1. Маленькие правила как unit-targets
Когда впервые смотришь на сервис вроде SlugService, мозг иногда честно спрашивает: «Серьёзно? Тестировать replaceAll()?» Но в реальном проекте именно такие мелочи ломают API-контракт и рождают странные баги уровня «вчера ссылки работали, сегодня — 404». Маленькие правила хороши тем, что их легко сделать чистыми, детерминированными и быстрыми.
После оркестратора полезно посмотреть на другой край unit-layer: сервисы, где нет координации нескольких зависимостей, а есть одно локальное правило на вход и выход. В ContentHub это как раз slug и валидация вложений — два небольших unit-target’а, на которых особенно хорошо видно, что unit-тесту часто не нужны ни Spring, ни тяжёлые моки.
Ключевая мысль: в unit-layer мы любим сервисы-правила, у которых есть понятный вход и понятный выход. Они похожи на хорошую математическую функцию: один и тот же вход всегда даёт один и тот же результат. Если функция вдруг начинает зависеть от текущего времени, системной локали, случайных чисел или состояния базы данных, это уже не «маленькое правило», а мини-приключение. А приключения в тестах обычно заканчиваются фразой «на моей машине проходит».
В ContentHub таких маленьких правил несколько. SlugService превращает заголовок в аккуратный slug. AttachmentValidationService решает, можно ли принять вложение по его метаданным и текущим лимитам. Это как раз те случаи, где unit-тесты дают простое преимущество: вы быстро и точно фиксируете контракт правила и перестаёте спорить с коллегами в стиле «а пробелы в конце заголовка мы учитываем?».
Slug в ContentHub: ожидания и границы
Slug — это «человекочитаемый идентификатор» статьи, который удобно использовать в URL и в публичных ссылках. В ContentHub мы хотим, чтобы статья могла открываться по адресу вроде /api/public/articles/spring-boot-testing-basics, а не по /api/public/articles/42. И это не просто эстетика: slug удобно читать в логах, им проще делиться, да и сам сервис от этого ощущается дружелюбнее, а не как бухгалтерия.
Важно заранее договориться о границе ответственности SlugService. Он не должен проверять уникальность slug в базе данных, не должен сохранять его и не должен знать о репозиториях. Его задача намного проще и честнее: получить строку и вернуть строку. Уникальность — это «другая плоскость» (уровень хранения или бизнес-оркестрации), и смешивание этих задач — классический способ сделать unit-тесты либо медленными, либо бессмысленно замоканными.
Чтобы не обсуждать slug «на пальцах», полезно зафиксировать ожидания в виде примеров. Это не список требований «как в ГОСТ», а скорее договорённость команды: как именно мы приводим заголовок к slug, что делаем с пробелами, пунктуацией и регистром.
| Заголовок статьи | Ожидаемый slug |
|---|---|
| Spring Boot Testing Basics | spring-boot-testing-basics |
| Spring Boot | spring-boot |
| Hello, world! | hello-world |
| JUnit 6: @Nested | junit-6-nested |
| Тестирование в Spring | тестирование-в-spring |
Да, slug может содержать кириллицу: технически это допустимо, URL просто будет percent-encoded. Хотите латиницу и транслитерацию — это отдельная история; мы не будем усложнять курс внешними библиотеками, пока учимся тестировать. Для нас сейчас важнее детерминизм и ясность правил, чем «идеальный SEO».
2. Реализация SlugService
Сейчас мы сделаем SlugService максимально дружелюбным к unit-тестам: никаких скрытых зависимостей, обращений к окружению и случайных суффиксов. И отдельно будем осторожны с локалью, потому что toLowerCase() по умолчанию зависит от системной локали. А это как раз тот баг, который проявляется только у коллеги на ноутбуке и сначала выглядит как мистика, пока не вспомнишь про турецкую букву I.
Тип сервиса: интерфейс для подмены
В прошлой лекции мы иногда подменяли генерацию slug простым стабом. Чтобы это естественно выглядело в Java, удобно сделать SlugService функциональным интерфейсом. Тогда в unit-тесте или в тесте координирующего сервиса можно подставить лямбду, не включая Mockito «ради одной строчки».
package com.contenthub.service;
// Функциональный интерфейс удобно подменять лямбдой в тестах.
@FunctionalInterface
public interface SlugService {
// Явный контракт: получили заголовок -> вернули slug.
String toSlug(String title);
}
Простая реализация: DefaultSlugService
Наша реализация будет делать несколько предсказуемых шагов: нормализовать строку, убрать диакритические символы после нормализации, привести всё к нижнему регистру через Locale.ROOT, заменить все последовательности не из букв/цифр на дефис и убрать дефисы по краям. От null и пустого заголовка тоже защитимся явно, потому что правило «заголовок обязателен» — часть доменных инвариантов ContentHub.
package com.contenthub.service;
import java.text.Normalizer;
import java.util.Locale;
public class DefaultSlugService implements SlugService {
@Override
public String toSlug(String title) {
// Защита доменного инварианта: пустой заголовок недопустим.
if (title == null || title.isBlank()) throw new IllegalArgumentException("title is blank");
String s = Normalizer.normalize(title, Normalizer.Form.NFKD)
// После нормализации убираем combining marks, чтобы не получать лишние артефакты.
.replaceAll("\\p{M}+", "")
// Locale.ROOT фиксирует поведение toLowerCase на любых машинах/в CI.
.toLowerCase(Locale.ROOT)
// trim() убирает ведущие/замыкающие пробелы, чтобы не получить дефисы по краям.
.trim();
return s.replaceAll("[^\\p{L}\\p{N}]+", "-") // всё, что не Unicode-буква/цифра -> дефис
.replaceAll("(^-+)|(-+$)", ""); // убрать дефисы с краёв
}
}
Если вам хочется спросить «а почему не \\w?» — хороший вопрос. \\w включает подчёркивание и ведёт себя не так прозрачно, как нам нужно. Нам важен простой и предсказуемый контракт: всё, что не Unicode-буква и не цифра, превращаем в дефис.
Нюанс с локалью и Locale.ROOT
Это тот случай, когда одна строчка кода экономит вам несколько часов «охоты на призраков».
import java.util.Locale;
// В Locale.ROOT поведение «ожидаемое» для большинства кейсов.
System.out.println("I".toLowerCase(Locale.ROOT)); // i
// В некоторых локалях (например, турецкой) результат будет другим.
System.out.println("I".toLowerCase(new Locale("tr"))); // ı
В некоторых локалях буквы меняются не так, как вы ожидаете, и slug внезапно становится «другим». В тестах мы хотим, чтобы slug был одинаковым на любой машине. Поэтому Locale.ROOT — это про детерминированность, а не про академизм.
3. Unit-тесты SlugService
Теперь самое приятное: превращаем «договорённость о slug» в небольшой и понятный набор тестов. Здесь важно не писать 50 тестов «на каждый символ ASCII», а выбрать сценарии, которые действительно защищают контракт: пробелы, пунктуацию, регистр, крайние случаи вроде пустой строки. Такие тесты запускаются быстро и дают очень конкретный сигнал о том, что именно изменилось.
Начнём с базового сценария: пробелы схлопываются, регистр становится нижним, лишнее убирается. Обратите внимание: здесь нет ни Spring, ни «магических аннотаций» — только JUnit и AssertJ.
package com.contenthub.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceTest {
// В unit-тесте используем реальную реализацию: она чистая и быстрая.
private final SlugService slugService = new DefaultSlugService();
@Test
void convertsTitleToReadableSlug() {
// Arrange + Act: генерируем slug из заголовка с лишними пробелами.
String slug = slugService.toSlug(" Spring Boot Testing Basics ");
// Assert: фиксируем контракт (что именно считаем «правильным» slug).
assertThat(slug).isEqualTo("spring-boot-testing-basics");
}
}
Теперь добавим сценарий с пунктуацией. Это тоже частый источник сюрпризов: двоеточия, запятые, @Nested, «и всё это в заголовке». Мы хотим, чтобы любые такие штуки превращались в дефисы и не оставляли «двойных дефисов» по краям.
package com.contenthub.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServicePunctuationTest {
private final SlugService slugService = new DefaultSlugService();
@Test
void replacesPunctuationWithHyphens() {
// Символы вроде ":" и "@Nested" не должны «просачиваться» в slug.
String slug = slugService.toSlug("JUnit 6: @Nested");
// Проверяем, что всё лишнее превращается в дефисы, а края чистые.
assertThat(slug).isEqualTo("junit-6-nested");
}
}
И наконец — негативный сценарий. Сразу договоримся: SlugService не обязан «угадывать», что делать с пустым заголовком. Это не генератор случайных идентификаторов и не «магический спасатель данных». Если заголовок пустой, это ошибка входных данных, и в unit-тестах такое поведение можно зафиксировать явно.
package com.contenthub.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SlugServiceInvalidInputTest {
private final SlugService slugService = new DefaultSlugService();
@Test
void rejectsBlankTitle() {
// Проверяем, что «плохие данные» не конвертируются в «случайный результат».
assertThatThrownBy(() -> slugService.toSlug(" "))
.isInstanceOf(IllegalArgumentException.class);
}
}
Можно заметить, что мы не проверяли уникальность slug. И это правильно: уникальность зависит от того, что уже лежит в хранилище, а значит выходит за пределы чистой функции. В unit-слое мы «закрываем» только то, что можем доказать без инфраструктуры: трансформацию строки в строку.
4. AttachmentValidationService: проверка метаданных
Вложения (attachments) почти всегда приносят в проект сразу два подарка. Первый — функциональный: «пользователю удобно». Второй — технический: «а теперь давайте обсудим типы, размеры, лимиты и безопасность». Но если вы попытаетесь тестировать вложения через реальную файловую систему уже на уровне unit-тестов, вы быстро превратите быстрый слой в медленный и капризный. Поэтому мы делаем проще: валидируем метаданные вложения как обычные числа и строки.
В ContentHub у вложения есть имя файла, contentType и размер. Также у нас есть ограничения: какие типы разрешены, максимальный размер, максимальное количество вложений на статью. И вот это как раз отличная зона для AttachmentValidationService: он получает метаданные и число «сколько уже вложений есть» и отвечает, проходит ли вложение по правилам.
Удобно представить это как короткий «контроль на входе», где нам не нужна файловая система, не нужен MultipartFile и вообще ничего web-специфичного. Нам достаточно данных, которые можно выразить в простом record.
Вот компактная таблица правил, с которыми мы сегодня работаем (условные числа, чтобы примеры были понятнее):
| Правило | Пример настройки | Что это значит |
|---|---|---|
| Разрешённые типы | image/png, |
Всё остальное отклоняем |
| Максимальный размер | 5_000_000 байт | Больше — нельзя |
| Максимум вложений на статью | 3 | Если уже 3 — новое нельзя |
И если хочется визуального «алгоритма», он примерно такой:
flowchart TD
A["Пришли метаданные вложения"] --> B{"Текущих вложений < max?"}
B -- нет --> X["Запрещено: лимит количества"]
B -- да --> C{"contentType разрешён?"}
C -- нет --> Y["Запрещено: тип"]
C -- да --> D{"size <= maxSize?"}
D -- нет --> Z["Запрещено: размер"]
D -- да --> OK["Разрешено"]
Это как раз тот случай, когда unit-тесты подходят идеально: никакой магии, только правила.
5. Реализация AttachmentValidationService
Сейчас мы соберём минимальный набор классов, который позволит тестировать правила вложений без Spring и без I/O. Здесь полезно держать код «честным»: если сервис зависит от настроек (лимитов), мы передаём эти лимиты через конструктор. Никаких чтений из файлов, системных переменных и «давай-ка я сам где-то достану конфиг».
Начнём с метаданных. В Java 25 идеально подходит record: он короткий, immutable, и в тестах с ним приятно работать, потому что это просто контейнер значений.
package com.contenthub.service;
// Метаданные вложения: то, что мы можем валидировать без файловой системы.
public record AttachmentMeta(String filename, String contentType, long sizeBytes) {
}
Теперь сам валидатор. Мы сознательно сделаем его «данные + правило». То есть он хранит ограничения и умеет применить их к конкретному мета-объекту. Заметьте: мы копируем Set через Set.copyOf(...), чтобы кто-нибудь случайно не подменил правила извне после создания сервиса. Такое тоже бывает, и оно тоже ломает детерминизм.
package com.contenthub.service;
import java.util.Set;
public class AttachmentValidationService {
// Разрешённые contentType (например, "image/png", "application/pdf").
private final Set<String> allowedTypes;
// Максимальный размер файла в байтах.
private final long maxSizeBytes;
// Максимальное количество вложений на статью.
private final int maxAttachments;
public AttachmentValidationService(Set<String> allowedTypes, long maxSizeBytes, int maxAttachments) {
// Копируем набор, чтобы правила нельзя было «подменить» извне после создания сервиса.
this.allowedTypes = Set.copyOf(allowedTypes);
this.maxSizeBytes = maxSizeBytes;
this.maxAttachments = maxAttachments;
}
}
А вот «сердце» правила — метод проверки. Мы сделаем его максимально прозрачным: просто три условия. Это не тот случай, где хочется «умный DSL» или 12 уровней абстракций. Чем проще правило, тем проще тест и тем меньше шанс, что мы случайно протестируем не то.
package com.contenthub.service;
public class AttachmentValidationService {
// поля и конструктор опущены ради краткости
public boolean isAllowed(AttachmentMeta meta, int currentCount) {
// currentCount — сколько вложений уже есть у статьи сейчас (до добавления нового).
return currentCount < maxAttachments
// Тип должен быть в белом списке.
&& allowedTypes.contains(meta.contentType())
// И размер не должен превышать лимит.
&& meta.sizeBytes() <= maxSizeBytes;
}
}
Да, такой метод возвращает только true/false. В реальном приложении вам часто захочется возвращать причину отказа или бросать domain-исключение, чтобы API мог ответить понятной ошибкой. Но как минимум для unit-уровня мысль должна быть понятна: мы проверяем правило без инфраструктуры.
6. Unit-тесты AttachmentValidationService
Теперь закрепим «матрицу правил» тестами. Здесь особенно важно помнить про граничные значения: именно они чаще всего ломаются. Если лимит размера 5_000_000 байт, то самые интересные случаи — это ровно 5_000_000 и 5_000_001. Если лимит количества 3, то интересные — это currentCount = 2 (можно добавить третье) и currentCount = 3 (уже нельзя). Тесты должны быть короткими и «в одну мысль».
Сначала базовый позитивный сценарий: разрешённый тип, размер в лимите, и по количеству ещё есть место.
package com.contenthub.service;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class AttachmentValidationServiceTest {
@Test
void allowsValidAttachment() {
// Arrange: задаём «конфиг» лимитов прямо в тесте (быстро и детерминированно).
AttachmentValidationService service =
new AttachmentValidationService(Set.of("image/png", "application/pdf"), 5_000_000L, 3);
// Метаданные валидного вложения.
AttachmentMeta meta = new AttachmentMeta("guide.pdf", "application/pdf", 1_200_000L);
// Assert: при currentCount=1 место ещё есть, тип и размер проходят.
assertThat(service.isAllowed(meta, 1)).isTrue();
}
}
Теперь проверка типа. Хорошая практика: не тестировать один неправильный тип, а сразу зафиксировать несколько наиболее вероятных «промахов». Для этого удобно использовать параметризованный тест, который вы уже видели раньше в курсе. И вот здесь он действительно к месту, потому что правило одно и то же, а значения разные.
package com.contenthub.service;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class AttachmentValidationContentTypeTest {
@ParameterizedTest
@ValueSource(strings = {"video/mp4", "text/plain", "application/octet-stream"})
void rejectsUnknownContentTypes(String contentType) {
// Разрешаем только PDF и PNG.
AttachmentValidationService service =
new AttachmentValidationService(Set.of("image/png", "application/pdf"), 5_000_000L, 3);
// В остальном метаданные «валидные», чтобы изолировать именно проверку типа.
AttachmentMeta meta = new AttachmentMeta("file.bin", contentType, 100L);
// Assert: неизвестный тип должен быть отклонён.
assertThat(service.isAllowed(meta, 0)).isFalse();
}
}
Следующий кусок — размер и граница. Здесь два теста выглядят почти одинаково, и это нормально: они фиксируют два разных края правила. Главное, чтобы числа были «говорящими», а не 1234567, после которых уже не понимаешь, что именно проверял.
package com.contenthub.service;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class AttachmentValidationSizeTest {
@Test
void allowsSizeExactlyOnLimit() {
AttachmentValidationService service =
new AttachmentValidationService(Set.of("application/pdf"), 5_000_000L, 3);
// Граница включительная: ровно лимит — можно.
assertThat(service.isAllowed(new AttachmentMeta("a.pdf", "application/pdf", 5_000_000L), 0)).isTrue();
}
@Test
void rejectsSizeAboveLimit() {
AttachmentValidationService service =
new AttachmentValidationService(Set.of("application/pdf"), 5_000_000L, 3);
// На 1 байт больше лимита — уже нельзя.
assertThat(service.isAllowed(new AttachmentMeta("a.pdf", "application/pdf", 5_000_001L), 0)).isFalse();
}
}
И наконец — правило количества. Тоже граница: 2 ещё можно, 3 уже нельзя, если максимум — 3. Здесь важно не перепутать смысл числа currentCount: это «сколько уже есть», а не «сколько будет после добавления».
package com.contenthub.service;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class AttachmentValidationCountTest {
@Test
void rejectsWhenMaxAlreadyReached() {
AttachmentValidationService service =
new AttachmentValidationService(Set.of("image/png"), 5_000_000L, 3);
AttachmentMeta meta = new AttachmentMeta("img.png", "image/png", 100L);
// Если уже 3 вложения, то добавить ещё одно нельзя.
assertThat(service.isAllowed(meta, 3)).isFalse();
}
}
Заметьте, чего мы тут не делали: не создавали файл, не писали во временную директорию, не читали bytes и не делали Thread.sleep() (спасибо, мы ещё не настолько устали). В этом и суть unit-слоя: правила проверяются как правила, а не в формате «поднимем всё подряд и посмотрим».
7. Типичные ошибки при работе с сервисами-правилами
Ошибка №1: смешивать генерацию slug с проверкой уникальности.
Очень соблазнительно сделать метод generateUniqueSlug(title) и внутри спросить базу данных: «а такой уже есть?» Но тогда SlugService перестаёт быть чистой функцией и превращается в сервис с инфраструктурой. Unit-тесты либо становятся медленными, если вы реально идёте в БД, либо бессмысленно замоканными, если ради строковой логики начинаете мокать репозиторий.
Ошибка №2: делать slug зависимым от системной локали.
title.toLowerCase() без указания локали может давать разные результаты на разных машинах. Это тот баг, который проявится в самый «удачный» момент: например, когда CI-агент работает в другой локали. Решение простое и почти бесплатное: используйте toLowerCase(Locale.ROOT) и в проде, и в тестах.
Ошибка №3: пытаться «спасти» пустой заголовок случайным slug.
Если заголовок пустой, некоторые разработчики генерируют slug вроде article-random. С точки зрения unit-теста это катастрофа: результат зависит от случайности. С точки зрения домена тоже сомнительно: у статьи обязателен заголовок, значит входные данные некорректны. Лучше выбросить понятное исключение и исправить место, где до SlugService дошли плохие данные.
Ошибка №4: тестировать AttachmentValidationService через реальный файл.
Если вы в unit-тесте создаёте временный файл, пишете туда байты и потом измеряете размер, вы проверяете не доменное правило, а поведение файловой системы и своей ОС. Это и медленнее, и хрупче. Правило вложений прекрасно проверяется по метаданным: имя, contentType, sizeBytes — это обычные значения.
Ошибка №5: перепутать смысл счётчика вложений.
Очень распространённая логическая ошибка: считать, что currentCount — это «включая новое вложение». Тогда тесты будут выглядеть «почти правильно», но правило сработает на один шаг мимо. Договоритесь об интерфейсе: если currentCount — это «сколько уже есть», то условие для добавления нового — currentCount < maxAttachments.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ