JavaRush /Курсы /Spring Test /Первое знакомство со Spring Test 💥

Первое знакомство со Spring Test 💥

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

1. Зелёный startup — это только smoke-сигнал 🌋

В мире Spring Boot фраза «приложение стартует» обычно означает следующее: JVM запустила main, Spring собрал ApplicationContext, создал бины, прочитал конфигурацию и, возможно, поднял embedded server. Это полезная проверка. Такой старт действительно ловит реальные проблемы: конфликтующие бины, битую конфигурацию, несовместимые зависимости, иногда — нерабочий datasource или ошибку в @Configuration. То есть сам запуск не бесполезен. Он просто отвечает на очень узкий вопрос: «система вообще может включиться?»

@SpringBootApplication // Точка входа Spring Boot: включает автоконфигурацию и сканирование компонентов
class ContentHubApplication {

    public static void main(String[] args) {
        // Запуск приложения: поднимается ApplicationContext и стартует жизненный цикл Spring
        SpringApplication.run(ContentHubApplication.class, args);
    }
}

Если этот код выполнился и приложение не упало на старте, это хороший сигнал. Но хороший сигнал — не то же самое, что доказательство корректного поведения. Старт не знает, какой status code должен вернуть public endpoint, если статья не найдена. Старт не знает, можно ли публиковать DRAFT в обход ревью. Старт не знает, должен ли анонимный пользователь видеть только PUBLISHED, а не всё подряд. И уж точно старт не проверяет, что внешний moderation-сервис ответит так, как вы от него ждёте.

Это важное различие, потому что у начинающего backend-разработчика очень легко возникает ложная связка: «раз сервер поднялся, значит всё в порядке». На самом деле в этот момент сервер проверил только собственную способность включиться. Клиент разговаривает не с вашим ApplicationContext, а с API, бизнес-правилами и данными.

2. Один реальный сценарий

Возьмём один реальный сценарий, который будет идти через весь уровень. Редактор создаёт черновик статьи, отправляет его на ревью, администратор подтверждает публикацию, а анонимный пользователь читает статью по slug. На человеческом языке это одна история. Для backend-а — сразу несколько границ: HTTP, бизнес-правила, данные, доступ, внешние вызовы и иногда асинхронные побочные эффекты.

Пока вы смотрите на этот flow глазами «ну оно же запускается», всё кажется простым. Но как только вы спрашиваете «что именно здесь может сломаться?», история быстро перестаёт быть скучной. Публичная выдача может показать неопубликованную статью. Редактор может отправить на ревью не свою статью. Админ может случайно получить возможность публиковать запись из неправильного статуса. Валидация может пропустить мусорный запрос. Модерация может вернуть неожиданный ответ. И каждую из этих поломок startup пропустит мимо себя.

Ручная проверка happy path тут помогает не намного больше. Вы дёрнули один-два endpoint’а, увидели 200 OK и успокоились. Но happy path почти всегда проверяет ровно то, что вы и так ожидали увидеть. Он плохо ловит запреты, неверные негативные ветки, ошибки выборок и несовместимые контракты. То есть ручной smoke полезен как быстрый осмотр, но не как стратегия качества.

3. HTTP и JSON ломаются тише, чем кажется

Первая большая зона риска — API-граница. Клиент разговаривает не с вашим ApplicationContext, а с URL, статусами, заголовками и JSON-полями. Поэтому многие болезненные дефекты живут именно здесь: не тот путь, не тот status code, не тот формат ошибки, лишнее поле, пропавшее поле, забытая валидация. И всё это вполне может существовать в приложении, которое прекрасно стартует.

@RestController // HTTP-слой: сюда приходят запросы клиентов
class PublicArticleController {

    private final ArticleRepository repository; // Зависимость на слой данных (репозиторий)

    @GetMapping("/api/public/articles/{slug}") // Маршрут: публичный endpoint по slug
    ArticleResponse getBySlug(@PathVariable String slug) { // slug берётся из пути запроса
        // Ищем статью в базе по slug; здесь пока нет фильтра по статусу (PUBLISHED и т.д.)
        Article article = repository.findBySlug(slug).orElseThrow(); // orElseThrow без маппинга часто превращается в 500
        // Формируем DTO ответа: то, что увидит клиент (контракт API)
        return new ArticleResponse(article.getTitle(), article.getBody());
    }
}

На вид это нормальный контроллер. Но у него сразу несколько потенциальных проблем. Во-первых, findBySlug вообще не гарантирует, что статья опубликована. Значит, public endpoint может вернуть черновик. Во-вторых, orElseThrow() без осмысленного маппинга часто заканчивается 500, хотя клиенту нужен 404 Not Found. В-третьих, сам ArticleResponse может тихо измениться: вы переименуете поле, поменяете формат даты или забудете обязательную часть payload, а приложение всё равно будет счастливо стартовать 🚨

Сюда же относится и банально забытая валидация. Вы убрали @Valid или ослабили constraint на request DTO, и API начал принимать то, что не должен. Контекст от этого не развалится. Чуть позже развалятся доменная логика, база данных или чей-то клиент. Именно поэтому «endpoint ответил» и «endpoint ответил правильно» — совершенно не одно и то же.

4. Бизнес-правила никогда не падают красиво

Бизнес-логика ломается особенно коварно: код компилируется, сервер работает, исключений нет, а продукт уже ведёт себя неправильно. Для ContentHub это особенно заметно на workflow статусов статьи. Публикация должна происходить из понятного состояния, обычно после ревью. Если правило перехода размазано по коду или просто забыто, backend начинает разрешать запрещённое.

class ArticleWorkflowService { // Сервис доменной логики: управляет переходами статусов

    void approve(Article article) {
        // Здесь происходит ключевое бизнес-действие: "одобрить" статью
        // Важно: в этом виде нет проверки текущего статуса (например, что он IN_REVIEW)
        article.setStatus(ArticleStatus.PUBLISHED); // Прямой перевод в PUBLISHED — потенциальная поломка workflow
    }
}

Технически здесь всё «работает». Но доменная цена у такой строчки огромная. Она позволяет перевести статью в PUBLISHED без проверки, была ли она вообще в IN_REVIEW. А это уже не мелкая неточность. Это поломка бизнес-процесса, из-за которой публичная выдача, уведомления и аудиторские следы начинают жить в неправильной реальности.

И вот здесь важный взрослый момент. Spring умеет очень многое, но не знает смысла вашего workflow. Он не будет защищать правило «publish only after review» только потому, что вы красиво написали сервис. Смысл домена всегда остаётся вашей ответственностью, а значит — и вашей зоной тестирования.

5. Данные умеют врать

У начинающего backend-разработчика слой данных часто выглядит почти нейтрально: есть репозиторий, есть save, есть find — что тут может пойти не так? На практике именно слой данных даёт много тихих регрессий. Неправильная выборка, забытый фильтр, не тот порядок сортировки, дырявый constraint — и пользователь видит совсем не то, что вы обещали ему на уровне API.

interface ArticleRepository { // Контракт репозитория: описывает доступ к данным (например, через Spring Data)
    // Метод выборки по категории; важно: в названии нет фильтра по статусу публикации
    List<Article> findAllByCategoryCode(String code);
}

Если таким методом пользоваться для публичного каталога, очень легко забыть фильтр по PUBLISHED. С точки зрения Spring Data всё хорошо: метод валидный, приложение стартует, запрос исполняется. С точки зрения продукта — катастрофа, потому что public-раздел внезапно начинает показывать DRAFT, IN_REVIEW или REJECTED статьи.

То же самое касается ограничений данных. slug должен быть уникальным, некоторые поля — не null, некоторые связи — обязательными. Если в этой части модели есть дыры, баг не обязательно проявится в момент старта. Он всплывёт, когда приложение встретит конкретные данные. А это уже самый неприятный тип дефекта: он появляется не там, где вы его ждёте.

6. Доступ нельзя откладывать «на потом» 🔒

Есть ещё одна зона, которую особенно любят недооценивать: доступ. Кажется, что сначала можно «сделать функционал», а безопасность и ограничения прикрутить потом. Почти всегда это заканчивается тем, что у вас уже есть работающий API, но работает он не для тех людей и не на тех условиях, для которых должен работать.

boolean canEdit(Article article, String username) {
    // Проверка "владелец ли пользователь": редактировать может только автор статьи
    return article.getAuthorUsername().equals(username)
            // Проверка по workflow: редактирование разрешено только в статусе DRAFT
            && article.getStatus() == ArticleStatus.DRAFT;
}

Даже в таком коротком правиле живут два разных риска. Первый — owner-based access: редактировать может только автор. Второй — workflow restriction: редактирование разрешено только в определённом статусе. Если потерять хотя бы одну половину условия, вы получаете либо security-дыру, либо поломку доменной логики.

Ролевая модель даёт тот же эффект. Анонимный пользователь не должен видеть editor/admin-действия, editor не должен менять чужую статью, admin не должен случайно получать обход доменных ограничений просто потому, что он admin. Всё это не проверяется фактом старта. Это проверяется только поведением. И очень полезно уже в первый день увидеть, что «кто может» и «при каком статусе можно» — это не одна, а две разные проверки 🔐

7. Внешние границы живут по своим правилам

Проект ContentHub не заканчивается на контроллерах и базе. У него есть внешняя модерация, файловое хранилище, уведомления о публикации и другие технические границы. Они особенно коварны, потому что могут быть полностью сломаны и всё равно не мешать приложению стартовать. Пока вы не вызвали конкретный адаптер, Spring честно считает, что всё в порядке.

interface ModerationClient { // Клиент внешнего сервиса: граница, у которой свой контракт и свои сбои
    // Проверка контента на блокировку; на практике здесь могут быть таймауты/ошибки/неожиданные ответы
    boolean isBlocked(String title, String body);
}

Проблема здесь уже не в одной строчке кода, а в поведении границы. Внешний сервис может таймаутить, менять контракт, возвращать неожиданный статус или просто быть недоступным. Файловое хранилище может сломаться на правах доступа или неверном пути. Уведомление может не уйти, хотя основное действие вроде бы «успешно завершилось».

Это важное отрезвление для backend-разработчика. Наличие интерфейса или красивого адаптера не делает границу надёжной. Оно всего лишь делает её удобнее для проектирования и тестирования. Надёжность появляется только там, где вы реально проверяете поведение этой границы в нужной глубине.

8. Как проверять по-взрослому

Когда вы складываете все эти зоны вместе, видно простое правило: тестирование нужно не затем, чтобы «украсить проект тестами», а затем, чтобы сделать разные классы поломок наблюдаемыми. В этом смысле startup-check — один инструмент. Ручной happy path — второй. Но полноценная стратегия тестирования начинается только там, где вы перестаёте смешивать их в одну большую иллюзию уверенности.

Что вы проверили Что это реально доказывает Что остаётся в тени
Приложение стартует Контекст собрался, базовый wiring работает Контракты API, бизнес-правила, доступ, корректность данных, интеграции
Руками дёрнули 1–2 happy path-сценария Несколько выбранных веток сейчас отвечают Негативные случаи, регрессии, редкие сочетания данных, воспроизводимость
Есть продуманная стратегия тестов Разные риски становятся видимыми на своей глубине Только то, что вы сознательно решили пока не покрывать

Здесь уже видна взрослая граница курса. Мы не будем делать вид, будто один большой smoke-тест заменяет всё. И не будем впадать в другую крайность — писать проверки ради красивой статистики. Нам нужна система, которая даёт понятную, повторяемую и экономную обратную связь.

Как только это становится ясным, следующий вопрос возникает сам собой. Если поломки живут в разных местах, backend нужно сначала разложить по зонам риска. Иначе выбор любого теста будет случайным. Именно к этой карте мы сейчас и перейдём 🗺️

9. Типичные ошибки при первом взгляде на качество 🚧

Ошибка №1: считать, что зелёный startup равен качеству приложения.
Startup полезен, но он проверяет только факт сборки системы в базовом состоянии. Он не доказывает корректность пользовательского поведения, не защищает от утечек данных и не знает ничего о ваших бизнес-правилах.

Ошибка №2: путать «endpoint ответил» и «endpoint ответил правильно».
200 OK сам по себе ничего не гарантирует. Нужны правильные данные, правильный статус, корректный контракт ошибки и правильные ограничения доступа. Иначе у вас просто красивый неправильный ответ.

Ошибка №3: доверять ручному happy path как основной защите от регрессий.
Ручная проверка хороша как быстрый осмотр, но она не масштабируется и не воспроизводится с машинной точностью. То, что вы не догадались проверить сегодня, спокойно вернётся багом завтра.

Ошибка №4: откладывать проблемы безопасности и данных «на потом».
Обычно именно такое «потом» и становится самым дорогим. Данные и доступ — не дополнительные украшения к API, а часть его корректности.

Ошибка №5: видеть backend как один чёрный ящик.
Как только всё сводится к «работает / не работает», вы теряете инженерную точность. Полезное тестирование начинается в тот момент, когда вы умеете назвать место риска, а не просто констатировать, что что-то где-то сломалось.

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