1. Роль health рядом с web smoke
Когда вы впервые слышите «давайте тестировать /actuator/health», это легко воспринять как «ещё один endpoint, ну и ладно». Но health — это не обычная ручка API и не «ещё один JSON». Это формальный сигнал о состоянии сервиса: что он не просто запустился, а готов быть полезным. Если web smoke отвечает на вопрос «сервер вообще отвечает?», то health отвечает «он отвечает в здравом уме?».
В catalog-service это особенно заметно, потому что наш сервис read-only и живёт на данных из конфигурации. Теоретически приложение может запуститься, контроллеры могут быть доступны, но по факту сервис может быть «пустым» или «сомнительным»: например, каталог загрузился без опубликованных курсов, или есть противоречивые данные. Пользователю от такого «живого, но бесполезного» сервиса не легче.
Именно поэтому мы в проекте делали CatalogDataHealthIndicator: он проверяет не фреймворк и не сеть, а доменно понятные условия. В нашей формулировке сервис «здоров», когда каталог загрузился, есть хотя бы один опубликованный курс, и нет дублирующихся slug. Это похоже на проверку «в машине есть двигатель, бензин и колёса не отваливаются» — не гарантия, что вы доедете в отпуск, но без этого точно никуда.
Важно ещё и то, что health — это та вещь, которая в реальности часто используется автоматикой: мониторингом, балансировщиком, оркестраторами. Мы не уходим в Kubernetes и production hardening (это отдельные дисциплины), но мыслить «health как контракт» полезно уже сейчас — чтобы сервис был не только учебным, но и инженерно вменяемым.
2. Модель: HealthIndicator → Health → endpoint
Чтобы тестировать health уверенно, нужно не гадать, а понимать цепочку: кто что вызывает и где появляется JSON. В Spring Boot Actuator есть слой индикаторов, которые возвращают объект Health (внутри статус и детали), и есть слой endpoint'а, который собирает результаты разных индикаторов и отдаёт наружу итоговый ответ. Если держать эту схему в голове, вы легко поймёте, что именно вы тестируете в каждом тесте.
Упрощённо это можно представить так:
flowchart TD
A[GET /actuator/health] --> B[Actuator Health Endpoint]
B --> C[DiskSpaceHealthIndicator]
B --> D[CatalogDataHealthIndicator]
C --> E[Health: UP/DOWN + details]
D --> F[Health: UP/DOWN + details]
E --> G[Aggregated Health]
F --> G
G --> H[JSON response]
Здесь есть три «уровня реальности»:
1) Наш индикатор (CatalogDataHealthIndicator) — это чистая логика, которую мы написали сами. Он возвращает Health, обычно через builder Health.up() или Health.down().
2) Агрегатор Actuator — это фреймворк-часть, которая собирает вклад разных индикаторов и делает общий статус. Мы не обязаны тестировать «правильность агрегатора» — это задача Spring Boot, он и так этим занимается много лет.
3) Web-представление /actuator/health — это то, что видит внешний мир. Здесь вступают в игру настройки экспозиции endpoint’ов, base path, а также настройки вроде «показывать ли details».
Полезно зафиксировать маленькую таблицу, чтобы не путать слои:
| Что проверяем | Где это живёт | Что означает падение |
|---|---|---|
| indicator.health() возвращает UP | в нашем коде | сломана доменная логика health или зависимость индикатора |
| GET /actuator/health отвечает и содержит UP | в web-слое Actuator | endpoint не поднялся/не экспонирован/сломалась интеграция или общий health стал не UP |
Отдельный маленький нюанс, который часто удивляет новичков: Status в Actuator — это не «какая-то строка», а объект (например, Status.UP). А вот JSON в endpoint’е обычно содержит строковое значение "UP". Поэтому в коде вы сравниваете Status.UP, а в JSON-проверке — строку "UP".
3. Тест индикатора напрямую
Самая чистая часть health-тестирования — проверять индикатор напрямую, минуя HTTP, MockMvc и формат JSON. Это почти идеальный «инженерный компромисс»: тест остаётся Boot-centric (мы поднимаем контекст, получаем реальный бин), но проверяем то, что принадлежит нам — результат health().
Начнём с минимальной заготовки теста, где мы просто поднимаем контекст и инжектим наш индикатор.
Файл: src/test/java/com/example/catalogservice/actuator/CatalogDataHealthIndicatorTest.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest // Поднимаем Spring-контекст, чтобы получить реальные бины
class CatalogDataHealthIndicatorTest {
@Autowired
CatalogDataHealthIndicator indicator; // Тестируем именно наш HealthIndicator
}
Теперь добавим самый простой позитивный сценарий: при нормальной конфигурации проекта индикатор должен говорить UP. Это тест не о том, сколько именно курсов у вас в конфиге, не о том, в каком порядке они лежат и не о том, какие поля сериализуются в JSON. Это тест о базовой гарантии: «каталог в порядке».
Файл: CatalogDataHealthIndicatorTest.java (фрагмент)
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Status;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Test
void reportsUpForValidCatalog() {
// Проверяем доменный итог: сервис "готов", значит статус должен быть UP
assertEquals(Status.UP, indicator.health().getStatus());
}
Обратите внимание на стиль assertions: мы сравниваем Status.UP, а не "UP". Это важная мелочь: вы тестируете Java-логику и контракт API Actuator, а не JSON-представление.
Про Health: что стоит проверять
Когда вы вызываете indicator.health(), вы получаете Health. У него есть getStatus() и getDetails(). В details мы часто кладём полезные доменные подсказки: сколько опубликованных курсов, есть ли дубли, загружен ли каталог. Это удобно для диагностики, но тут есть ловушка: если вы начнёте проверять все details до последнего ключа, тест станет хрупким.
Хороший подход для начинающего — либо ограничиться статусом, либо проверять один-два стабильных факта. Например: «в details есть ключ publishedCourses и значение больше нуля». Это не привязывает тест к точному числу курсов и не заставляет вас править тест каждый раз, когда вы добавили ещё один курс в конфиг.
Файл: CatalogDataHealthIndicatorTest.java (фрагмент)
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Health;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Test
void includesDetailsWeOwn() {
Health health = indicator.health(); // Вызываем индикатор напрямую, без HTTP-слоя
// Детали — это диагностическая информация, которую мы "владеем" в рамках индикатора
assertTrue(health.getDetails().containsKey("publishedCourses")); // ключ есть
}
Если у вас в CatalogDataHealthIndicator нет такого detail-ключа — ничего страшного, этот фрагмент просто показывает идею. Смысл в том, что проверять стоит те «детали», которые вы считаете частью контракта вашего индикатора, а не все внутренности подряд.
Почему же полезно иметь и тест индикатора напрямую, и endpoint-смоук? Потому что если тест на indicator.health() упадёт, вы мгновенно понимаете область проблемы: это ваша логика health, или зависимость, на которую она опирается. Вам не нужно гадать «это MockMvc? это сериализация? это base-path? это exposure policy?». Вы смотрите на индикатор, и точка.
Это похоже на диагностику машины: если вы проверили аккумулятор отдельно и он мёртв, вам не нужно проверять радио, фары и дворники. Да, всё не работает — но причина локализована.
4. Smoke-тест /actuator/health через MockMvc
Проверить индикатор напрямую — отлично, но этого недостаточно, чтобы быть уверенным в «операционной поверхности» сервиса. Можно случайно отключить Actuator, можно закрыть endpoint настройками exposure, можно поменять base path или порт управления. В итоге индикатор может быть идеальным, но внешний мир его никогда не увидит. Поэтому мы добавляем второй тест: лёгкий endpoint smoke.
Начнём с подготовки теста с MockMvc. Здесь всё знакомо по предыдущей лекции: @SpringBootTest + @AutoConfigureMockMvc, и мы получаем объект MockMvc, чтобы вызвать endpoint без реальной сети.
Файл: src/test/java/com/example/catalogservice/actuator/HealthEndpointSmokeTest.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest // Поднимаем контекст приложения
@AutoConfigureMockMvc // Включаем MockMvc, чтобы дергать endpoint'ы без реального HTTP-сервера
class HealthEndpointSmokeTest {
@Autowired
MockMvc mockMvc; // Через него делаем GET /actuator/health
}
Теперь сам тест. Здесь важно держать себя в руках (в хорошем смысле): мы не делаем «полный снимок JSON» и не сравниваем его целиком. Для smoke достаточно проверить, что endpoint доступен и что общий статус "UP".
Файл: HealthEndpointSmokeTest.java (фрагмент)
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void exposesHealthEndpoint() throws Exception {
mockMvc.perform(get("/actuator/health")) // Дергаем actuator endpoint так же, как это сделает внешний мир
.andExpect(status().isOk()) // В здоровом состоянии обычно 200 OK
.andExpect(jsonPath("$.status").value("UP")); // Здесь проверяем именно JSON-представление, поэтому строка
}
Здесь мы сравниваем строку "UP", потому что тестируем JSON-представление endpoint’а. И это нормально: индикатор тестируется через Status.UP, endpoint — через "UP".
jsonPath("$.status") вместо contains("UP")
В прошлых примерах часто делают body.contains("UP"), и для совсем первого знакомства это окей. Но как только в ответе появятся дополнительные поля или вы добавите health components, строковый поиск может стать менее надёжным. jsonPath("$.status") говорит прямо: «меня интересует конкретное поле status», и тест остаётся читаемым.
При этом мы всё равно не делаем тест хрупким: мы проверяем только один ключевой сигнал, а не всю структуру.
Нюанс про HTTP-статус и 200 OK
В здоровом состоянии сервиса общий health обычно UP, и Actuator отвечает 200. Если бы общий health стал DOWN, то Actuator может вернуть 503 Service Unavailable (и это, кстати, разумно: «я жив, но я не готов»). Мы не строим сейчас сложную матрицу негативных сценариев, но важно помнить: не всегда health = 200. Зависит от общего статуса и настроек.
5. Границы health-тестов
Любой тестовый набор умирает не от недостатка тестов, а от их «неуправляемого расползания». Health-тесты особенно склонны к этому: вы открываете /actuator/health, видите красивый JSON, и рука тянется проверить всё: disk space, uptime, компоненты, детали… И вот вы уже тестируете то, что не вы писали, и то, что может меняться от окружения к окружению.
Самый здоровый (простите за каламбур) подход — помнить границу ответственности. Наши тесты должны отвечать на два конкретных вопроса. Первый вопрос: «Наш CatalogDataHealthIndicator действительно корректно определяет состояние каталога?». Второй вопрос: «Endpoint /actuator/health действительно доступен и отдаёт общий статус UP?». Всё, что глубже, либо относится к отдельному курсу по тестированию, либо превращает тесты в тяжёлые и хрупкие.
Чтобы эта граница была совсем осязаемой, держите в голове простую расшифровку падений:
| Упал тест | Что это значит на практике | Самое вероятное место проблемы |
|---|---|---|
| CatalogDataHealthIndicatorTest | сломали правило здоровья каталога или его зависимость | код индикатора, сервис/репозиторий, конфигурация данных |
| HealthEndpointSmokeTest | endpoint недоступен или общий health не UP | exposure/base-path/actuator wiring или реальная проблема со здоровьем |
Ещё один тонкий момент: не пытайтесь делать health-тест «про бизнес-логику каталога». Если вы в health-тесте начинаете проверять фильтрацию курсов, обработку query params и прочие вещи — вы смешиваете уровни. Health не должен быть вторым API для каталога, и тест health не должен превращаться в альтернативный тест контроллера.
И наконец, если вам очень хочется протестировать негативный сценарий health (например, «нет опубликованных курсов ⇒ DOWN»), делайте это максимально локально и понятно. В идеале вы создаёте в тесте контролируемые условия (например, подсовываете индикатору тестовые данные через зависимость) и проверяете только статус. Но на уровне этого курса мы держимся минимального набора: один позитивный тест на индикатор и один smoke на endpoint — уже отличный базовый слой уверенности.
6. Типичные ошибки при health-тестах
Ошибка №1: проверять только /actuator/health и игнорировать сам CatalogDataHealthIndicator.
Если вы тестируете только endpoint, вы не различаете две ситуации: «ваша логика health сломана» и «endpoint не экспонирован/не подключен». В результате при падении теста вы получаете более размытый сигнал и начинаете искать проблему по всему проекту. Прямая проверка indicator.health() делает причину падения гораздо более локальной и понятной.
Ошибка №2: сравнивать весь JSON ответа /actuator/health целиком.
Полный JSON легко меняется: могут добавиться компоненты, поменяться детали, появиться новые индикаторы от Boot, или измениться конфигурация show-details. Если вы сравниваете весь ответ строкой, тест превращается в «сторожевого пса» против любых изменений, даже полезных. Гораздо устойчивее проверять только $.status и, максимум, один-два стабильных элемента, которые вы действительно считаете контрактом.
Ошибка №3: пытаться тестировать через health бизнес-логику каталога.
Health — это сигнал «готов/не готов», а не «список курсов корректно фильтруется по track». Как только в health-тестах появляются проверки фильтрации, сортировки или формы ответа каталога, вы смешиваете responsibilities. Это почти всегда приводит к тому, что тесты становятся перегруженными и начинают падать «не по адресу».
Ошибка №4: ожидать, что health всегда возвращает 200 OK.
В состоянии UP — да, обычно 200. Но как только общий статус становится DOWN, Actuator может отвечать 503. Если вы когда-нибудь будете добавлять негативные сценарии, не делайте проверку «всегда должен быть 200». Проверяйте то, что вы действительно хотите: статус в JSON или ожидаемый HTTP-статус для конкретного состояния.
Ошибка №5: проверять детали health endpoint'а, не контролируя настройки show-details.
Даже если ваш индикатор кладёт полезные детали в Health, endpoint может их не показать (и часто так и нужно, особенно вне local/dev). Поэтому проверка деталей через /actuator/health может быть нестабильной и зависящей от профиля. Если вам очень нужны детали — проверяйте их напрямую через indicator.health().getDetails() и считайте это контрактом вашего индикатора, а не web-ответа.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ