1. Критерии smoke‑проверки локального API
Когда вы впервые поднимаете сервер, мозг радостно кричит: «Ура, процесс живой, значит API готово!» — и тут же пытается перейти к следующей теме. Но backend‑реальность жестока: процесс может жить, а HTTP‑контракт при этом быть мёртвым, странным или отвечать вообще не на тот путь. Поэтому мы вводим простое правило: результат дня измеряется запросом и ответом, а не фактом запуска.
Контракт /health уже собран на стороне сервера. Теперь важно не поверить своему коду на слово, а проверить его глазами клиента: status, headers и body должны совпасть с тем, что мы обещали.
В нашем случае smoke‑проверка должна ответить на вопрос: «Если я отправлю GET /health, получу ли я предсказуемый ответ, который можно прочитать глазами и проверить инструментом?» Здесь важно смотреть на три части ответа вместе: status code, headers и body. Если проверять только body, вы легко пропустите ситуацию, когда сервер возвращает JSON, но с неправильным Content-Type, или отвечает 500, но в body всё равно «что-то похожее на JSON». Если проверять только статус, можно получить 200 OK с пустым телом из-за ошибки записи body.
Чтобы было проще держать критерии в голове, полезно иметь маленькую «памятку» (не для экзамена, а для жизни).
| Что проверяем | Ожидание для /health | Где видно лучше всего | Если не так — что это обычно значит |
|---|---|---|---|
| Доступность по адресу | http://host:port/health открывается | браузер / curl / Postman | сервер не стартовал, не тот порт, порт занят, неверный host |
| HTTP‑метод | мы делаем GET | curl -v, Postman, HttpClient | не тот метод (или сервер ожидает другой, но это уже баг контракта) |
| Status code | 200 | curl -i, Postman, HttpClient | handler не сработал, исключение, неверная регистрация context |
| Content-Type | application/json | curl -i, Postman | забыли заголовок или записали «как получится» |
| JSON body | {"status":"UP","appName":"..."} | браузер (грубо), Postman, HttpClient | не сериализовали DTO, неверная кодировка, не записали body |
Если эта smoke‑проверка проходит, доказан только skeleton: сервер умеет принять запрос и отдать JSON. Как только путей станет больше одного, придётся уже руками различать метод, path, query и body.
Обратите внимание: это не «тестирование» в смысле отдельной дисциплины. Это дисциплина проверки контракта. Ровно то же вы будете делать в прод‑жизни, только вместо /health будет «почему у клиентов 502», вместо curl — лог‑агрегатор, и вместо спокойствия — кофе, который вы пьёте не потому что хочется, а потому что так надо.
2. Проверка через браузер
Браузер — это самый быстрый способ почувствовать «сервер вообще отвечает или я разговариваю с розеткой». И да, он удобен: вы просто открыли вкладку, ввели адрес и увидели ответ. Но браузер, как любой «удобный инструмент», часть деталей прячет — именно те, которые backend‑разработчику часто важны. Поэтому браузер хорош как первый шаг, но плох как единственный.
Если сервер у нас поднят на localhost:8080, мы открываем:
http://localhost:8080/health
И ожидаем увидеть JSON, примерно такой:
{"status":"UP","appName":"readlater-starter"}
Если вы увидели что-то похожее на JSON — это уже неплохо. Если браузер пишет This site can’t be reached или ERR_CONNECTION_REFUSED, это означает, что до HTTP‑ответа мы даже не дошли. Здесь чаще всего виноват не handler, а адрес: сервер не поднят, порт другой, порт занят, или вы запускаете сервер на одном host, а обращаетесь к другому.
Чтобы мозг не начинал магически «чинить код», полезно сделать короткую паузу и задать себе вопрос: «Я точно стучусь туда, куда сервер сказал в startup‑логе?» Это звучит как капитан‑очевидность, но именно такие капитаны спасают проекты.
Если хочется увидеть больше деталей прямо в браузере, можно открыть DevTools → вкладку Network и посмотреть status code и response headers. Это уже почти взрослый подход, но в учебном сценарии проще и быстрее перейти к curl или Postman: там детали видны сразу, без танцев по вкладкам.
3. Проверка через curl
curl — это как фонарик для HTTP‑контракта: некрасиво, зато честно. Он не пытается «помочь» вам красивой страничкой и не прячет заголовки, если вы явно попросили их показать. Для backend‑разработчика это один из лучших инструментов для smoke‑проверок, потому что он сразу даёт ответ на три ключевых вопроса: «какой статус? какие заголовки? какое тело?».
Самая полезная команда для начала — попросить curl показать и заголовки, и тело:
# -i: вывести заголовки ответа вместе с телом
curl -i http://localhost:8080/health
Ключ -i означает: «покажи response headers». Пример ожидаемого результата может выглядеть так (формат слегка зависит от версии и окружения, но смысл один):
HTTP/1.1 200 OK
Date: Thu, 19 Mar 2026 12:00:00 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 45
{"status":"UP","appName":"readlater-starter"}
Здесь нас интересуют две вещи. Первая — статус 200 OK. Вторая — Content-Type: application/json; charset=utf-8. Да, даже на /health. Потому что мы заранее договорились: наш локальный API общается в JSON, и даже технический endpoint придерживается того же стиля. Это не прихоть — это дисциплина контракта.
Если вы хотите отдельно проверить только status code (например, когда тело большое, а вы не хотите его каждый раз читать), можно попросить curl вывести только код:
# -s: без прогресс-бара, -o /dev/null: не печатать тело, -w: вывести только код статуса
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/health
Если всё хорошо, вы увидите:
200
И это приятно. (Тут можно на секунду улыбнуться и продолжить.)
Иногда полезно посмотреть запрос/ответ чуть более «болтливо», чтобы понять, дошли ли мы вообще до сервера и что происходило по пути:
# -v: подробный вывод (соединение и технические детали запроса/ответа)
curl -v http://localhost:8080/health
Флаг -v (verbose) покажет детали соединения. Если у вас проблема уровня «сервер не слушает порт», вы увидите это прямо в выводе.
Отдельная микропроверка, которая часто помогает заметить забытый Content-Type, — попросить вывести только заголовки:
curl -I http://localhost:8080/health
Это делает HEAD‑запрос, а не GET. И здесь важный момент: в рамках нашего дня мы фокусируемся на GET /health, так что -I полезен именно как быстрый просмотр заголовков, но не как «контрактная проверка метода». Если вы пока не обрабатывали HEAD, не удивляйтесь, что поведение отличается. Не усложняйте: для «правильной» проверки остаёмся на GET через curl -i.
4. Проверка через Postman
Postman в этом курсе — не просто «кнопка Send». Это ваша привычка фиксировать контракт и smoke‑сценарий так, чтобы вы могли повторить проверку через неделю, не вспоминая, какой был порт и какой путь. И да, звучит как «лишняя бюрократия», пока проект маленький. Но ровно эта бюрократия потом экономит часы.
Здесь есть два принципа, которые делают Postman‑проверку взрослой, а не случайной:
Первый принцип — использовать переменную окружения для baseUrl. Например, завести environment Local и переменную:
baseUrl = http://localhost:8080
А запрос хранить как:
GET {{baseUrl}}/health
Тогда, если вы поменяли порт в application.properties или через env var, вы не правите 10 запросов — вы правите одно значение.
Второй принцип — сохранить запрос в коллекцию. Например, в коллекцию ReadLater Local API и папку Tech. Названия не должны быть идеальными, но они должны быть понятными будущему вам. «New Request 47» — это всегда ловушка: сегодня вы помните, что это /health, а через пару дней у вас будет «археология Postman».
Когда вы отправляете запрос, проверьте глазами три вещи, как мы и договорились: статус (в Postman он виден сверху), заголовки (Headers в ответе) и JSON body. В идеале body будет сразу красиво форматирован как JSON. Если нет — это намёк, что Content-Type не application/json, и Postman не понял, что это JSON.
Есть маленький психологический трюк: в Postman удобно держать вкладку Tests пустой и не превращать её в тестовый фреймворк (мы не в testing‑курсе). Но можно сделать себе «ручной чек»: один взгляд на статус, один — на Content-Type, один — на body. Через неделю вы будете делать это автоматически. Как чистить зубы. Только вместо зубов — endpoint-ы.
5. Проверка через JDK HttpClient
HttpClient вы уже использовали в клиентской фазе проекта, когда ходили во внешний каталог. И это очень круто, потому что теперь вы можете использовать ровно тот же инструмент, чтобы проверить собственный локальный API. Это почти философски красиво: приложение сначала было клиентом к чужому API, а теперь у него появился свой маленький API, и мы умеем разговаривать с ним на том же «языке».
Важная оговорка: мы сейчас не строим тестовую инфраструктуру и не пишем полноценные автотесты. Мы делаем микро‑самопроверку, которую можно запустить вручную, когда у вас что-то не отвечает, а Postman «вроде бы показывает странное».
Вот минимальный пример запроса к /health через HttpClient. Представьте, что это маленький отдельный класс (или временный метод), который вы запускаете вручную.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// Создаём клиента один раз (для микро-проверки достаточно дефолтного)
HttpClient client = HttpClient.newHttpClient();
// Собираем GET-запрос на наш локальный endpoint /health
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/health"))
.GET()
.build();
// Отправляем запрос и получаем ответ строкой (нам нужно прочитать body глазами)
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
// Минимальная контрактная проверка: статус должен быть 200
System.out.println("status=" + response.statusCode()); // status=200
Этот кусочек уже даёт главное: статус. Но по нашему контрактному подходу хочется проверить и Content-Type, и кусочек body. Сделаем компактную «проверку здравого смысла», не превращая её в лабораторную по тестированию:
import java.util.Optional;
// Достаём Content-Type (может отсутствовать, поэтому Optional)
Optional<String> contentType = response.headers()
.firstValue("Content-Type");
// Проверяем три части контракта: статус, заголовок и содержимое body
boolean ok = response.statusCode() == 200
&& contentType.orElse("").contains("application/json") // допускаем charset в конце
&& response.body().contains("\"status\":\"UP\""); // быстрый smoke-чек по body
System.out.println("health ok=" + ok); // health ok=true
Если ok=false, вы сразу понимаете, что именно не совпало: статус, заголовок или body. И вот здесь наступает приятный момент backend‑мышления: вы больше не «чувствуете», что что-то не так. Вы знаете, какая часть контракта сломалась.
Если вы уже используете SLF4J в проекте (а вы его используете), можно заменить println на логирование, но в этом микро‑фрагменте println допустим как локальный диагностический вывод. Главное — не превращать это в основной operational‑инструмент приложения. Дневник — это хорошо, но строить по нему самолёт — сомнительно.
6. Диагностика проблем с /health
Когда /health не работает, у новичка часто включается режим «сломался код handler-a». Это понятная реакция: код же только что писали, значит он виноват. Но в server‑мире есть два больших класса проблем, и лечатся они по-разному. Если вы научитесь разделять их в голове, вы сэкономите себе много нервов.
Первый класс — сервер не поднялся или недоступен по адресу. Симптомы обычно такие: браузер не открывает страницу, curl пишет Connection refused, HttpClient бросает ConnectException. Тут нет смысла смотреть на JSON, потому что вы даже не дошли до HTTP‑ответа.
Второй класс — сервер поднялся, но /health отвечает не так, как мы договорились. Симптомы: статус не 200, или Content-Type не application/json, или body пустой/не JSON. Тут уже нужно смотреть на handler и на то, как формируется ответ.
Чтобы эти два класса проблем не путались в голове, полезно иметь короткую блок‑схему. Не потому что «мы любим диаграммы», а потому что мозг любит чёткие развилки.
flowchart TD
A["Делаем GET /health"] --> B{"Есть ответ (HTTP response)?"}
B -- "Нет" --> C["Проверяем startup-log: host/port"]
C --> D["Проверяем порт занят / правильный порт / правильный host"]
B -- "Да" --> E{"status == 200?"}
E -- "Нет" --> F["Смотрим handler: sendResponseHeaders, исключения, context /health"]
E -- "Да" --> G{"Content-Type содержит application/json?"}
G -- "Нет" --> H["Проверяем exchange.getResponseHeaders().set(...)"]
G -- "Да" --> I{"Body содержит status=UP?"}
I -- "Нет" --> J["Проверяем ObjectMapper, запись в responseBody, закрытие потока"]
I -- "Да" --> K["Готово: /health работает по контракту"]
Теперь — как применять это на практике, не превращая жизнь в чтение схем.
Когда «ответа нет»: сверяемся со startup‑логом и адресом
Если вы видите Connection refused, первое, что вы делаете — не открываете IDE и не переписываете handler. Вы смотрите в лог старта сервера. Он должен быть примерно такого вида:
Local API started: http://localhost:8080, appName=readlater-starter
Если в логе 8081, а вы стучитесь в 8080 — всё, диагностика закончилась за 3 секунды.
Если порт верный, но ответа нет, есть классическая причина: порт занят. Тогда на старте сервера вы обычно получите BindException (например, «Address already in use»). И это не проблема /health, это проблема того, что вы пытаетесь слушать порт, который уже занят другим процессом. Иногда этим процессом бывает ваш же сервер, который вы забыли остановить. И да, это та самая реальность разработки, где вы иногда боретесь не с кодом, а с собой из прошлого, который «оставил сервер работать и ушёл пить чай».
Когда «ответ есть, но не тот»: context, status и заголовки
Если curl -i показывает, что статус не 200, это значит, что handler либо не отработал как надо, либо отработал и отправил другой статус, либо вообще не тот handler поймали.
Самая частая причина на нашем уровне — неправильно зарегистрировали context. Например, в коде написали:
// Внимание: опечатка в пути — handler будет висеть на /heath, а не на /health
server.createContext("/heath", exchange -> handleHealth(exchange, config)); // опечатка
а в запросе вы стучитесь в /health. Сервер в таком случае может вернуть 404 (или что-то дефолтное), и вы будете смотреть на handler и думать «почему же он не вызывается».
Ещё одна частая причина — забыли выставить Content-Type. Тогда статус может быть 200, body — JSON, но клиент (и Postman) будут вести себя странно, потому что по контракту это «текст», а не JSON.
И, наконец, бывает «тишина» из-за того, что вы не закрыли response body stream. В вашем коде правильно делать это через try-with-resources:
// try-with-resources гарантирует закрытие потока и "доталкивание" ответа клиенту
try (OutputStream os = exchange.getResponseBody()) {
os.write(body); // пишем байты body в ответ
}
Если поток не закрывать, поведение может стать нестабильным: ответ может не уйти полностью или клиент будет ждать «конец ответа» дольше, чем вы ожидаете.
Минимальный request‑лог: проверяем, что запрос дошёл
Есть очень простой приём, который почти всегда помогает: в начале handler-a залогировать метод и путь. Это не про «наблюдаемость продакшна», это про «чтобы вы не гадали».
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// Отдельный логгер под входящие HTTP-запросы, чтобы в логах было легче искать
private static final Logger log =
LoggerFactory.getLogger("HttpRequests");
// Логируем минимум: метод и путь (этого достаточно для диагностики "доходит/не доходит")
log.info("Incoming request: {} {}",
exchange.getRequestMethod(),
exchange.getRequestURI().getPath());
Если вы видите в логах эту строку — запрос дошёл до handler-a. Если не видите — значит, проблема на уровне адреса/контекста, а не на уровне JSON‑сериализации.
7. Типичные ошибки проверки /health
Ошибка №1: считать успехом «процесс запустился», а не «endpoint отвечает».
Очень легко перепутать «я вижу лог Server started» с «API работает». Но клиенту абсолютно всё равно на ваш лог — он живёт в мире request/response. Поэтому всегда делайте реальный HTTP‑запрос: браузер, curl, Postman или HttpClient. Любой. Но настоящий.
Ошибка №2: проверять только body и игнорировать статус и заголовки.
Новички часто смотрят только на JSON в ответе и радуются: «Ну вот же, статус UP». А потом оказывается, что статус 500, а JSON — это просто кусок текста, который успел записаться до ошибки. Или Content-Type забыли, и часть клиентов начинает воспринимать ответ как обычную строку. Три элемента контракта — status, headers, body — живут вместе.
Ошибка №3: стучаться не на тот host/port и чинить handler вместо конфигурации.
Это классика: вы поменяли server.port в application.properties, сервер честно поднялся на новом порту, лог это подтвердил, а вы по привычке продолжаете ходить на старый адрес. Через 10 минут вы уже готовы «переписать сервер», хотя достаточно просто посмотреть на startup‑лог и поверить ему.
Ошибка №4: забыть, что http:// и https:// — разные миры.
На локальном HttpServer в этом курсе вы поднимаете обычный HTTP, без TLS. Поэтому запросы должны быть вида http://localhost:8080/health. Если вы по привычке пишете https://..., клиенты могут выдавать странные ошибки, и это будет выглядеть как «сервер сломан». На самом деле вы просто постучались в дверь не тем ключом.
Ошибка №5: не отличать «сервер недоступен» от «endpoint отвечает неправильно».
Connection refused — это не «неправильный JSON». Это «нет соединения». Это разные классы проблем, и лечатся они по-разному. В первом случае вы проверяете startup‑лог, порт, занят ли он, и жив ли процесс. Во втором — вы проверяете context, handler, sendResponseHeaders, Content-Type и запись body.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ