1. Единый error contract как часть API
Когда вы пишете контроллер, очень хочется думать, что API — это только «успешные» ответы: списки статей, карточки, статус 201 Created с Location. Но реальный клиент чаще всего страдает (в хорошем смысле) не от ваших DTO, а от ваших ошибок. И если ошибки хаотичные, то клиенту больно: фронтенд не понимает, что показать пользователю, мобильное приложение не может локализовать сообщение, а вы в логах видите «что-то где-то упало» и начинаете гадать, что именно.
Единый error contract — это договор: «В любой ошибке я дам JSON понятной формы. В нём будет статус, человекочитаемый заголовок, деталь, машиночитаемый errorCode, а если проблема в полях — список violations». Такой договор делает систему предсказуемой. И для тестирования это золото: вы можете не просто проверить, что ошибка случилась, а проверить, что она всегда превращается в один и тот же формат ответа, независимо от того, где именно она возникла.
Чтобы было проще держать в голове «что мы вообще хотим видеть», удобно зафиксировать ориентир в виде таблички (не как строгий стандарт вселенной, а как рабочая карта для ContentHub):
| Источник проблемы | Пример исключения | Ожидаемый status | Пример errorCode | violations |
|---|---|---|---|---|
| Ошибка входных данных (валидный JSON, но правила DTO нарушены) | MethodArgumentNotValid Exception | 400 | VALIDATION_FAILED | да |
| Тело запроса не читается (JSON сломан) | HttpMessageNotReadable Exception | 400 | MALFORMED_JSON | обычно нет |
| Ресурс не найден | ArticleNotFound Exception | 404 | ARTICLE_NOT_FOUND | нет |
| Конфликт состояния ресурса | InvalidStatusTransition Exception | 409 | INVALID_STATUS_TRANSITION | нет |
| Внутренняя авария | Exception | 500 | INTERNAL_ERROR | нет |
Смысл этой таблички в одном: даже если детали будут меняться, форма ошибки должна оставаться стабильной. И вот тут мы переходим к механике Spring.
2. Конвейер ошибок в Spring MVC
Чтобы @ControllerAdvice не казался «магической аннотацией для шаманов», полезно увидеть, где именно в MVC рождаются ошибки. Контроллер — это не единственная точка, где что-то может пойти не так. До контроллера есть чтение тела, конвертация параметров и binding. После контроллера есть сериализация ответа. А если внутри делегированного сервиса летит исключение — оно тоже должно стать нормальным HTTP-ответом, а не «упс, стек-трейс в проде».
Если упростить жизненный цикл запроса до учебной схемы, получается примерно так:
flowchart TD
A["HTTP запрос"] --> B["Message converters / чтение JSON"]
B -->|успех| C["Binding: собираем DTO"]
B -->|ошибка| X["HttpMessageNotReadableException"]
C --> D["Bean Validation @Valid"]
D -->|ошибка| Y["MethodArgumentNotValidException"]
D -->|успех| E["Controller method"]
E --> F["Service"]
F -->|business error| Z1["ArticleNotFoundException / InvalidStatusTransitionException"]
F -->|unexpected| Z2["RuntimeException"]
X --> G["@ControllerAdvice"]
Y --> G
Z1 --> G
Z2 --> G
G --> H["ApiProblem JSON + status"]
В этой схеме важно вот что: @ControllerAdvice стоит «сбоку» от всего конвейера. Он не заменяет валидацию, не лечит кривой JSON и не чинит бизнес-логику. Он делает более прагматичную вещь: берёт исключение и превращает его в предсказуемый HTTP-ответ.
И если мы хотим тестировать web-layer как границу API, нам нужно уметь в тестах «подсветить» этот перевод исключения в ApiProblem.
3. try/catch в контроллере как анти-паттерн
На старте проекта соблазн очень простой: «Ну я же могу прямо в контроллере сделать try/catch, и всё будет норм». И это действительно будет какое-то время работать. Но потом у вас появится второй контроллер, третий, потом добавятся новые ошибки, потом вы захотите поменять формат ответа… и вы окажетесь в мире копипасты, где одно место забыли обновить и клиент получает два разных формата ошибок в зависимости от endpoint-а. Это такой тихий, вежливый хаос.
Посмотрите на типичный анти-паттерн. Он даже выглядит «аккуратно», но это аккуратность с характером «пока проект маленький»:
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class EditorArticleController {
@PostMapping("/api/editor/articles/42/submit")
ResponseEntity
submit() {
try {
// Плохо: контроллер сам решает, как маппить ошибки в HTTP-ответ.
// Из-за этого формат ошибок расползается по проекту.
return ResponseEntity.ok().build();
} catch (RuntimeException ex) {
// Плохо: здесь мы теряем единый контракт (нет ApiProblem, нет errorCode, нет instance).
return ResponseEntity.status(500).build();
}
}
}
Проблема здесь не в catch. Проблема в том, что контроллер начинает отвечать за то, за что он не должен отвечать: за формат ошибок и за mapping исключений. В хорошей картине мира контроллер тонкий: принимает HTTP, валидирует, делегирует, возвращает результат. Перевод исключений — отдельная ответственность.
И самое неприятное для тестирования: вам приходится в каждом тесте думать «а этот контроллер сам ловит исключение или нет?», и вы теряете единую точку, которую можно проверить один раз и доверять ей дальше.
4. @ControllerAdvice как единый зонтик
Когда вы уже увидели, что ошибки приходят из разных мест, возникает логичный вопрос: «А где сделать единое место, которое всё это нормализует?» В Spring MVC ответ как раз такой: @ControllerAdvice. Это компонент, который подключается к MVC-инфраструктуре и позволяет централизованно обрабатывать исключения, добавлять атрибуты в модель (для MVC-страниц) и в целом влиять на web-layer поведение. В нашем API-слое ключевой сценарий — именно обработка исключений.
Для REST API чаще всего используют @RestControllerAdvice. Это фактически @ControllerAdvice + автоматический @ResponseBody для возвращаемого объекта. То есть вы возвращаете ApiProblem, а Spring сериализует его в JSON (через Jackson 3), как обычный response DTO.
Минимальный каркас выглядит так:
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(Exception.class)
void handleAny() {
// Важно: это fallback-обработчик на «всё неизвестное».
// В реальном коде тут обычно будет ResponseEntity<ApiProblem> + логирование.
}
}
Сам по себе этот каркас ещё ничего не делает полезного, но он задаёт важное архитектурное правило: любой контроллер может «просто бросить исключение», а обработчик ошибок превратит его в стабильный ответ. И для тестов это удобно: в @WebMvcTest мы можем замокать сервис и заставить его бросить нужное исключение, не моделируя весь мир.
5. @ExceptionHandler: перевод в ApiProblem
Когда мы пишем @ExceptionHandler, наша цель не «спрятать ошибку», а перевести её на язык клиента. Это похоже на переводчика на международной конференции: участники говорят на разных языках (исключения разных типов), но клиент хочет всегда слышать один язык (наш ApiProblem). И да, переводчик должен быть последовательным.
Начнём с простых бизнес-исключений. Допустим, у нас есть ArticleNotFoundException, который должен стать 404, и InvalidStatusTransitionException, который должен стать 409. Внутри handler-а мы собираем ApiProblem и возвращаем ResponseEntity с нужным статусом.
Чтобы пример был компактным, покажем только смысловую часть:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ExceptionHandler(ArticleNotFoundException.class)
ResponseEntity<ApiProblem> handleNotFound(ArticleNotFoundException ex, HttpServletRequest request) {
// instance удобно заполнять URL запроса: это помогает и клиенту, и логам.
String instance = request.getRequestURI();
// Важно: фабричный метод скрывает детали заполнения ApiProblem и уменьшает копипасту.
ApiProblem problem = ApiProblem.notFound(instance, ex.getMessage());
// HTTP-статус должен соответствовать семантике ошибки.
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
Здесь мы уже видим несколько полезных идей. Во-первых, instance можно заполнить из request.getRequestURI(), чтобы клиент понимал, какой конкретно endpoint дал ошибку (это удобно и для дебага, и для логов). Во-вторых, создание ApiProblem лучше вынести в фабричный метод (notFound, conflict, badRequest) — так вы избегаете копипасты одних и тех же полей по всему advice.
Технические исключения тоже должны попадать в этот зонтик. Например, malformed JSON, который в Spring часто проявляется как HttpMessageNotReadableException. Мы уже видели его в предыдущей лекции, но теперь наша задача — сделать так, чтобы он превратился не в «дефолтную ошибку Spring», а в наш ApiProblem:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ApiProblem> handleMalformedJson(HttpMessageNotReadableException ex, HttpServletRequest request) {
// Тут важно не возвращать «внутренности» (например, часть стектрейса) в detail.
// Клиенту достаточно понять: JSON не читается.
ApiProblem problem = ApiProblem.badRequest(
request.getRequestURI(),
"Malformed JSON",
"MALFORMED_JSON"
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem);
}
И, наконец, validation failure (MethodArgumentNotValidException) — это отдельный класс ошибок, где клиенту нужно видеть violations. Мы не будем повторять всю Bean Validation (это было в лекции 1), но покажем важный кусок: violations формируются из FieldError в BindingResult.
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiProblem> handleValidation(MethodArgumentNotValidException ex) {
// Превращаем ошибки валидации Spring в наш стабильный формат violations.
List<Violation> violations = ex.getBindingResult().getFieldErrors().stream()
// field = имя поля DTO, message = человекочитаемое объяснение
.map(fe -> new Violation(fe.getField(), fe.getDefaultMessage()))
.toList();
// Важно: контракт «validation failed» отличается от «malformed JSON».
return ResponseEntity.badRequest().body(ApiProblem.validationFailed(violations));
}
Заметьте, насколько здесь «вкусно» выглядит контракт: клиент получает структурированные нарушения, а не один длинный текст «Validation failed for object…». И это как раз то, что мы хотим фиксировать тестами.
6. ApiProblem: что стабилизировать тестами
Проблема с error DTO такая же, как с любым DTO: можно сделать его слишком рыхлым (и тогда клиенту неудобно), а можно сделать слишком «бетонным» (и тогда любое изменение превращается в боль). Здесь нужен баланс. Мы хотим, чтобы ApiProblem был достаточно стабильным, чтобы клиент мог на него опираться, но достаточно гибким, чтобы вы могли менять формулировки не ломая мир.
Минимальная форма ApiProblem для ContentHub может выглядеть примерно так (не как «единственно верная», а как понятный ориентир):
import java.util.List;
public record ApiProblem(
int status,
String title,
String detail,
String instance,
String errorCode,
List<Violation> violations
) { }
И violation — простая пара «поле + сообщение»:
public record Violation(String field, String message) { }
Что здесь обычно стоит стабилизировать тестами? Статус и errorCode — почти всегда да, потому что по ним клиент строит логику. Наличие или отсутствие violations — тоже да, потому что фронтенд будет рисовать ошибки формы. А вот detail часто лучше проверять не полностью, а частично: иногда сообщение исключения меняется, и вам не хочется, чтобы тесты падали из‑за лишнего слова.
В итоге получается практическое правило для тестов: фиксируйте смысл, а не художественную литературу. Ключевые поля и структура — да. Полные тексты сообщений — только если это реально часть вашего контракта.
7. @WebMvcTest и подключение advice
С @WebMvcTest есть тонкий, но важный момент. Этот slice поднимает MVC-инфраструктуру и ваш контроллер, но не обязан автоматически подтянуть весь ваш «боевой» набор компонентов. И если ваш @ControllerAdvice по какой-то причине не оказался в контексте теста, вы начнёте проверять дефолтное поведение Spring/Boot: где-то будет стандартный ответ, где-то HTML-страница ошибки, где-то другой формат ProblemDetail. А вы будете смотреть на это и думать: «Странно, но тест же зелёный…» — да, зелёный, потому что тестировал не тот контракт.
Поэтому в web-slice тестах хорошая привычка такая: если вы хотите проверять перевод исключений в ApiProblem, подключайте advice явно. Самый прямой вариант — @Import.
Пример каркаса теста:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(EditorArticleController.class) // Поднимаем только MVC-слой и конкретный контроллер
@Import(ApiExceptionHandler.class) // Явно подключаем наш error-handling (advice)
class EditorArticleControllerErrorWebMvcTest { }
Так вы сами себе (и команде) честно говорите: «В этом тесте я проверяю поведение контроллера вместе с нашим error handling». И это ровно то, что надо.
8. Тестирование mapping бизнес-исключений
Теперь самая приятная часть: как это выглядит в тесте. Ключевая мысль — мы не хотим моделировать бизнес-логику внутри controller test. Мы хотим проверить web-контракт: если сервис сообщает “конфликт статуса”, то HTTP-ответ должен стать 409 и иметь errorCode = INVALID_STATUS_TRANSITION.
Делаем просто: мок сервиса, given(...).willThrow(...), запрос через MockMvc, проверки статуса и JSON.
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(EditorArticleController.class)
@Import(ApiExceptionHandler.class)
class EditorArticleControllerErrorWebMvcTest {
@Autowired MockMvc mvc; // Инструмент для выполнения HTTP-запросов к MVC-слою в тесте
@org.springframework.test.context.bean.override.mockito.MockitoBean
EditorArticleService service; // Мокаем сервис, чтобы управлять тем, какие исключения «вылетят»
@Test
void shouldTranslateConflictToApiProblem() throws Exception {
// Договоримся с мок-сервисом: на submit для статьи 42 он бросает бизнес-исключение
given(service.submitForReview(42L))
.willThrow(new InvalidStatusTransitionException("Already published"));
// Проверяем глазами клиента: статус + ключевые поля ApiProblem
mvc.perform(post("/api/editor/articles/42/submit"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.status").value(409))
.andExpect(jsonPath("$.errorCode").value("INVALID_STATUS_TRANSITION"));
}
}
Здесь происходит очень «правильная» вещь. Тест не знает, почему именно переход статуса недопустим. Он не повторяет логику PublicationPolicy. Он проверяет границу HTTP: какой статус и какой payload увидит клиент. И именно этим @WebMvcTest ценен — он защищает API-контракт, а не бизнес-правила.
Точно так же можно проверить 404, если сервис бросает ArticleNotFoundException. И это будет очень честный web-layer тест: форма запроса корректна, но ресурса нет, и advice должен перевести это в единый контракт.
9. Технические ошибки: malformed JSON и validation
В жизни клиенты ломают запросы двумя способами: иногда «правильно, но неправильно» (валидный JSON, но пустой title), а иногда «неправильно на уровне синтаксиса» (сломанный JSON). Эти две ветки пайплайна мы уже развели: в одном случае DTO успевает собраться и валидироваться, в другом — нет. Теперь цель другая: оба случая должны стать ApiProblem, но с разными смысловыми полями.
Malformed JSON тестируется просто: мы отправляем кривое тело. Контроллер даже не успевает стартовать, но advice всё равно должен дать наш формат.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@Test
void shouldReturnApiProblemWhenJsonIsMalformed() throws Exception {
// Важно: здесь JSON синтаксически сломан (не закрыта фигурная скобка).
String body = "{\"title\":\"A\",\"summary\":\"B\",\"body\":\"C\",\"category\":\"JAVA\"";
// Контроллер не вызовется — упадёт чтение тела запроса. Но контракт ошибок должен соблюдаться.
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errorCode").value("MALFORMED_JSON"));
}
Validation failure — похожий по статусу кейс (400), но по структуре другой: здесь должны быть violations. Мы не обязаны в каждом тесте проверять весь список, но проверять, что violations вообще есть и что в них есть нужное поле — полезно.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@Test
void shouldReturnViolationsWhenTitleIsBlank() throws Exception {
// JSON валиден, но значение title не проходит @NotBlank/@NotEmpty (или аналогичную проверку).
String body = "{\"title\":\" \",\"summary\":\"S\",\"body\":\"B\",\"category\":\"JAVA\"}";
// Нам важно не имя исключения, а то, что клиент получит violations и сможет подсветить поле.
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.violations[0].field").exists());
}
Обратите внимание на важную психологическую штуку: клиенту неинтересно, что это MethodArgumentNotValidException. Клиенту интересно: «Я могу по violations подсветить поля?». И именно это мы фиксируем.
Инженерный нюанс: тестируйте mapping, а не детали исключений
Когда вы начинаете активно писать error tests, очень легко скатиться в «перепроверку Spring». Например, пытаться проверить, что при malformed JSON кидается именно HttpMessageNotReadableException, или что validation падает именно таким классом. Обычно это слабая ценность: эти детали принадлежат фреймворку и могут меняться между версиями (а у нас, напомню, современный стек Spring 7 / Boot 4).
Гораздо сильнее тест выглядит так: «на входе такой запрос, на выходе такой ApiProblem». Это проверка вашего контракта. И она переживёт и смену версии Jackson, и внутренние перестановки в Spring MVC (пока контракт остаётся тем же). Конечно, если вы намеренно завязались на конкретный exception type в advice — окей, вы это выбрали осознанно. Но в тестах лучше смотреть глазами клиента, а не глазами ExceptionResolver.
И здесь же полезно держать в голове ещё один ограничитель: controller tests не должны превращаться в сборник всех возможных ошибок. Они должны покрывать ключевые классы ошибок и фиксировать, что ваш advice стабилен. Единого формата ошибки здесь уже достаточно, чтобы клиенту было удобно читать ответ, но для полного контракта этого ещё мало: тот же ApiProblem должен приходить с правильным HTTP-статусом.
10. Типичные ошибки: advice и тесты
Ошибка №1: advice написан, но в @WebMvcTest он не подключён.
Тесты начинают проверять дефолтный формат ошибок Spring/Boot, и это выглядит как «вроде бы всё работает». Потом вы запускаете приложение, получаете другой payload, и клиент ломается. Лечится это очень просто: в web-slice тестах, где вы проверяете error contract, подключайте advice явно через @Import и не надейтесь на “авось он сам подтянется”.
Ошибка №2: контроллеры ловят исключения вручную, и advice становится бесполезным.
Если внутри контроллера стоит try/catch и он возвращает “как-то” сформированный ответ, то @ControllerAdvice просто не увидит проблему. В итоге в одном endpoint-те ошибки идут через advice, в другом — через ручной код, и API перестаёт быть однородным. Правильнее держать контроллеры тонкими и позволять исключениям «вылетать наружу», чтобы advice их перевёл.
Ошибка №3: все ошибки свалены в один handler на Exception.class, и теряется смысл статусов и errorCode.
Да, технически можно одним обработчиком сделать «всегда 500». Но тогда клиент не отличит “не нашли статью” от “сервер упал”. И главное — вы сами себе отключите возможность писать точные негативные тесты. Лучше иметь несколько handler-ов на основные классы ошибок: not found, conflict, validation, unreadable body, и только в конце — fallback на unexpected.
Ошибка №4: в error response утекли внутренние детали (stacktrace, имена классов, SQL).
Новичкам хочется в detail отправить ex.toString() — «ну пусть клиент увидит». На практике это и небезопасно, и некрасиво, и превращает ваш API в “открытую книгу внутренних устройств”. В detail лучше держать безопасную, контролируемую формулировку, а детали оставлять в логах. В тестах можно фиксировать, что errorCode корректный, а detail не содержит чего-то явно лишнего.
Ошибка №5: тесты проверяют полный текст detail и становятся хрупкими.
Если вы в тесте сравниваете detail целиком, любое изменение текста (даже полезное) ломает suite. Чаще разумнее проверять смысловые поля (status, errorCode, наличие violations) и, при необходимости, только часть detail (или вообще не проверять текст). Исключение — когда текст реально является частью контракта и клиент его использует как есть.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ