1. Роль RestClientModerationClient у тестах
Коли в застосунку з’являється зовнішній HTTP-виклик, дуже хочеться перевірити все одним великим інтеграційним тестом: підняти @SpringBootTest, прогнати сценарій публікації — і нехай усе якось зійдеться само. Проблема в тому, що якщо в такому тесті щось ламається, ви отримуєте запитання на мільйон: «Зламалася бізнес-логіка? Конфігурація? Серіалізація? Безпека? Чи зовнішній JSON раптово змінився?». І далі ходите з цим запитанням по офісу або по своїй кухні, наче з котом, який на вас образився, але ви не знаєте чому.
Межа вже розвела дві відповідальності: сервіс ухвалює рішення через порт, а HTTP залишається всередині адаптера. Тепер треба перевірити самого перекладача — RestClientModerationClient. Далі це буде наш робочий орієнтир для outbound-блоку: ті самі request/response DTO, той самий baseUrl і той самий MockRestServiceServer.
Отже, у outbound-адаптера своя зона відповідальності та окрема перевірка. RestClientModerationClient має довести дві речі: що він формує правильний HTTP-запит (URL, метод, заголовки, тіло) і що вміє коректно розібрати зовнішню відповідь у зрозумілий домену результат. Бізнес-рішення на кшталт «при BLOCK переводимо статтю в REJECTED» живуть і тестуються в сервісі навколо порту, а коректність «HTTP → доменна модель» — це відповідальність адаптера.
Щоб тестувати адаптер без мережі, нам потрібен спеціальний режим: ми піднімаємо мінімальний Spring-контекст лише для клієнта й перехоплюємо його запити локальним сервером-заглушкою. Саме це й робить @RestClientTest у парі з MockRestServiceServer.
2. Можливості @RestClientTest
Якщо ви колись жили у світі «є @SpringBootTest, а решта — компроміси», то @RestClientTest спершу здається дивним. Але насправді це дуже чесна інженерна позиція: ми піднімаємо рівно стільки Spring, скільки потрібно, щоб перевірити HTTP-адаптер, і не тягнемо в тест увесь застосунок.
@RestClientTest — це test slice для клієнтської сторони. Він націлений на класи, які ходять назовні по HTTP (у нашому випадку — RestClientModerationClient). Усередині він вмикає автоконфігурацію, потрібну для RestClient, Jackson і тестових інструментів навколо HTTP. І найважливіше — він уміє підняти MockRestServiceServer, який перехопить запити та поверне вам задану відповідь, не звертаючись до реальної мережі.
Щоб було простіше втримати це в голові, давайте порівняємо три близькі режими:
| Режим тесту | Що перевіряємо | Що піднімається | Що не піднімається (і це плюс) |
|---|---|---|---|
| Unit-тест адаптера (без Spring) | Зазвичай небагато, бо RestClient справді живе у світі Spring | Нічого | Жодної автоконфігурації, зате багато ручної роботи |
| @RestClientTest | Зовнішній HTTP-контракт клієнта + мапінг відповіді | Вузький контекст для RestClient і MockRestServiceServer | Не тягнемо MVC, security, репозиторії та весь інший всесвіт |
| @SpringBootTest | Наскрізний сценарій (підключення + поведінка системи) | Повний контекст застосунку | Дорого, повільно, важче діагностувати, що саме зламалося |
Важливо чесно проговорити обмеження: @RestClientTest не доводить, що ваш сервісний шар правильно використовує порт у бізнес-сценарії. Він доводить, що конкретний HTTP-клієнт правильно розмовляє із зовнішнім контрактом і коректно переводить відповідь у доменні типи. Для ContentHub це ідеальний «серединний шар упевненості»: уже не чистий unit, але ще й не важкий full integration test.
3. Підміна HTTP через MockRestServiceServer
Слово «server» у назві MockRestServiceServer звучить так, ніби ми зараз будемо піднімати якийсь Tomcat, слухати порт і воювати з конфліктами. Хороша новина: ні. Дуже хороша. Прямо «з’їв печиво — і зрозумів, що життя знову має сенс».
MockRestServiceServer працює як перехоплювач запитів, які надсилає RestClient. Він не потребує реального мережевого з’єднання. Ви заздалегідь описуєте очікування: «очікую запит на такий-то URL, таким-то методом, з таким-то тілом», і задаєте відповідь: «поверни ось такий JSON». А потім викликаєте ваш адаптер як звичайно, і він «думає», що справді сходив назовні.
Ментальна модель може бути такою:
sequenceDiagram
participant A as RestClientModerationClient
participant R as RestClient
participant M as MockRestServiceServer
A->>R: POST https://moderation.local/api/check
R->>M: "перехоплення запиту всередині тесту"
M-->>R: 200 OK + тіло JSON
R-->>A: ModerationResponse
Тобто ми перевіряємо реальний код адаптера, але замість реального зовнішнього moderation service у нас «тренажер»: він віддає рівно ті відповіді, які ми хочемо протестувати. Це дає змогу зафіксувати зовнішній контракт тестами так само жорстко, як DTO JSON-контракт ми фіксували через @JsonTest, тільки тепер ідеться про контракт між сервісами (усередині однієї JVM, без мережі).
4. Базові моделі та RestClient-адаптер
Перед тим як писати тест, потрібно, щоб ми говорили про один і той самий код. Нижче — той самий робочий базовий варіант клієнта: окремий зовнішній DTO, RestClient.Builder і baseUrl в одному місці. Тут немає «магії» й немає бізнес-гілок; тільки те, що потрібно адаптеру: вхідний request, зовнішній response і доменне рішення.
Почнемо з порту та доменних типів:
import java.util.Objects;
// Порт (інтерфейс) — точка входу в інтеграцію для домену.
// Домену не важливо, як саме влаштований HTTP-виклик.
public interface ModerationClient {
ModerationDecision moderate(ModerationRequest request);
}
// Доменний запит: те, що ми хочемо перевірити у зовнішньому сервісі.
public record ModerationRequest(String title, String body) {
public ModerationRequest {
// Базовий захист від «порожніх» викликів: доменні дані не мають бути null.
Objects.requireNonNull(title);
Objects.requireNonNull(body);
}
}
// Доменне рішення: його використовуватиме бізнес-логіка (не HTTP-клієнт).
public record ModerationDecision(ModerationResult result, String reason) {
}
// Доменне перелічення: саме його має повернути адаптер після розбору відповіді.
public enum ModerationResult {
OK, WARN, BLOCK
}
Далі нам потрібен зовнішній payload. Зазвичай зручно тримати його як окремий тип, щоб не змішувати зовнішній JSON і внутрішні доменні рішення:
// DTO зовнішнього сервісу: структура відповідає JSON-контракту сервісу модерації.
public record ModerationResponse(ModerationResult result, String reason) {
// Явне перетворення «зовнішньої відповіді» в «доменне рішення».
ModerationDecision toDecision() {
return new ModerationDecision(result, reason);
}
}
Тепер сам адаптер. У реальному проєкті у вас буде більше нюансів (тайм-аути, обробка помилок, логування), але для лекції достатньо показати суть: сформувати POST-запит, надіслати ModerationRequest, прочитати ModerationResponse і повернути ModerationDecision.
import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;
public final class RestClientModerationClient implements ModerationClient {
private final RestClient restClient;
public RestClientModerationClient(RestClient.Builder builder, String baseUrl) {
// RestClient збирається через Builder, щоб Spring міг підкласти свою конфігурацію в тестовому зрізі.
this.restClient = builder.baseUrl(baseUrl).build();
}
@Override
public ModerationDecision moderate(ModerationRequest request) {
// Важливо: фіксуємо endpoint і те, що надсилаємо JSON-тіло.
ModerationResponse response = restClient.post()
.uri("/api/check") // шлях відносно baseUrl
.contentType(MediaType.APPLICATION_JSON)
.body(request) // тіло запиту: ModerationRequest буде серіалізовано Jacksonʼом
.retrieve()
.body(ModerationResponse.class); // очікуємо конкретний зовнішній DTO
// Адаптер відповідає за перетворення "HTTP/JSON" -> "доменні типи".
return response.toDecision();
}
}
Зверніть увагу: тут адаптер приймає RestClient.Builder (щоб Spring міг його нормально сконфігурувати в тестовому slice), і окремо baseUrl. У повноцінному ContentHub це частіше буде @ConfigurationProperties, але зараз нам важливо показати принцип, а не влаштовувати конкурс анотацій.
Далі наше завдання — перевірити, що за заданої зовнішньої відповіді "OK" адаптер повертає ModerationDecision(OK, null), а при "BLOCK" повертає рішення з причиною. І паралельно ми хочемо переконатися, що запит пішов на правильний URL і правильним методом.
5. Скелет @RestClientTest і властивості
У @RestClientTest є приємна особливість: тест виглядає майже як звичайний Spring Boot тест, але під капотом піднімається вузький контекст. Це означає, що запуск буде швидким, а залежності — контрольованими. Для новачка це чудовий компроміс: ви все ще в знайомому Spring-світі (@Autowired, властивості, автоконфігурація), але не тонете в повному застосунку.
Почнемо зі скелета RestClientModerationClientTest. Тут важливо два впровадження: сам RestClientModerationClient і MockRestServiceServer. Клієнт — наш об’єкт тестування, сервер — підміна зовнішньої мережі.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.client.MockRestServiceServer;
@RestClientTest(RestClientModerationClient.class) // піднімаємо лише test-slice для HTTP-клієнта
@TestPropertySource(properties = {
// Підставляємо baseUrl у тестовий контекст, щоб RestClient коректно збирав повний URL.
"contenthub.moderation.base-url=https://moderation.local"
})
class RestClientModerationClientTest {
@Autowired
private RestClientModerationClient client; // об’єкт тестування (наш адаптер)
@Autowired
private MockRestServiceServer server; // перехоплювач HTTP-запитів RestClient (без реальної мережі)
}
Але ми передаємо baseUrl у конструктор як String. Отже, потрібно показати, звідки він береться. Один із простих навчальних способів — @Value("${contenthub.moderation.base-url}"). У бойовому коді ви, найімовірніше, оформите це як properties-клас, але механіка тесту від цього не змінюється.
Мініконструктор з @Value:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.client.RestClient;
public RestClientModerationClient(
RestClient.Builder builder,
@Value("${contenthub.moderation.base-url}") String baseUrl // беремо baseUrl із конфігурації
) {
// Збираємо RestClient із базовою адресою, щоб у коді можна було використовувати відносні uri("/api/check").
this.restClient = builder.baseUrl(baseUrl).build();
}
Так тестова властивість із @TestPropertySource буде справді використана. Це дрібниця, але важлива: якщо ви забудете підставити base-url, адаптер почне будувати URL із повітря, а повітря — погане джерело конфігурації: воно занадто легковажне.
6. Перевірка контракту та мапінгу
Успішний сценарій: OK
Тепер найприємніше: пишемо тест, який читається як сценарій. Ми задаємо очікування для MockRestServiceServer, потім викликаємо client.moderate(...), а потім робимо перевірки через AssertJ.
Нехай зовнішній сервіс повертає такий JSON:
{"result":"OK","reason":null}
Тест може виглядати так:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class RestClientModerationClientTest {
// ... поля @Autowired
@Test
void shouldMapOkResponse() {
// Кажемо мок-серверу: очікуй конкретний запит (URL + метод)...
server.expect(requestTo("https://moderation.local/api/check"))
.andExpect(method(POST))
// ...і поверни заздалегідь заданий JSON, ніби це відповідь реального сервісу.
.andRespond(withSuccess("""
{"result":"OK","reason":null}
""", APPLICATION_JSON));
// Викликаємо адаптер як звичайний код: він "думає", що ходить назовні.
ModerationDecision decision = client.moderate(
new ModerationRequest("Hello", "Some body")
);
// Перевіряємо доменний результат, а не «сирий» JSON.
assertThat(decision.result()).isEqualTo(ModerationResult.OK);
assertThat(decision.reason()).isNull();
}
}
Тут ми вже захищаємо два шари ризику: фіксуємо, що запит пішов саме на https://moderation.local/api/check, і що відповідь коректно перетворилася на доменне рішення. Для новачка це ключова думка: тест адаптера — це не «перевірити, що повернулося OK», а «перевірити, що адаптер правильно розмовляє за контрактом».
Зверніть увагу на """...""". Текстові блоки Java — це чудовий спосіб тримати JSON прямо в тесті без нескінченних екранувань. Так, це виглядає як «міні-JSON-файл», тільки без файлової системи. У невеликих прикладах це доречно, а коли payload складніший, краще переходити до fixture-файлів, але це вже окрема дисципліна — не будемо зараз влаштовувати «війну форматів».
Контракт запиту: заголовки та тіло
Іноді студенти пишуть такий тест — і задоволені. А потім виявляється, що адаптер, наприклад, випадково надсилає GET замість POST (або взагалі не надсилає тіло, бо «ой, забув .body(request)»), а тест усе одно зелений. Щоб не потрапити в таку пастку, можна додати кілька перевірок на запит.
Найпрактичніша перевірка — переконатися, що ми справді надіслали JSON. MockRestServiceServer дозволяє звіряти request body. Не потрібно перетворювати це на повний знімок усього JSON (інакше тест стане крихким), але перевірити ключові поля — корисно.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
@Test
void shouldSendJsonBody() {
server.expect(requestTo("https://moderation.local/api/check"))
.andExpect(method(POST))
// Перевіряємо, що заголовок Content-Type встановлено коректно.
.andExpect(content().contentType(APPLICATION_JSON))
// Перевіряємо форму запиту: ключові поля, важливі для зовнішнього контракту.
.andExpect(content().json("""
{"title":"Hello","body":"Some body"}
"""))
.andRespond(withSuccess("""
{"result":"OK","reason":null}
""", APPLICATION_JSON));
ModerationDecision decision = client.moderate(
new ModerationRequest("Hello", "Some body")
);
// Тут нам достатньо перевірити результат: запит уже зафіксовано очікуваннями вище.
assertThat(decision.result()).isEqualTo(ModerationResult.OK);
}
Тут ми вже зафіксували значно більш «контрактний» аспект: форму запиту. Якщо завтра хтось перейменує поле body на text у ModerationRequest, то тест одразу почне кричати. І це добре, бо зовнішній сервіс не зобов’язаний вгадувати наші наміри; він читає лише те, що ми йому надіслали.
При цьому важливо не переборщити. Якщо ви почнете порівнювати весь JSON до останньої коми, ви швидко прийдете до тестів, які ламаються від косметики. В адаптері зазвичай варто перевіряти те, що справді є частиною зовнішнього контракту: URL, метод, content type, ключові поля.
Гілки відповіді: WARN і BLOCK
Дуже часта помилка початківців — бажання написати «один великий тест на всі випадки». Він виглядає героїчно, але падає трагічно: будь-який дефект ламає весь сценарій, і ви довго з’ясовуєте, яка саме гілка зламалася.
Набагато стабільніше тримати принцип «одна гілка — один тест». Тоді тест стає документацією: ось що означає OK, ось що означає BLOCK. Для нашої модерації BLOCK особливо важливий: причина блокування має дійти до домену й далі до бізнес-логіки.
import org.junit.jupiter.api.Test;
@Test
void shouldMapBlockResponse() {
// Гілка BLOCK: важливо, щоб причина не загубилася під час мапінгу.
server.expect(requestTo("https://moderation.local/api/check"))
.andRespond(withSuccess("""
{"result":"BLOCK","reason":"spam"}
""", APPLICATION_JSON));
ModerationDecision decision = client.moderate(
new ModerationRequest("Buy now", "Cheap cheap cheap")
);
assertThat(decision.result()).isEqualTo(ModerationResult.BLOCK);
assertThat(decision.reason()).isEqualTo("spam");
}
Аналогічно можна зафіксувати WARN:
import org.junit.jupiter.api.Test;
@Test
void shouldMapWarnResponse() {
// Гілка WARN: результат має потрапити в домен, а пояснення — зберегтися.
server.expect(requestTo("https://moderation.local/api/check"))
.andRespond(withSuccess("""
{"result":"WARN","reason":"too many links"}
""", APPLICATION_JSON));
ModerationDecision decision = client.moderate(
new ModerationRequest("Links", "http://a http://b http://c")
);
assertThat(decision.result()).isEqualTo(ModerationResult.WARN);
assertThat(decision.reason()).isEqualTo("too many links");
}
Ми поки свідомо лишаємося на успішному сценарії: HTTP-відповідь успішна (200 OK), JSON валідний. Тут важливо зафіксувати базовий, вузький і зрозумілий тест, який тримає зовнішній контракт адаптера без зайвої інфраструктури. Коли цей шлях стабільний, на нього вже легко навішувати відмовні гілки того самого клієнта.
7. Читабельність і стійкість тестів
Коли тестів стає більше трьох, з’являється новий ризик: вони починають виглядати як полотно з server.expect(...). І тут дуже легко, намагаючись «скоротити код», сховати сенс. Ми вже проходили це на MockMvc і в unit-тестах: helperʼи корисні, доки не перетворюють тест на загадку.
Гарна практична стратегія — винести повторювані частини, які не несуть сенсу сценарію, але залишити читабельними ключові відмінності. Наприклад, URL можна зробити константою. Це невелика річ, але вона рятує від «помилок на мільйон доларів» — точніше, на мільйон хвилин дебагу.
private static final String CHECK_URL = "https://moderation.local/api/check";
Далі можна оформити маленький helper для відповіді:
private void stubResponse(String json) {
server.expect(requestTo(CHECK_URL))
.andRespond(withSuccess(json, APPLICATION_JSON));
}
І в тесті залишиться лише сенс:
@Test
void shouldMapOkResponse() {
stubResponse("""{"result":"OK","reason":null}""");
ModerationDecision decision = client.moderate(
new ModerationRequest("Hello", "Some body")
);
assertThat(decision.result()).isEqualTo(ModerationResult.OK);
}
Але важливо не зайти надто далеко і не сховати, наприклад, HTTP-метод або перевірку тіла запиту. Бо потім ви забудете, що у вас узагалі перевіряється метод POST, і тест стане «нібито щось перевіряє, але що саме — таємниця давніх». У тестах адаптера ключове — зберегти прозорість зовнішнього контракту.
Ще одна корисна техніка, особливо для новачків, — групувати гілки через @Nested. Тоді тест-клас читається як міні-документація протоколу:
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@Nested
class Mapping {
@Test
void ok() { /* ... */ }
@Test
void warn() { /* ... */ }
@Test
void block() { /* ... */ }
}
Це не робить тести «магічно кращими», але різко підвищує читабельність. А читабельність — це те, що рятує вас через три місяці, коли ви вже не пам’ятаєте, навіщо написали цей тест, але він раптово впав у CI.
І останній нюанс: пам’ятайте, що requestTo(...) в очікуваннях найчастіше має бути повним URL, якщо ви використовуєте baseUrl. Це одна з найчастіших причин падінь. Ви пишете очікування на "/api/check", а реальний запит іде на "https://moderation.local/api/check". І MockRestServiceServer чесно каже: «не збіглося». Він не шкідливий — він просто буквальний. Як будь-який хороший тестовий інструмент.
8. Типові помилки під час @RestClientTest
Помилка №1: тестувати адаптер через @SpringBootTest, бо «так надійніше».
Це виглядає логічно, доки ви не помічаєте ціну. Повний контекст робить тест повільним, часто потребує більше конфігурації та дає гіршу діагностику: падіння може бути через що завгодно. @RestClientTest якраз створений, щоб перевіряти саме контракт адаптера швидко й ізольовано, не перетворюючи кожну перевірку на міні-запуск усього застосунку.
Помилка №2: перевіряти лише результат (decision.result()), але не перевіряти зовнішній контракт.
Якщо ви не фіксуєте URL, метод і хоча б мінімальну форму тіла запиту, тест може стати зеленим навіть за серйозної поломки: наприклад, адаптер почав ходити на інший endpoint або «випадково» надсилає GET замість POST. Тест адаптера цінний тим, що він захищає домовленість між вашим кодом і зовнішнім сервісом, а не лише ваш enum усередині JVM.
Помилка №3: очікувати в requestTo(...) шлях, а не повний URL, коли у вас використовується baseUrl.
Це один із найподразливіших багів, бо він «схожий на дрібницю», але ламає весь тест. Якщо ви збираєте RestClient через builder.baseUrl("https://moderation.local"), то реальний запит буде на повну адресу. І MockRestServiceServer теж чекає повну адресу, інакше він не розуміє, що саме ви хотіли перевірити.
Помилка №4: перетворювати тест на перевірку «кожної коми» і робити його крихким.
Так, можна порівняти весь JSON запиту/відповіді точно. Але тоді будь-яка косметика (наприклад, порядок полів, поява нового необов’язкового поля) почне ламати тести, навіть якщо бізнес-сенс не змінився. Утримуйте баланс: перевіряйте те, що справді є контрактом, і не перетворюйте тест на «скриншот» усієї структури без потреби.
Помилка №5: змішувати в одному тесті відповідальність адаптера та відповідальність сервісу.
Якщо тест починає перевіряти «і що запит правильний, і що стаття змінила статус, і що security не пустив аноніма, і що сповіщення надіслано», то ви знову отримуєте дорогий, довгий сценарій із поганою діагностикою. Адаптер перевіряємо в @RestClientTest, бізнес-реакцію сервісу — unit-тестом із fake/stub порту, наскрізні ефекти — integration-тестами. Кожен шар має доводити свою частину правди, інакше тестова «правда» перетворюється на кашу.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ