JavaRush /Курсы /Spring Test /SlugService и

SlugService и AttachmentValidationService

Spring Test
5 уровень , 3 лекция
Открыта

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,
application/pdf
Всё остальное отклоняем
Максимальный размер 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.

1
Задача
Spring Test, 5 уровень, 3 лекция
Недоступна
Локальная генерация slug без внешних зависимостей
Локальная генерация slug без внешних зависимостей
1
Задача
Spring Test, 5 уровень, 3 лекция
Недоступна
Проверка метаданных вложения по трём простым правилам
Проверка метаданных вложения по трём простым правилам
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ