1. Отказные сценарии — базовый реализм
Если честно, внешние сервисы — как кофе-машина в офисе: по документации «работает», по факту иногда «в режиме медитации». И если мы тестируем только happy path, то мы проверяем мир, где модерация всегда отвечает быстро, отдаёт идеальный JSON и никогда не делает 500. Такой мир существует… но обычно только в презентациях.
В ContentHub внешний moderation-сервис участвует в важном бизнес-потоке: редактор отправляет статью на ревью, и именно там мы должны либо перевести статью в IN_REVIEW, либо отклонить (REJECTED) по решению модерации. Поэтому отказные сценарии — это не «на будущее», а буквально ответ на вопрос: «что будет с пользователем и данными, когда внешний мир не в духе?». Наша задача — сделать поведение явным, наблюдаемым и тестируемым, а не «ну… как-то само разберётся».
Happy path у клиента уже зафиксирован: запрос уходит на правильный URL, валидный JSON маппится в доменное решение. Теперь берём тот же RestClientModerationClient и смотрим, где он начинает ломаться первым.
2. Карта отказов: как ломается HTTP-интеграция
Когда говорят «интеграция упала», это звучит примерно как «всё плохо». Но для тестов нам нужно не «плохо», а конкретно: что именно пошло не так, и в какой точке мы должны реагировать. Ошибки внешнего HTTP-вызова удобно рассматривать как несколько классов проблем: транспорт, статус ответа, и содержимое ответа.
Ниже — компактная карта, которую полезно держать в голове (и в тестах). Это не «формальная классификация ради классификации», а способ понять: где тестировать и что утверждать.
| Что случилось | Как это выглядит в коде | Зона тестирования | Что должен сделать адаптер | Что должен сделать сервис |
|---|---|---|---|---|
| Timeout / не дозвонились | исключение клиента (RestClientException/I/O) | тест адаптера | перевести в понятную ошибку приложения (например, ModerationUnavailableException) | не менять статус статьи, вернуть понятный отказ пользователю |
| 5xx от внешнего сервиса | HTTP 500/503/504 | тест адаптера | то же: перевести в ModerationUnavailableException | то же: не «продвигать» workflow дальше |
| 200 OK, но JSON сломан | не парсится / неожиданные поля | тест адаптера | перевести в ModerationResponseException (контракт/формат сломан) | считать интеграцию неисправной, не менять состояние |
| 200 OK, но payload «почти нормальный» (не хватает полей) | result == null или reason отсутствует там, где нужна | тест адаптера | валидировать и бросать ModerationResponseException | аналогично: не менять состояние |
| «Легальный» negative результат | result=BLOCK + причина | тест сервиса (и отдельно тест адаптера на маппинг) | корректно замаппить в доменную модель | применить бизнес-правило (перевести статью в REJECTED) |
Заметьте: BLOCK — это не авария интеграции. Это бизнес-ветка, и она должна быть обработана спокойно и детерминированно. А вот 200 OK, но результат потерялся — как раз авария: формально HTTP успешен, а смысл ответа — нет.
3. Два уровня тестов: адаптер и сервис
Очень хочется написать один «большой тест на всё», который будет и HTTP проверять, и статусы статьи менять, и ещё желательно кофе варить. Но в этом курсе мы держим дисциплину: один тест — один слой ответственности. Поэтому мы будем проверять отказные сценарии на двух уровнях, и это не прихоть, а способ получить быстрые, понятные и стабильные тесты.
Неформально это выглядит так:
flowchart LR
S["ArticleWorkflowService"] --> P["ModerationClient (порт)"]
P --> A["RestClientModerationClient (адаптер)"]
A --> X["Moderation HTTP service (внешний мир)"]
А теперь важная мысль: сервис не должен знать, был ли это timeout, 503 или «JSON съел кавычку». Ему достаточно знать «модерация недоступна» или «ответ модерации невалиден». Поэтому адаптер переводит техническую реальность в доменные исключения/результаты, а сервис уже решает: меняем статус, не меняем, возвращаем ошибку пользователю, и т.д.
Кстати, именно для этого @RestClientTest хорош: мы поднимаем узкий контекст только для клиента и тестируем конкретный бин, а не всё приложение. Spring Boot отдельно подчёркивает, что тестируемые компоненты в @RestClientTest нужно задавать явно.
4. Timeout: отдельная ветка отказа
Timeout — это самый жизненный сценарий отказа. Причём он коварен тем, что он может проявляться по-разному: от реального таймаута чтения до «соединение не установилось» или «DNS умер». Для вашего кода это обычно означает исключение из HTTP-клиента, а для пользователя — «кнопка нажата, а что дальше непонятно».
С инженерной точки зрения важно два момента. Во‑первых, timeout должен быть настроен явно, иначе ваш поток может зависнуть на неопределённое время. Во‑вторых, timeout должен переводиться в понятную форму: «модерация недоступна», а не «IOException: something somewhere». Spring Boot в документации по HTTP-клиентам отдельно упоминает базовые параметры конфигурации вроде base URL и таймаутов (connect/read timeouts).
Минимальные «наши» исключения для модерации
Начнём с того, что адаптеру нужен язык, на котором он общается с приложением. Для этого обычно достаточно двух runtime-исключений: одно про недоступность сервиса, другое про сломанный ответ.
package com.example.contenthub.integration.moderation;
// Исключение уровня домена приложения: внешняя модерация недоступна технически.
// Важно: это не "BLOCK", а именно аварийный случай интеграции.
public class ModerationUnavailableException extends RuntimeException {
public ModerationUnavailableException(String message, Throwable cause) {
// Пробрасываем исходную причину, чтобы в логах/трейсинге было видно "что именно упало".
super(message, cause);
}
}
ModerationResponseException делается аналогично: это «мы получили ответ, но он непригоден для понимания».
Перевод ошибок в адаптере: «техника внутрь, смысл наружу»
Продолжаем тот же RestClientModerationClient, который уже собрали через RestClient.Builder, baseUrl и ModerationResponse. Чтобы не дублировать happy-path целиком, ниже оставим только то, что меняется для отказов: валидацию ответа и перевод технических исключений.
import org.springframework.web.client.RestClientException;
@Override
public ModerationDecision moderate(ModerationRequest request) {
try {
ModerationResponse response = restClient.post()
.uri("/api/check")
.body(request)
.retrieve()
.body(ModerationResponse.class);
return map(response);
} catch (RestClientException e) {
throw translate(e);
}
}
Маппинг, который ещё и валидирует, что ответ «хотя бы имеет смысл»:
private ModerationDecision map(ModerationResponse r) {
// Валидация контрактной целостности ответа:
// 200 OK без result для нас не "успех", а "сломанный контракт".
if (r == null || r.result() == null) {
throw new ModerationResponseException("Missing result in moderation response");
}
// reason может быть null — это уже бизнес-договорённость, а не техническая авария.
return r.toDecision();
}
И перевод ошибки. Тут мы показываем идею: если это проблема конвертации payload — это «сломанный ответ», иначе — «недоступность».
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.web.client.RestClientException;
private RuntimeException translate(RestClientException e) {
// Если проблема в десериализации/конвертации, значит пришёл мусор вместо ожидаемого JSON.
if (e.getCause() instanceof HttpMessageConversionException) {
return new ModerationResponseException("Broken JSON from moderation", e);
}
// Всё остальное (таймауты, I/O, 5xx и т.п.) сводим к "модерация недоступна".
return new ModerationUnavailableException("Moderation service unavailable", e);
}
Да, в реальном мире типов исключений больше. Но учебно важно понять: адаптер — переводчик, а не «труба, которая выбрасывает наружу всё подряд».
Тест timeout-сценария на уровне адаптера
MockRestServiceServer позволяет не только отдавать ответы, но и симулировать I/O-ошибку. Мы не ждём реальные 5 секунд (иначе тесты превратятся в сериал), мы просто создаём ситуацию «вызов упал технически».
import java.net.SocketTimeoutException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withException;
@Test
void shouldTranslateTimeoutToModerationUnavailable() {
// Считаем тот же baseUrl, что и в happy path: https://moderation.local.
server.expect(requestTo("https://moderation.local/api/check"))
// Симулируем технический отказ без реального ожидания таймаута.
.andRespond(withException(new SocketTimeoutException("Read timed out")));
// Проверяем именно перевод исключения: наружу должен уйти доменный "модерация недоступна".
assertThatThrownBy(() -> client.moderate(new ModerationRequest("T", "B")))
.isInstanceOf(ModerationUnavailableException.class);
}
Здесь важно, что мы тестируем именно перевод ошибки в адаптере. Мы не проверяем статус статьи, не проверяем workflow — это уровень сервиса, и туда мы перейдём позже в этой же лекции.
5. 5xx и «серверная ошибка»: когда внешняя система сказала «ой»
HTTP 5xx — это более «честная» проблема, чем timeout. В timeout мы вообще не знаем, что там произошло. А в 500/503 внешний сервис явно сообщает: «я сейчас не могу». Для системы это всё равно означает: «нельзя продолжать бизнес-поток, который зависит от модерации». Здесь особенно важно не перепутать два сценария: бизнес-отклонение BLOCK — это нормальная ветка, а 500 — это авария интеграции.
На уровне адаптера мы, как правило, переводим все 5xx в одну доменную ошибку: ModerationUnavailableException. И тест должен зафиксировать это поведение, потому что завтра кто-то «оптимизирует» код и случайно начнёт считать 503 «почти успехом». А это уже путь к публикации непроверенного контента — и привет, модераторы.
Тест: 500 от внешнего сервиса → ModerationUnavailableException
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
@Test
void shouldTranslateServerErrorToModerationUnavailable() {
// Внешний сервис честно ответил 500: техническая авария.
server.expect(requestTo("https://moderation.local/api/check"))
.andRespond(withServerError()); // 500
// Наша договорённость: любые 5xx превращаем в ModerationUnavailableException.
assertThatThrownBy(() -> client.moderate(new ModerationRequest("T", "B")))
.isInstanceOf(ModerationUnavailableException.class);
}
Нюанс с URI в ожиданиях
Если вы собираете клиент через builder.baseUrl("https://moderation.local"), то в ожиданиях MockRestServiceServer обычно тоже нужен полный адрес. Путь "/api/check" для нашего baseline уже недостаточен: реальный запрос уходит на "https://moderation.local/api/check". Это одна из тех мелочей, которые ломают тест «просто потому что», и студент начинает думать, что тестирование — это магия. Нет, это просто внимательность к тому, как реально собирается запрос.
6. Сломанный payload: «200 OK», но ...
Самая неприятная категория ошибок — когда HTTP говорит «успех», а смысловой контракт ответа поломан. Это может быть неправильное имя поля, неожиданное значение enum, отсутствие обязательного поля, или вообще HTML-страница вместо JSON (да, такое бывает — особенно когда за вашим сервисом стоит прокси, который тоже хочет жить).
Эта ветка важна тем, что многие системы наивно считают: «если 200 — значит всё хорошо». В тестах мы должны доказать обратное: 200 — это только транспортная оболочка, а правильность результата — это ещё и корректный payload. Поэтому мы валидируем payload и переводим проблему в ModerationResponseException, чтобы сервис мог отличить «сервис недоступен» от «сервис отвечает мусором».
Тест: 200 OK, но нет поля result
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@Test
void shouldFailOnMissingResultField() {
// HTTP 200, но контракт сломан: обязательного поля result нет.
// Это должно считаться ошибкой интеграции, а не "почти успехом".
server.expect(requestTo("https://moderation.local/api/check"))
.andRespond(withSuccess(
"""
{
"reason": "spam"
}
""",
MediaType.APPLICATION_JSON));
// Проверяем, что адаптер не пропускает "смысловую пустоту" наружу.
assertThatThrownBy(() -> client.moderate(new ModerationRequest("T", "B")))
.isInstanceOf(ModerationResponseException.class);
}
Здесь срабатывает наша map(dto)-валидация: result == null → ответ «непонимаемый» → исключение «сломанный ответ».
Тест: 200 OK, но enum-значение неизвестно
Это классическая ситуация «контракт эволюционировал, а вы не заметили». Например, внешний сервис начинает возвращать result=ALLOW, а у вас enum знает только OK/WARN/BLOCK.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@Test
void shouldFailOnUnknownResultValue() {
// HTTP 200, но значение поля result неожиданное для нашей модели/enum.
// Такое должно падать явно: иначе мы можем "по умолчанию" принять мусор за OK.
server.expect(requestTo("https://moderation.local/api/check"))
.andRespond(withSuccess(
"""
{
"result": "ALLOW"
}
""",
MediaType.APPLICATION_JSON));
assertThatThrownBy(() -> client.moderate(new ModerationRequest("T", "B")))
.isInstanceOf(ModerationResponseException.class);
}
В зависимости от настроек Jackson и того, как вы объявили DTO, это может упасть на этапе десериализации, а может привести к null. В любом случае тест закрепляет важную идею: неожиданный payload — это ошибка интеграции, и мы обязаны реагировать явно, а не «ну, наверное OK».
7. Безопасная деградация workflow
Как только адаптер научился честно различать unavailable и broken response, сервису остаётся только правильно среагировать. Пользователю и бизнесу вообще всё равно, как именно зовут ваше исключение. Их интересует другое: что будет со статьёй и что увидит редактор, если модерация не отвечает или отвечает мусором. Вот это и есть безопасная деградация.
Безопасная деградация — это когда система не делает вид, что всё прошло успешно, и при этом не оставляет данные в странном промежуточном состоянии. Для ContentHub логика обычно такая: если мы не можем получить решение модерации, то не переводим статью в IN_REVIEW и не публикуем её «на авось». Самое простое корректное поведение — оставить статью в DRAFT и сообщить об ошибке (например, через бизнес-исключение, которое web-слой превратит в 503).
Сервисная ветка: «если модерация не сработала — поток прерываем»
Покажем идею на маленьком фрагменте ArticleWorkflowService. Важный приём: сначала зовём модерацию, и только потом меняем статус статьи. Тогда при падении интеграции состояние статьи остаётся прежним.
public Article submitForReview(Article draft) {
try {
// Сначала получаем решение модерации...
ModerationDecision d = moderationClient.moderate(toRequest(draft));
// ...и только потом применяем его к статье (меняем статус).
return applyDecision(draft, d);
} catch (ModerationUnavailableException | ModerationResponseException e) {
// Доменно фиксируем факт: отправка на ревью невозможна.
// Важно: не делаем вид, что всё получилось.
throw new ArticleSubmissionException("Cannot submit: moderation failed", e);
}
}
ArticleSubmissionException — это уже «язык» бизнес-потока, а не интеграции. Да, по сути это обёртка. Но она важна тем, что наверху (в API слое) вы будете стабильно отличать «не получилось отправить на ревью» от других ошибок.
Тест на уровне сервиса: состояние статьи не должно продвинуться
Этот тест пишется без HTTP. Здесь нам не нужен MockRestServiceServer вообще. Мы считаем, что адаптер уже перевёл технический сбой в понятное исключение (ModerationUnavailableException), и сервис реагирует на него как на часть бизнес-ветки «не получилось отправить».
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.any;
@Test
void shouldNotMoveDraftWhenModerationUnavailable() {
// Мокаем порт: модерация "недоступна" в доменных терминах.
given(moderationClient.moderate(any()))
.willThrow(new ModerationUnavailableException("timeout", null));
Article draft = Article.draft(42L); // status = DRAFT
// Поток должен прерваться явно: наверх уходит бизнес-ошибка отправки.
assertThatThrownBy(() -> workflowService.submitForReview(draft))
.isInstanceOf(ArticleSubmissionException.class);
// Главное бизнес-утверждение: статус не должен "уехать" в IN_REVIEW из-за отказа интеграции.
assertThat(draft.getStatus()).isEqualTo(ArticleStatus.DRAFT);
}
Обрати внимание на «психологию» этого теста. Мы не проверяем «сколько раз дёрнули RestClient». Мы не проверяем текст исключения. Мы проверяем два факта: поток прервался явно (исключение) и данные не уехали в неверное состояние (статус остался DRAFT). Это и есть безопасная деградация в минимальной форме.
8. Типичные ошибки при отказах интеграции
Ошибка №1: смешивать в одном тесте и HTTP-механику, и бизнес-реакцию сервиса.
Такой тест обычно получается длинным, медленным и очень трудно диагностируемым. Если он падает, вы не понимаете, где проблема: в маппинге JSON, в переводе исключения, или в логике смены статуса статьи. В итоге тест «как будто покрывает всё», а на деле превращается в шумный сценарий, который никто не хочет поддерживать.
Ошибка №2: считать 200 OK автоматическим успехом и не валидировать payload.
Это классика интеграционных багов. Внешний сервис может вернуть JSON с пропущенным полем или с новым enum-значением, а вы «проглотите» это как null и, например, отправите статью на ревью без реальной модерации. Тесты на “сломанный payload” нужны именно для того, чтобы 200 без смысла считался ошибкой.
Ошибка №3: «безопасная деградация» в стиле “тихо проглотили ошибку”.
Иногда разработчик ловит exception и делает вид, что ничего не произошло: возвращает успешный результат, оставляя статью в DRAFT, но при этом не сообщает пользователю, что отправка на ревью не состоялась. С точки зрения UX это хуже, чем честный отказ, потому что пользователь уверен, что система сработала, а она нет. Деградация должна быть явной: либо исключение/ошибка, либо явный результат-статус операции.
Ошибка №4: тестировать в сервисном тесте низкоуровневые детали типа SocketTimeoutException.
Если сервис начинает зависеть от конкретных технических исключений HTTP-клиента, вы ломаете границу “порт/адаптер” и усложняете тесты. Сервис должен работать с переведёнными доменными ошибками вроде ModerationUnavailableException, иначе любая смена клиента (RestClient → другой клиент) повлечёт переписывание бизнес-тестов.
Ошибка №5: сломать ожидания MockRestServiceServer из-за неправильного URI и решить, что «Spring тесты не работают».
Когда клиент собирается через baseUrl, ожидания обычно должны смотреть на полный URI. Если этот нюанс игнорировать, тест падает не по сути, а по “строка не совпала”, и обучение превращается в борьбу с инфраструктурой вместо понимания поведения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ