JavaRush /Курсы /Spring Test /Граница внешнего вызова

Граница внешнего вызова

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

1. Граница интеграций и бизнес-логики

Когда в приложении появляется внешний вызов, у начинающего разработчика обычно два режима. Первый — “давайте просто дернем HTTP прямо из сервиса, что может пойти не так”. Второй — “давайте замокаем всё вообще, и тесты будут зелёные всегда”. Оба режима приводят к тестам, которые либо хрупкие и медленные, либо зелёные, но ничего не доказывают.

Право на действие само по себе тоже не спасает. Статья может быть доступна нужной роли, endpoint может быть защищён как надо, но дальше действие всё равно упирается во внешний сервис или рождает побочный эффект, который случится не сразу. Сегодня как раз про эти два неприятных края.

В ContentHub внешний мир проявляется регулярно. Когда редактор отправляет статью на ревью, мы зовём модерацию. Когда админ публикует статью, мы отправляем уведомление. Когда редактор загружает вложение, мы идём в файловую систему. Во всех этих случаях у нас есть один общий страх: тесты начнут случайно “падать от погоды”, потому что сеть, файловая система и асинхронщина умеют устраивать сюрпризы даже в пятницу вечером.

Ключевая идея этой лекции проста: внешняя интеграция должна иметь границу, и эта граница должна быть устроена так, чтобы мы могли тестировать:

1) бизнес‑решения (что делать при OK/WARN/BLOCK),
2) технический перевод (какой HTTP-запрос отправили и как распарсили ответ),
3) поведение при отказах (как деградируем безопасно).

И всё это — не в одном “монструозном” тесте, а на правильных уровнях.

2. Порт и адаптер: разные ответственности

Слова “порт” и “адаптер” звучат немного как детали от шуруповёрта, но в тестировании они дают удивительно практичную пользу. Представьте: бизнес‑код хочет “получить решение модерации”, а технический код знает, как именно это сделать через HTTP. Если смешать эти части, тест получится либо слишком дорогим (надо поднимать половину мира), либо слишком неинформативным (моки на всё, кроме веры в чудо).

Порт (port) — это контракт, который описывает, что нужно приложению. Он живёт на стороне приложения и выражается его терминами: ModerationRequest, ModerationDecision, “модерация недоступна”.

Адаптер (adapter) — это конкретная реализация порта, которая знает, как сходить наружу: какой URL, какой HTTP-метод, какие заголовки, какой JSON и что делать с ошибками транспорта.

Обычно удобно держать эту картину в голове так:

flowchart LR
    S["ArticleWorkflowService
бизнес-решение"] --> P["ModerationClient
порт"] P --> A["RestClientModerationClient
HTTP-адаптер"] A --> X["Moderation Service
внешний мир"]

Тесты здесь тоже “раскладываются” по смыслу. Если мы тестируем сервис, мы проверяем бизнес‑реакцию и используем тестовый двойник порта. Если мы тестируем адаптер, мы проверяем корректность HTTP‑контракта и маппинга — и подменяем внешний мир на управляемую заглушку.

3. Внешние границы в ContentHub

Слово “интеграция” многие автоматически воспринимают как “HTTP”, но в реальном backend внешним миром становится всё, что не принадлежит вашей доменной логике. Даже если оно на том же сервере и “почти рядом”. Поэтому в ContentHub мы считаем внешними границами как минимум три вещи: модерацию, файловое хранилище и отправку уведомлений.

Модерация представлена портом ModerationClient. Бизнес‑слой хочет получить решение OK/WARN/BLOCK и (в случае BLOCK) причину. Бизнес‑слой не обязан знать, что где-то есть POST /api/check и JSON вида {"result":"BLOCK"}. Это забота адаптера.

Файловое хранилище представлено портом FileStorageService. Для бизнес‑логики важно “сохранить вложение и получить ссылку/идентификатор”. Но как именно мы пишем в файловую систему, где хранится root, какие исключения возникают при нехватке прав — это технические детали. Если сервис начнёт напрямую работать с Files.copy(...), тесты очень быстро превратятся в квест “угадай, где на CI нет прав на директорию”.

Уведомления о публикации представлены портом PublicationNotificationSender. Для домена важно “после публикации отправить уведомление”. Для домена неважно, будет ли это отдельный поток, очередь, executor или просто лог. И особенно неважно, как именно оно планируется в асинхронность. Доменные тесты должны проверять факт “уведомление запрошено”, а не низкоуровневую механику того, как именно оно улетело.

Чтобы не было ощущения “всё это теория”, давайте зафиксируем порт для модерации в коде.

// Порт: бизнес-код зависит от этого интерфейса, а не от HTTP-реализации.
public interface ModerationClient {

    // Возвращаем доменное решение модерации; детали транспорта скрыты за границей.
    ModerationDecision moderate(ModerationRequest request);
}

Здесь порт намеренно простой: бизнес‑слою не нужно знать ни HTTP‑статусы, ни RestClient, ни ObjectMapper. Он хочет решение в терминах приложения.

4. Что проверяем через порт

Очень важный момент, который экономит много времени и нервов: порт сам по себе обычно не является объектом тестирования. Это интерфейс. Он должен быть маленьким и понятным, и, честно говоря, тестировать его нечего.

Объектом тестирования становится код, который принимает решение, опираясь на порт. В ContentHub таким кодом является (например) ArticleWorkflowService, который решает: переводим статью в IN_REVIEW, отклоняем в REJECTED, или остаёмся в DRAFT, если модерация недоступна.

Чтобы сервис мог нормально тестироваться, ему нужна зависимость на интерфейс (порт), а не на технический клиент. Это выглядит максимально “скучно” — а значит, правильно:

public class ArticleWorkflowService {

    // Важно: зависим не от RestClient/HTTP, а от порта (контракта).
    private final ModerationClient moderationClient;

    public ArticleWorkflowService(ModerationClient moderationClient) {
        // Через конструктор проще подменять порт в unit-тестах.
        this.moderationClient = moderationClient;
    }
}

Дальше сервис делает бизнес‑шаги. Например, “submit for review” может выглядеть упрощённо так:

public ArticleStatus submitForReview(String title, String body) {
    // Сервис формирует доменный запрос (не HTTP-запрос).
    ModerationDecision decision = moderationClient.moderate(new ModerationRequest(title, body));

    // Сервис принимает бизнес-решение на основе результата.
    return decision.result() == ModerationResult.BLOCK ? ArticleStatus.REJECTED : ArticleStatus.IN_REVIEW;
}

Да, это упрощение (в реальном коде будет Article, сохранение причины, timestamps и т.д.), но важна мысль: сервис принимает решение, а порт только “дает входные данные” для решения.

И вот это отлично тестируется обычным unit‑тестом: мы подменяем порт и проверяем, что сервис выбрал правильный переход состояния. Здесь нам не нужна сеть, не нужна база и не нужен Spring. Нужен только один честный сценарий.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;

class ArticleWorkflowServiceTest {

    // Мокаем именно порт: это и есть граница внешнего мира для сервиса.
    private final ModerationClient moderationClient = mock(ModerationClient.class);

    // Тестируем сервис как чистую бизнес-логику.
    private final ArticleWorkflowService service = new ArticleWorkflowService(moderationClient);

    @Test
    void submitForReview_shouldReject_whenDecisionIsBlock() {
        // Настраиваем сценарий: внешний мир "сказал" BLOCK.
        given(moderationClient.moderate(new ModerationRequest("T", "B")))
                .willReturn(new ModerationDecision(ModerationResult.BLOCK, "spam"));

        // Выполняем действие бизнеса.
        ArticleStatus status = service.submitForReview("T", "B");

        // Проверяем только бизнес-результат (без URL/HTTP/JSON).
        assertThat(status).isEqualTo(ArticleStatus.REJECTED);
    }
}

Обратите внимание, что в таком тесте мы не обсуждаем ни URL, ни HTTP‑метод. Это осознанно. Если завтра внешний сервис переедет с /api/check на /api/v2/check, бизнес‑правило “BLOCK означает REJECTED” не меняется. Поэтому бизнес‑тест не должен ломаться.

И ещё один нюанс: если вы в сервисном тесте начинаете проверять “а какой именно JSON был отправлен наружу”, вы неявно превращаете сервисный тест в тест адаптера. Сервисный тест должен проверять смысл решения, а не протокол общения.

5. Что проверяем в адаптере

Технический адаптер — это место, где приложение “снимает костюм доменной логики” и надевает “костюм протокола”. И именно здесь живут ошибки вида “забыли заголовок”, “не тот URI”, “не так назвали поле в JSON”, “не тот media type”, “перепутали enum”, “не перевели 500 в понятное исключение”.

Важно заранее принять один факт: адаптер — это не бизнес-логика. Он не должен решать, что делать при BLOCK. Он должен суметь получить от внешнего мира факт BLOCK и аккуратно вернуть его в доменную форму.

Здесь нам важна только роль адаптера как переводчика между доменом и HTTP. Поэтому код ниже схематичен: детали baseUrl, внешнего DTO и тестового перехвата запросов пока опускаем, чтобы не смешивать границу с техникой её проверки.

Скелет адаптера для модерации в ContentHub может выглядеть так:

import org.springframework.web.client.RestClient;

public final class RestClientModerationClient implements ModerationClient {

    // Техническая зависимость: Spring RestClient, HTTP, сериализация и т.д.
    private final RestClient restClient;

    public RestClientModerationClient(RestClient restClient) {
        // Важно: адаптеру разрешено знать про RestClient, но сервису — нет.
        this.restClient = restClient;
    }
}

А логика вызова — это как раз “технический переводчик”:

public ModerationDecision moderate(ModerationRequest request) {
    // Здесь фиксируем контракт: метод, URI, payload и ожидаемый тип ответа.
    return restClient.post()
            .uri("/api/check") // Важно тестировать: корректный путь и версия API.
            .body(request)     // Важно тестировать: корректный JSON-пейлоад из доменного DTO.
            .retrieve()        // Важно тестировать: как обрабатываем статусы и ошибки.
            .body(ModerationDecision.class); // Схематично: полный DTO-layer и маппинг здесь сознательно опущены.
}

Здесь уже появляются вещи, которые бизнес‑слою неинтересны, но тесту адаптера — очень даже. Например, корректный путь "/api/check", HTTP‑метод POST, корректный Content-Type, корректный JSON‑пейлоад, корректный маппинг ответа. И отдельно — перевод ошибок: если внешний сервис вернул 500 или вернул мусор вместо JSON, адаптер должен не просто “упасть как упал”, а перевести техническую проблему в понятную форму для приложения.

Если transport ломается, адаптер тоже должен перевести техническую аварию в понятное приложению исключение. Бизнес-коду не нужна коллекция из HttpServerErrorException, SocketTimeoutException и друзей; ему нужен понятный сигнал, что модерация недоступна или ответ сломан.

И вот тут важное разграничение: в адаптере мы тестируем перевод, а в сервисе — реакцию на переведённый результат. Если адаптер при серверной ошибке выбрасывает ModerationUnavailableException, то сервисный тест может проверять “при ModerationUnavailableException остаёмся в DRAFT”, и ему не нужно знать, была ли это 500, timeout или упавший DNS.

6. Таблица: изменение → тест

Когда проект растёт, очень хочется “подстраховаться” и проверять всё везде. Итог предсказуем: тестов становится много, они медленные, и падение любого из них не объясняет, что именно сломалось. Гораздо выгоднее держать маленькую ментальную табличку: что поменяли — где проверяем.

Ниже не догма, а практичный ориентир именно для ContentHub и именно для нашего курса:

Что изменилось в коде Где это проверять Почему это там, а не в другом месте
Поменяли правило workflow (например, реакцию на BLOCK) Unit-тест сервиса (ArticleWorkflowServiceTest) Это бизнес‑решение, и ему не нужен HTTP‑мир
Поменяли формат внешнего ответа (добавили/переименовали поле) Тест адаптера модерации Это вопрос маппинга внешнего payload
Поменяли URI/метод/заголовки внешнего запроса Тест адаптера модерации Это контракт общения с внешним сервисом
Поменяли перевод ошибок транспорта (как оборачиваем 500/timeout) Тест адаптера + unit-тест реакции сервиса Перевод — адаптер, реакция — сервис
Поменяли “куда пишем файл” или структуру сохранения вложений Тест адаптера storage Это техническая граница файловой системы
Поменяли “когда отправляем уведомление” Unit-тест сервиса (порт подменяем) Решение отправлять уведомление — бизнес‑часть

Если заметили, тут всё время повторяется одна мысль: мы не пытаемся доказать одним тестом всё сразу. Мы доказываем ровно то, что относится к выбранной ответственности.

7. Наблюдаемые эффекты: capturing fake

Одна из главных причин, почему тесты на внешние штуки становятся странными, — отсутствие наблюдаемого эффекта. Если метод “что-то отправляет”, но мы не можем увидеть, что именно отправилось, мы начинаем проверять внутренности реализации. А это прямой путь к хрупким тестам: поменяли реализацию — тесты упали, хотя поведение не изменилось.

Простой приём, который часто спасает начинающих: capturing fake. Это не mock, который проверяет взаимодействие через verify(...), а маленькая реальная реализация интерфейса, которая просто записывает факт вызова в коллекцию. Её приятно читать, и она не ломается от того, что вы переставили строчки местами.

Например, для PublicationNotificationSender:

import java.util.ArrayList;
import java.util.List;

public class CapturingNotificationSender implements PublicationNotificationSender {

    // Храним наблюдаемый эффект: какие articleId "попросили отправить".
    private final List<Long> sentIds = new ArrayList<>();

    @Override
    public void sendPublished(long articleId) {
        // Фиксируем факт вызова без потоков, очередей и сетевых вызовов.
        sentIds.add(articleId);
    }

    public List<Long> sentIds() {
        // Возвращаем копию, чтобы тесты не могли "случайно" испортить состояние.
        return List.copyOf(sentIds);
    }
}

С таким объектом сервисный тест становится “человеческим”. Мы не “верим”, что отправка произошла, и не лезем в детали потоков. Мы проверяем наблюдаемый эффект: идентификатор статьи оказался в sentIds().

Чтобы не тащить сюда весь реальный workflow, возьмём отдельный мини-срез сервиса публикации: у него уже есть и moderationClient, и sender. Для этого сценария модерация не участвует, поэтому подставим явный stub, а не null.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class PublicationFlowTest {

    @Test
    void shouldSendNotificationRequestWhenPublished() {
        CapturingNotificationSender sender = new CapturingNotificationSender();
        ModerationClient unusedModerationClient =
                request -> new ModerationDecision(ModerationResult.OK, null);

        ArticleWorkflowService service =
                new ArticleWorkflowService(unusedModerationClient, sender);

        // Действие домена: публикация статьи.
        service.publish(42L);

        // Проверяем наблюдаемый эффект: была запрошена отправка уведомления.
        assertThat(sender.sentIds()).containsExactly(42L);
    }
}

Да, в реальном коде ArticleWorkflowService будет иметь больше зависимостей, и publish(...) будет работать с сущностью/репозиторием. Но сама техника важна: мы сделали эффект наблюдаемым, не превращая тест в “сканер внутренностей”.

То же мышление работает и для storage: хороший порт “вернёт описатель результата” (например, StoredFile или AttachmentId), и это будет вашим наблюдаемым эффектом. Тогда сервисный тест не обязан проверять, лежит ли файл на диске: он проверяет, что сервис корректно использовал результат порта.

8. Типичные ошибки при работе с границами

Ошибка №1: сервис зависит напрямую от RestClient, Files или какого-нибудь “технического” класса.
Это выглядит как экономия кода (“зачем мне интерфейс, у меня же один клиент”), но тестируемость рушится мгновенно. Любой unit‑тест сервиса внезапно начинает требовать сеть, файловую систему или сложный мок низкоуровневых деталей. В итоге вы либо уходите в @SpringBootTest “на всякий случай”, либо перестаёте писать тесты на бизнес‑решения вообще.

Ошибка №2: адаптер становится “умным” и начинает принимать бизнес‑решения.
Иногда разработчик думает: “раз я уже получил BLOCK, то прямо тут и переведу статью в REJECTED”. Получается каша: технический слой знает про доменные статусы, доменный слой начинает зависеть от формата внешнего ответа, а тесты вынуждены проверять всё сразу. Правильная схема обратная: адаптер переводит данные, сервис принимает решение.

Ошибка №3: один тест пытается проверить и бизнес-реакцию, и HTTP‑контракт, и перевод ошибок.
Такой тест обычно длинный, дорогой и плохо объясняет падение. Если он упал — непонятно, сломался ли URL, JSON‑маппинг или бизнес‑ветка. Гораздо лучше держать два коротких теста: один на сервис (реакция), второй на адаптер (контракт и перевод).

Ошибка №4: в сервисном тесте проверяют слишком много “деталей вызова” порта.
Проверка того, что сервис вообще вызвал moderationClient.moderate(...), обычно имеет смысл. Но если вы начинаете утверждать “а в запросе было ровно 17 пробелов” или “body сериализовался именно так”, вы незаметно превращаете сервисный тест в тест протокола. Это делает suite хрупким: поменяли DTO или способ сборки запроса — и бизнес‑тест падает без реальной причины.

Ошибка №5: внешние эффекты не делают наблюдаемыми, и тесты вынуждены лезть во внутреннюю реализацию.
Когда нечего проверять снаружи, рука тянется к verify(...) по каждому чиху, к проверкам порядка вызовов и к мокированию половины мира. В итоге тест проверяет не поведение, а сценарий исполнения строчек. Capturing fake, явный результат порта и аккуратные доменные DTO обычно решают эту проблему намного проще и дешевле.

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