1. Статус‑код как часть контракта
HTTP‑статус легко воспринимать как “формальность”, пока вы не попробуете жить без него в реальном API. Клиенту (мобильному приложению, фронту, интеграции) нужно быстро понять, что произошло: он ошибся в запросе, ресурс исчез, состояние конфликтует или сервер сломался. Если вы всё превращаете в 400, клиент начинает гадать — и делает это, как правило, неправильно.
С точки зрения тестов ситуация ещё практичнее. Когда мы пишем controller tests, мы фиксируем не бизнес‑логику (она в сервисах и уже должна быть покрыта другими уровнями), а семантику границы: какие входы считаются неверными, как сервер сообщает о “нет такой статьи”, и чем отличается “нельзя, потому что такой статьи нет” от “нельзя, потому что статья уже опубликована”.
Есть ещё и “человеческий” бонус: корректные статусы ускоряют поддержку. Когда в логах и мониторинге вы видите рост 409, вы начинаете думать о workflow и конфликте состояния. Когда вы видите рост 400, вы думаете о входных данных, валидации, клиентских багах или миграции контракта. Когда растёт 500, это уже “срочно — горит”.
2. Модель выбора статуса: 400/404/409/500
Когда начинающий разработчик выбирает статус, он часто начинает “от статуса”: “Так… тут, кажется, 400?”. Гораздо спокойнее начинать “от причины”: понять, на какой стадии запроса произошёл сбой. В MVC это особенно удобно, потому что пайплайн обработки запроса довольно структурированный: сначала читаем и конвертируем входные данные, потом ищем ресурс, потом проверяем правила состояния, и уже затем выполняем действие.
Для нашего набора статусов (400, 404, 409, 500) достаточно держать в голове всего четыре вопроса. Сначала мы спрашиваем: запрос вообще удалось прочитать и связать? Если нет — это 400. Если да — ищем ресурс. Ресурса нет — 404. Ресурс есть — проверяем, разрешено ли действие в текущем состоянии. Конфликт — 409. И наконец, если всё выше было нормально, но всё равно “упало” по непредвиденной причине — это 500 (наша ответственность).
Эту модель удобно нарисовать как маленькое “дерево решений”:
flowchart TD
A[Пришёл HTTP-запрос] --> B{Удалось прочитать и сконвертировать вход?}
B -->|Нет| S400[400 Bad Request]
B -->|Да| C{Ресурс существует?}
C -->|Нет| S404[404 Not Found]
C -->|Да| D{"Состояние/правила позволяют действие?"}
D -->|Нет| S409[409 Conflict]
D -->|Да| E{Произошёл непредвиденный сбой?}
E -->|Да| S500[500 Internal Server Error]
E -->|Нет| OK[2xx Success]
Важно: одинаковый формат ApiProblem не означает одинаковый смысл. Мы можем отдавать всегда один и тот же JSON‑скелет, но статус и errorCode должны сообщать клиенту, что именно он должен сделать: исправить запрос, перестать искать несуществующее, обновить состояние, или просто повторить позже и дать нам чинить сервер.
3. 400 Bad Request: ошибки запроса
400 — это самая частая категория ошибок в публичных API, и самая “скользкая” в начинающих проектах. Скользкая потому, что она про вход, а входов у нас много: тело запроса, query params, path variables, заголовки. Плюс есть соблазн “все ошибки клиента” свалить в один мешок и всегда отвечать 400, даже когда на самом деле конфликтует состояние ресурса и логичнее 409.
В рамках ContentHub 400 почти всегда означает: запрос либо не прочитался как JSON, либо прочитался, но DTO не прошёл Bean Validation, либо входной параметр (например id) не сконвертировался в нужный тип. Это всё — разные причины, но их объединяет одно: клиент может исправить проблему, изменив запрос. Никакого “магического” действия на сервере не требуется.
Посмотрим на типичный “честный 400” — validation failure: JSON корректный, но поле нарушило правило @NotBlank. Тест здесь должен показать две вещи: статус 400 действительно вернулся, и сервис не дернули, потому что мы остановились на web‑границе.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldReturn400AndNotCallServiceWhenTitleBlank() throws Exception {
// Готовим корректный JSON по форме, но с невалидным значением поля (title = " ")
String json = """
{
"title": " ",
"summary": "S",
"body": "B",
"category": "JAVA"
}
""";
// Проверяем именно web-границу: валидация должна "уронить" запрос до вызова сервиса
mvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
// Важно: при 400 на этапе binding/validation сервис не должен вызываться вообще
then(service).shouldHaveNoInteractions();
}
Тут важно не перепутать: “пустой title” — это не конфликт статусов статьи и не отсутствие статьи. Это просто плохой ввод. Значит, 400.
А вот другой “честный 400” — type mismatch для path variable. Когда пользователь делает GET /api/editor/articles/abc, ресурс мы даже не ищем, потому что abc не превращается в Long. Ошибка возникает до сервиса, поэтому тест проверяет 400, а не 404.
import org.junit.jupiter.api.Test;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldReturn400WhenIdIsNotLong() throws Exception {
// "abc" не конвертируется в Long, поэтому до поиска статьи в сервисе дело не дойдёт
mvc.perform(get("/api/editor/articles/abc"))
.andExpect(status().isBadRequest());
// Сервис не трогаем: ошибка произошла на этапе конвертации path variable
then(service).shouldHaveNoInteractions();
}
Если вы когда‑нибудь видели API, которое на abc отвечает 404, знайте: где‑то в мире плачет один HTTP‑спецификатор. Потому что 404 — это “ресурс не найден”, а тут ресурс даже не был адресован правильно.
4. 404 Not Found: ресурс отсутствует
404 психологически очень удобен: он понятен даже тем, кто не любит HTTP. Но в API он полезен именно своей точностью: клиент понимает, что он попросил корректную вещь (в смысле формы и типов), но такой сущности не существует. То есть “исправь id/slug”, а не “исправь JSON”.
В ContentHub 404 типично прилетает, когда редактор пытается получить свою статью по id, а статьи нет. На уровне controller tests мы не должны поднимать БД и реально искать статью: мы просто настраиваем мок сервиса так, чтобы он бросил ArticleNotFoundException, и проверяем, что @ControllerAdvice переводит это в 404 и в нужный errorCode (например ARTICLE_NOT_FOUND).
import org.junit.jupiter.api.Test;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void shouldReturn404WithErrorCodeWhenArticleNotFound() throws Exception {
// Моделируем ситуацию "ресурса нет": сервис явно сообщает об этом доменным исключением
given(service.getById(99L)).willThrow(new ArticleNotFoundException("Article 99 was not found"));
// Проверяем маппинг исключения в web-контракт: статус 404 и стабилизированный errorCode
mvc.perform(get("/api/editor/articles/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.errorCode").value("ARTICLE_NOT_FOUND"));
}
Обратите внимание на тонкий момент: 99 — это валидный Long, значит запрос по форме корректен. Поэтому это не 400. Но статьи нет — значит 404.
Если вы хотите сделать проверку чуть более “контрактной”, можно дополнительно проверять, что поле status внутри ApiProblem совпадает со статусом HTTP. Это не “обязательная истина вселенной”, но в стабильном контракте полезно: клиент может не парсить HTTP‑уровень (бывает всякое), но увидит смысл и в JSON.
5. 409 Conflict: конфликт состояния
409 — статус, который особенно хорошо показывает зрелость API. Потому что он требует от нас признать: “да, ресурс есть; да, запрос валиден; но действие сейчас не может быть выполнено, потому что состояние ресурса конфликтует с тем, что вы пытаетесь сделать”. Это как пытаться “оплатить уже оплаченный заказ”: заказ существует, но действие нелепое.
Для ContentHub это почти родной статус, потому что у статьи есть workflow: DRAFT, IN_REVIEW, PUBLISHED, REJECTED, ARCHIVED. Например, POST /api/editor/articles/{id}/submit должен работать для черновика, но не для уже отправленной на ревью статьи. И это как раз не 400 (запрос нормальный), и не 404 (статья есть), а конфликт состояния — 409.
В тесте мы опять же не воспроизводим бизнес‑логику. Мы моделируем факт: сервис говорит “нельзя” через исключение (например, InvalidStatusTransitionException), а web‑слой обязан перевести это в 409 и стабилизированный errorCode.
import org.junit.jupiter.api.Test;
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.*;
@Test
void shouldReturn409WhenStatusTransitionIsInvalid() throws Exception {
// Ресурс существует, запрос валиден, но действие запрещено текущим состоянием (workflow-конфликт)
given(service.submitForReview(42L))
.willThrow(new InvalidStatusTransitionException("Already submitted"));
// Проверяем, что контроллер/адвайс переводит это именно в 409 (а не в 400/404)
mvc.perform(post("/api/editor/articles/42/submit"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.errorCode").value("INVALID_STATUS_TRANSITION"));
}
Если вы по ошибке ответите 400, клиент может начать “чинить JSON” или “повторять запрос”, хотя проблема не в JSON. Если вы по ошибке ответите 404, клиент может решить, что статья исчезла, и, например, показать “создайте новую”. А правильный 409 даёт клиенту шанс показать нормальное сообщение: “Нельзя отправить на ревью: статья уже в ревью”.
6. Статусы в @WebMvcTest
500 Internal Server Error: наша проблема
500 — статус, который обычно не хотят видеть ни разработчики, ни пользователи. Но полностью избавиться от него нельзя: он нужен как честный сигнал “произошло что-то непредвиденное”. Ключевое слово здесь — непредвиденное. То есть не validation, не not found и не контролируемый бизнес‑конфликт. Это “упало неожиданно”.
В controller tests 500 важен по двум причинам. Во-первых, мы должны убедиться, что даже при неожиданной ошибке API отвечает в стабильном формате (наш ApiProblem), а не в случайной форме. Во-вторых, мы должны убедиться, что мы не утекли клиенту в ответе чем-то вроде stack trace или внутреннего сообщения, которое вы потом сами же будете читать на скриншоте в тикете.
В упрощённом виде тест можно написать так: сервис бросает RuntimeException, а мы ожидаем 500.
import org.junit.jupiter.api.Test;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void shouldReturn500WhenUnexpectedFailureHappens() throws Exception {
// Имитируем "непредвиденное": не доменное исключение, а обычный runtime-сбой
given(service.getById(42L)).willThrow(new RuntimeException("DB is down"));
// Фиксируем, что web-слой возвращает 500 (и не маскирует его под 400/404/409)
mvc.perform(get("/api/editor/articles/42"))
.andExpect(status().isInternalServerError());
}
Если у вас в @ControllerAdvice есть fallback‑обработчик (например, @ExceptionHandler(Exception.class)), то можно дополнительно зафиксировать, что в ApiProblem пришёл какой‑то общий errorCode вроде INTERNAL_ERROR. Главное — не проверять точный текст detail, если вы не хотите сделать тест хрупким. Детали внутренних сбоев часто меняются (и должны меняться), а контракт должен оставаться стабильным.
Маппинг статусов в controller tests
Теперь соберём всё в одну понятную картинку. Когда мы пишем controller tests, мы не “угадываем статус”, а фиксируем правила маппинга: какие классы проблем в нашем API соответствуют каким HTTP‑кодам. Для этого нам не нужна БД, не нужен полный контекст и не нужен реальный сервис. Нам нужен контроллер, @ControllerAdvice и управляемый мок сервиса, который позволяет воспроизводить нужные ветки.
Удобно держать маленькую табличку‑шпаргалку именно для нашего проекта (и именно для текущего дня):
| Что пошло не так | Это что по смыслу? | HTTP | Пример в ContentHub | Что обычно проверяем в тесте |
|---|---|---|---|---|
| JSON не читается / тип не конвертируется | запрос “сломался” до DTO | 400 | malformed JSON, id=abc | статус, иногда title, и что сервис не вызывался |
| DTO собрался, но не прошёл @Valid | запрос корректен, но нарушает правила DTO | 400 | blank title/summary/body/category | статус + violations, и что сервис не вызывался |
| статьи нет | корректный запрос, но ресурса нет | 404 | GET /api/editor/articles/99 | статус + errorCode=ARTICLE_NOT_FOUND |
| статья есть, но действие запрещено текущим статусом | конфликт состояния | 409 | submit already submitted/published | статус + errorCode=INVALID_STATUS_TRANSITION |
| “непредвиденное падение” | баг/инфраструктурный сбой | 500 | runtime exception | статус, и что формат ответа не “разваливается” |
Чтобы тесты реально проверяли web‑контракт, @ControllerAdvice должен быть подключён в slice. Часто это выглядит так:
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
@WebMvcTest(EditorArticleController.class) // Поднимаем только web-слайс (контроллеры/конвертеры/валидацию)
@Import(ApiExceptionHandler.class) // Явно подключаем наш @ControllerAdvice с маппингом исключений в статусы
class EditorArticleControllerWebMvcTest {
// ...
}
И дальше вы пишете маленькие тесты, каждый из которых отвечает на один вопрос. Один тест — один источник проблемы. Не надо писать “универсальный тест всех статусов”: он будет выглядеть “умно”, но диагностироваться будет больно.
Если смотреть на идею “минимально достаточных проверок”, то в controller tests обычно достаточно зафиксировать статус, и где уместно — errorCode. Поля вроде instance или type можно проверять точечно и аккуратно, но не превращать в коллекционирование JSON‑полей. Помните: мы защищаем контракт, а не выписываем инвентаризацию каждого байта в ответе.
7. Типичные ошибки при выборе статуса
Ошибка №1: отвечать 404 на неверный тип параметра (/articles/abc).
Такое происходит, когда разработчик мыслит “сервисно”: “ну статьи же нет — значит 404”. Но на самом деле статьи даже не пытались искать, потому что запрос не прошёл conversion. В результате клиент получает “ресурс не найден”, хотя правильнее “запрос некорректен”. Это ломает диагностику и клиентскую логику.
Ошибка №2: отвечать 400 на конфликт состояния (workflow), потому что “клиент ошибся”.
Да, клиент ошибся, но не в форме запроса, а в том, что попросил действие, которое невозможно в текущем состоянии ресурса. Если валидация входа — это 400, то бизнес‑конфликт “статья уже опубликована, нельзя отправить на ревью” — это 409. Иначе клиент начинает лечить JSON‑поля, хотя ему надо обновить состояние (или показать пользователю корректное сообщение).
Ошибка №3: отвечать 500 на любые бизнес‑исключения “чтобы не думать”.
Это кажется удобным, но это прямое ухудшение API. 500 — сигнал “у нас проблема”. Если у вас контролируемая ситуация (не нашли статью, конфликт статуса), это должна быть контролируемая ветка: 404 или 409. Клиент должен иметь возможность реагировать правильно, а не просто показывать “что-то пошло не так”.
Ошибка №4: утекать внутренними деталями в 500‑ответе.
Сообщение исключения, stack trace, названия таблиц, внутренние идентификаторы — всё это может оказаться в ответе, если не продумать error handling. Даже если “это только для внутреннего проекта”, скриншоты умеют путешествовать. В ApiProblem.detail лучше держать безопасное, контролируемое описание, а детали оставлять логам.
Ошибка №5: писать один тест на три разных проблемы сразу.
Например, одновременно отправить malformed JSON и ожидать там violations и ещё errorCode. Такой тест будет падать “рандомно” при изменениях, и вы будете тратить время не на понимание API, а на археологию. Правило простое: один тест — один источник сбоя. Тогда и статус, и тело ошибки интерпретируются однозначно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ