1. Критерії smoke-перевірки локального API
Коли ви вперше запускаєте сервер, мозок радісно кричить: «Ура, процес живий, отже API готове!» — і тут же хоче перейти до наступної теми. Але бекенд-реальність жорстока: процес може працювати, а HTTP-контракт при цьому бути мертвим, дивним або відповідати взагалі не на той шлях. Тому ми вводимо просте правило: результат дня вимірюється запитом і відповіддю, а не фактом запуску.
Контракт /health уже реалізовано на боці сервера. Тепер важливо не вірити коду на слово, а перевірити його очима клієнта: код статусу, заголовки й тіло відповіді мають збігатися з тим, що ми обіцяли.
У нашому випадку smoke-перевірка має відповісти на запитання: «Якщо я надішлю GET /health, чи отримаю я передбачувану відповідь, яку можна прочитати очима й перевірити інструментом?» Тут важливо дивитися на три частини відповіді разом: код статусу, заголовки і тіло відповіді. Якщо перевіряти тільки тіло, ви легко пропустите ситуацію, коли сервер повертає JSON, але з неправильним Content-Type, або відповідає 500, хоча в тілі все одно «щось схоже на JSON». Якщо перевіряти тільки статус, можна отримати 200 OK з порожнім тілом через помилку під час запису.
Щоб було простіше тримати критерії в голові, корисно мати маленьку «пам’ятку» — не для іспиту, а для життя.
| Що перевіряємо | Очікування для /health | Де видно найкраще | Якщо не так — що це зазвичай означає |
|---|---|---|---|
| Доступність за адресою | http://host:port/health відкривається | браузер / curl / Postman | сервер не запустився, порт не той, порт зайнято, неправильний host |
| HTTP-метод | ми робимо GET | curl -v, Postman, HttpClient | не той метод (або сервер очікує інший, але це вже баг контракту) |
| Status code | 200 | curl -i, Postman, HttpClient | обробник не спрацював, виняток, некоректна реєстрація context |
| Content-Type | application/json | curl -i, Postman | забули заголовок або записали «як вийшло» |
| JSON body | {"status":"UP","appName":"..."} | браузер (грубо), Postman, HttpClient | не серіалізували DTO, некоректне кодування, не записали body |
Якщо ця smoke-перевірка проходить, ми довели лише каркас: сервер уміє прийняти запит і віддати JSON. Щойно шляхів стане більше ніж один, доведеться вже вручну розрізняти метод, шлях, query-параметри і body.
Зверніть увагу: це не «тестування» в сенсі окремої дисципліни. Це дисципліна перевірки контракту. Рівно те саме ви будете робити в продакшн-житті, тільки замість /health буде «чому в клієнтів 502», замість curl — лог-агрегатор, а замість спокою — кава, яку ви п’єте не тому, що хочеться, а тому, що так треба.
2. Перевірка через браузер
Браузер — це найшвидший спосіб відчути, чи сервер взагалі відповідає, чи ви розмовляєте з розеткою. І так, він зручний: ви просто відкрили вкладку, ввели адресу й побачили відповідь. Але браузер, як будь-який «зручний інструмент», частину деталей ховає — саме ті, які бекенд-розробнику часто важливі. Тому браузер хороший як перший крок, але поганий як єдиний.
Якщо сервер у нас піднято на localhost:8080, ми відкриваємо:
http://localhost:8080/health
І очікуємо побачити JSON, приблизно такий:
{"status":"UP","appName":"readlater-starter"}
Якщо ви побачили щось схоже на JSON — це вже непогано. Якщо браузер пише This site can’t be reached або ERR_CONNECTION_REFUSED, це означає, що до HTTP-відповіді ми навіть не дійшли. Тут найчастіше винен не обробник, а адреса: сервер не запущено, порт інший, порт зайнято або ви запускаєте сервер на одному host, а звертаєтеся до іншого.
Щоб мозок не починав магічно «лагодити код», корисно зробити коротку паузу й поставити собі запитання: «Я точно стукаю туди, куди сервер сказав у startup-лозі?» Це звучить як капітан Очевидність, але саме такі капітани рятують проєкти.
Якщо хочеться побачити більше деталей прямо в браузері, можна відкрити DevTools → вкладку Network і подивитися код статусу та response headers. Це вже майже дорослий підхід, але в навчальному сценарії простіше й швидше перейти до curl або Postman: там деталі видно одразу, без танців по вкладках.
3. Перевірка через curl
curl — це як ліхтарик для HTTP-контракту: некрасиво, зате чесно. Він не намагається «допомогти» вам красивою сторінкою й не ховає заголовки, якщо ви явно попросили їх показати. Для бекенд-розробника це один із найкращих інструментів для 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, і навіть технічна кінцева точка дотримується того самого стилю. Це не примха — це дисципліна контракту.
Якщо ви хочете окремо перевірити лише код статусу, наприклад коли тіло велике, а ви не хочете читати його щоразу, можна попросити 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 порожньою і не перетворювати її на тестовий фреймворк (ми не на курсі з тестування). Але можна зробити собі «ручну перевірку»: один погляд на статус, один — на Content-Type, один — на body. За тиждень ви будете робити це автоматично. Як чистити зуби. Тільки замість зубів — кінцеві точки.
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-запит на нашу локальну кінцеву точку /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. І ось тут настає приємний момент бекенд-мислення: ви більше не «відчуваєте», що щось не так. Ви знаєте, яка частина контракту зламалася.
Якщо ви вже використовуєте SLF4J у проєкті (а ви його використовуєте), можна замінити println на логування, але в цьому мікрофрагменті println допустимий як локальний діагностичний вивід. Головне — не перетворювати його на основний інструмент щоденної роботи застосунку. Щоденник — це добре, але будувати за ним літак — сумнівно.
6. Діагностика проблем із /health
Коли /health не працює, у новачка часто вмикається режим «зламався код обробника». Це зрозуміла реакція: код же тільки-но писали, отже він винен. Але у серверному світі є два великі класи проблем, і лікуються вони по-різному. Якщо ви навчитеся розділяти їх у голові, ви збережете собі багато нервів.
Перший клас — сервер не запущено або він недоступний за адресою. Симптоми зазвичай такі: браузер не відкриває сторінку, curl пише Connection refused, HttpClient кидає ConnectException. Тут немає сенсу дивитися на JSON, тому що ви навіть не дійшли до HTTP-відповіді.
Другий клас — сервер запущено, але /health відповідає не так, як ми домовилися. Симптоми: статус не 200, або Content-Type не application/json, або body порожній чи не JSON. Тут уже потрібно дивитися на обробник і на те, як формується відповідь.
Щоб ці два класи проблем не плуталися в голові, корисно мати коротку блок-схему. Не тому, що «ми любимо діаграми», а тому, що мозок любить чіткі розгалуження.
flowchart TD
A["Виконуємо GET /health"] --> B{"Є відповідь (HTTP-відповідь)?"}
B -- "Ні" --> C["Перевіряємо startup-лог: host/port"]
C --> D["Перевіряємо, чи правильний порт, чи не зайнято його і чи правильний host"]
B -- "Так" --> E{"status == 200?"}
E -- "Ні" --> F["Дивимося на обробник: sendResponseHeaders, винятки, context /health"]
E -- "Так" --> G{"Content-Type містить application/json?"}
G -- "Ні" --> H["Перевіряємо exchange.getResponseHeaders().set(...)"]
G -- "Так" --> I{"Тіло містить status=UP?"}
I -- "Ні" --> J["Перевіряємо ObjectMapper, запис у responseBody, закриття потоку"]
I -- "Так" --> K["Готово: /health працює за контрактом"]
Тепер — як застосовувати це на практиці, не перетворюючи життя на читання схем.
Коли «відповіді немає»: звіряємося зі startup-логом і адресою
Якщо ви бачите Connection refused, перше, що ви робите, — не відкриваєте IDE і не переписуєте обробник. Ви дивитеся в лог запуску сервера. Він має бути приблизно такого вигляду:
Локальний API запущено: http://localhost:8080, appName=readlater-starter
Якщо в логу 8081, а ви стукаєте в 8080 — усе, діагностика закінчилася за 3 секунди.
Якщо порт правильний, але відповіді немає, є класична причина: порт зайнято. Тоді під час запуску сервера ви зазвичай отримаєте BindException (наприклад, «Address already in use»). І це не проблема /health, це проблема того, що ви намагаєтеся слухати порт, який уже зайняв інший процес. Інколи цим процесом буває ваш же сервер, який ви забули зупинити. І так, це та сама розробницька реальність, де ви іноді боретеся не з кодом, а із собою з минулого, який «залишив сервер працювати й пішов пити чай».
Коли «відповідь є, але не та»: context, статус і заголовки
Якщо curl -i показує, що статус не 200, це означає, що обробник або не спрацював як треба, або відпрацював і надіслав інший статус, або ви взагалі потрапили не в той обробник.
Найчастіша причина на нашому рівні — неправильно зареєстрували context. Наприклад, у коді написали:
// Увага: помилка в шляху — обробник висітиме на /heath, а не на /health
server.createContext("/heath", exchange -> handleHealth(exchange, config)); // помилка
а в запиті ви стукаєте в /health. Сервер у такому разі може повернути 404 (або щось дефолтне), і ви будете дивитися на обробник та думати: «Чому ж він не викликається?».
Ще одна часта причина — забули виставити Content-Type. Тоді статус може бути 200, body — JSON, але клієнт (і Postman) поводитимуться дивно, бо за контрактом це «текст», а не JSON.
І, нарешті, буває тиша через те, що ви не закрили stream тіла відповіді. У коді правильно робити це через try-with-resources:
// try-with-resources гарантує закриття потоку і надсилання відповіді клієнту
try (OutputStream os = exchange.getResponseBody()) {
os.write(body); // пишемо байти body у відповідь
}
Якщо потік не закривати, поведінка може стати нестабільною: відповідь може не піти повністю або клієнт чекатиме кінець відповіді довше, ніж ви очікуєте.
Мінімальний request-лог: перевіряємо, що запит дійшов
Є дуже простий прийом, який майже завжди допомагає: на початку обробника залогувати метод і шлях. Це не про спостережуваність продакшну, це про те, щоб ви не гадали.
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());
Якщо ви бачите в логах цей рядок — запит дійшов до обробника. Якщо не бачите — отже, проблема на рівні адреси або контексту, а не на рівні JSON-серіалізації.
7. Типові помилки перевірки /health
Помилка № 1: вважати успіхом «процес запустився», а не «кінцева точка відповідає».
Дуже легко переплутати «я бачу лог Server started» із «API працює». Але клієнтові абсолютно байдуже на ваш лог — він живе у світі request/response. Тому завжди робіть реальний HTTP-запит: браузер, curl, Postman або HttpClient. Будь-який. Але справжній.
Помилка № 2: перевіряти лише тіло й ігнорувати статус і заголовки.
Новачки часто дивляться тільки на JSON у відповіді й радіють: «Ну ось же, статус UP». А потім виявляється, що статус 500, а JSON — це просто шматок тексту, який устиг записатися до помилки. Або Content-Type забули, і частина клієнтів починає сприймати відповідь як звичайний рядок. Три елементи контракту — status, headers, body — живуть разом.
Помилка № 3: стукатися не на той host/port і лагодити обробник замість конфігурації.
Це класика: ви змінили server.port у application.properties, сервер чесно запустився на новому порту, лог це підтвердив, а ви за звичкою продовжуєте ходити на стару адресу. Через 10 хвилин ви вже готові «переписати сервер», хоча достатньо просто подивитися на startup-лог і довіритися йому.
Помилка № 4: забути, що http:// і https:// — різні світи.
На локальному HttpServer у цьому курсі ви піднімаєте звичайний HTTP, без TLS. Тому запити мають бути виду http://localhost:8080/health. Якщо ви за звичкою пишете https://..., клієнти можуть видавати дивні помилки, і це виглядатиме як «сервер зламано». Насправді ви просто постукали в двері не тим ключем.
Помилка № 5: не відрізняти «сервер недоступний» від «кінцева точка відповідає неправильно».
Connection refused — це не «неправильний JSON». Це «немає з’єднання». Це різні класи проблем, і лікуються вони по-різному. У першому випадку ви перевіряєте startup-лог, порт, чи він не зайнятий, і чи живий процес. У другому — перевіряєте context, обробник, sendResponseHeaders, Content-Type і запис body.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ