1. Єдина поведінка: сенс і користь
Команди вже вміють запускати catalog search і catalog details, а діагностика вже вміє показати, що сталося під час real-виклику. Залишилося одне джерело хаосу: що саме змінюється, коли ми перемикаємося між real і mock. Якщо тут попливуть контракт, формат результату або обробка порожніх і помилкових випадків, уся попередня дисципліна швидко розсиплеться.
Коли кажуть «у нас є real і mock режим», новачки часто чують: «У нас два різні застосунки, просто в одному проєкті». А далі починаються веселощі: у real-режимі друкуємо красиво, у mock-режимі — як вийде; у real-режимі повертаємо DTO, у mock-режимі — рядки; у real-режимі помилки оформлюємо одним способом, у mock-режимі — іншим. У підсумку будь-який код «вище» клієнта змушений знати про режим, розгалужуватися й ускладнюватися.
«Єдина поведінка» — це сувора і дуже практична ідея: режим змінює джерело даних, але не змінює сенс і форму результату. Якщо решта застосунку вміє працювати з CatalogBookSearchItem, то має вміти працювати з ним і в real-режимі, і в mock-режимі. Якщо catalog search у real-режимі може повернути порожній список — mock-режим теж має вміти повернути порожній список, а не падати або повідомляти, що ресурсу немає.
Можна уявити це як правило: у застосунку один «внутрішній контракт», а не два. А режим — це просто те, яку трубу ми підʼєднали до цього контракту.
2. Контракт CatalogClient: інтерфейс і DTO
Щоб real і mock не перетворилися на два різні застосунки, решта коду має бачити один і той самий внутрішній контракт. CatalogClient і далі повертає нормалізовані CatalogBookSearchItem і CatalogBookDetails, а не DTO провайдера, не JsonNode і не сирий JSON. Тут важливо не вигадувати для mock «полегшену версію клієнта», а зберегти вже обрану мову застосунку.
Практична ознака дуже проста:
- search(query) в обох режимах повертає список CatalogBookSearchItem;
- details(externalId) в обох режимах повертає CatalogBookDetails;
- слова зовнішнього провайдера на кшталт docs, key, author_name, numFound залишаються всередині клієнтського шару.
Щойно цей договір витримано, точка входу, друк результатів і решта коду перестають розгалужуватися за режимом. Вони працюють з одним API і взагалі не питають, чи прийшли дані мережею, чи із sample JSON.
3. Mock як джерело даних
Найчастіше mock ламається з однієї причини: люди думають, що mock — це «підставити готовий результат». Тоді mock повертає «вже надруковані рядки», або «вже нормалізовані DTO, зібрані вручну», або навіть «просто System.out.println("мок!")». Такий mock, звісно, швидкий… але марний. Він не перевіряє найважливішого: чи взагалі працює наша логіка читання JSON і мапінгу.
Правильна модель mock-режиму тут дуже приземлена: mock підміняє джерело даних, але не змінює подальший шлях. Тобто в mock-режимі ми беремо sample JSON, читаємо його тим самим ObjectMapper, отримуємо DTO провайдера, а потім нормалізуємо в ті самі DTO, що й у real-режимі.
Зручно тримати в голові таку схему:
flowchart TD
A["Команда: catalog search/details"] --> B["CatalogClient"]
B -->|real| C[HTTP-запит]
B -->|mock| D[JSON-ресурс із classpath]
C --> E[Provider JSON]
D --> E[Provider JSON]
E --> F[Provider DTO]
F --> G[Mapping]
G --> H[Normalized DTO]
H --> I[Єдине виведення]
Тут головний фокус у тому, що після отримання JSON шлях однаковий.
Із цього ж правила одразу випливає, де обирати режим. Достатньо однієї точки збирання: там створюється або RealCatalogClient, або MockCatalogClient, а далі в застосунку живе тільки посилання типу CatalogClient. Якщо if(useMock) починає зʼявлятися в командах, друку або мапінгу, режим уже протікав занадто далеко.
Корисно порівняти real і mock не як «два світи», а як «дві реалізації однієї обіцянки»:
| Що порівнюємо | real режим | mock режим | Що має збігатися |
|---|---|---|---|
| Джерело даних | HTTP-відповідь | JSON із classpath | Нормалізований результат |
| Парсинг JSON | ObjectMapper.readValue(...) | ObjectMapper.readValue(...) | Так, тим самим способом |
| Mapping | DTO провайдера → нормалізований DTO | DTO провайдера → нормалізований DTO | Один і той самий mapping |
| Повернення назовні | нормалізований DTO | нормалізований DTO | Обовʼязково |
| Користувацьке виведення | друкує нормалізований DTO | друкує нормалізований DTO | Один формат |
4. Mock JSON у resources
З mock-режимом є одна підступна пастка, яку майже всі ловлять один раз — іноді двічі, щоб закріпити успіх. Пастка називається: «В IDE працює, а після збирання JAR — ні». Причина зазвичай у тому, що mock-файл читають як звичайний файл за шляхом у файловій системі, а в JAR це вже не «просто файл». Тому ми читаємо mock-JSON як ресурс classpath, тобто через getResourceAsStream.
Уявімо, що в нас є файл src/main/resources/mock/search-clean-code.json. Тоді читання виглядає так:
import java.io.IOException;
import java.io.InputStream;
// Читаємо саме як ресурс classpath (це працює і в IDE, і з JAR)
try (InputStream in = getClass().getResourceAsStream("/mock/search-clean-code.json")) {
if (in == null) {
// Явно повідомляємо причину: ресурс не знайдено (інакше десь далі буде незрозумілий NPE)
throw new IllegalStateException("Мок-ресурс не знайдено: /mock/search-clean-code.json");
}
// Важливо: використовуємо той самий ObjectMapper, що й у real-режимі
ProviderSearchResponse response = objectMapper.readValue(in, ProviderSearchResponse.class);
} catch (IOException e) {
throw new IllegalStateException("Не вдалося прочитати mock-ресурс", e);
}
Тут важливі дві речі. По-перше, шлях починається з / — це абсолютний шлях у classpath. По-друге, ми обовʼязково перевіряємо in == null. Інакше ви отримаєте NullPointerException у місці, яке зовсім не пояснює, що файл просто не знайшовся. А IllegalStateException("Мок-ресурс не знайдено: ...") хоча б говорить правду, хай і без ніжності.
Важлива деталь: тут не створюємо новий ObjectMapper «на місці». Якщо real і mock почнуть читати JSON різними mapper-ами, ви дуже швидко отримаєте дві трохи різні поведінки і почнете ловити привидів.
Далі ви робите те саме, що робили б у real-режимі: із DTO провайдера робите нормалізований DTO. Наприклад, якщо провайдер надсилає docs (список книг), а в книги є key, title і author_name, mapping може бути таким:
import java.util.List;
CatalogBookSearchItem toSearchItem(ProviderBookDoc doc) {
// У навчальному mapping часто беремо "першого автора", щоб не ускладнювати формат виведення
List<String> authors = doc.authorNames();
String author = (authors == null || authors.isEmpty()) ? "Невідомо" : authors.get(0);
// externalId — це стабільний ідентифікатор із контракту провайдера (ключ/ID), але в нашому DTO
return new CatalogBookSearchItem(doc.key(), doc.title(), author);
}
Зверніть увагу: ми не намагаємося зробити «ідеальний» mapping і не влаштовуємо війну за те, що таке «правильний автор». Наша мета зараз — щоб поведінка була однаковою в обох режимах і щоб формат результату був стабільним.
Залишається дрібне, але життєве питання: «А як у mock-режимі обрати, який файл читати?» Найпростіший шлях — зробити невелику мапу «запит → ресурс». Так, це майже таблиця відповідностей. І це нормально: mock-режим — це навчальна стабілізація, а не повноцінна система фейкових даних.
import java.util.Map;
// Таблиця відповідностей: запит користувача -> конкретний JSON-ресурс у classpath
private static final Map<String, String> SEARCH_MOCKS = Map.of(
"clean code", "/mock/search-clean-code.json",
"unknown-book", "/mock/search-empty.json"
);
І далі ви берете шлях із мапи та читаєте ресурс. Якщо запит невідомий — можна повертати порожній результат, щоб mock-режим був передбачуваним, а не падав через те, що користувач ввів «clean coder» замість «clean code». У навчальному проєкті «передбачувано» майже завжди важливіше, ніж «суперреалістично».
5. Єдине виведення: принтер результатів
Дуже поширена помилка в клієнтських проєктах: «Раз я вже сходив у зовнішній API, я тут же й надрукую». Виглядає зручно, але закінчується тим, що клієнт знає про формат виведення, мову повідомлень і те, що в нас там у main відбувається. А потім ви хочете змінити формат — і раптом лізете в transport-код. Це все одно що змінювати дизайн кухні, розкручуючи двигун автомобіля. Технічно можливо, але емоційно не рекомендовано.
Сенс простий: клієнт повертає дані, а друк — окрема відповідальність. Але робити з цього новий обовʼязковий шар теж не треба. Поки команд небагато, виведення можна спокійно залишити прямо в ReadLaterApplication. Якщо форматування починає розростатися і заважає читати маршрутизацію команд, його зручно винести в окремий CatalogConsolePrinter як невеликий рефакторинг.
Мініприклад такого винесення:
import java.util.List;
class CatalogConsolePrinter {
void printSearch(List<CatalogBookSearchItem> items) {
// Порожня видача — це нормальний результат пошуку, а не помилка
if (items.isEmpty()) {
System.out.println("Нічого не знайдено");
return;
}
// Друкуємо завжди нормалізовані DTO: формат однаковий у real/mock
for (CatalogBookSearchItem item : items) {
// Найпростіший формат: id | title | author (можна ускладнити пізніше)
System.out.println(item.externalId() + " | " + item.title() + " | " + item.author());
}
}
void printDetails(CatalogBookDetails book) {
// Деталі друкуються з нормалізованої моделі, а не з DTO провайдера/JSON
System.out.println(book.externalId()); // OL12345M
System.out.println(book.title()); // Clean Code
System.out.println(book.author()); // Robert C. Martin
}
}
І real, і mock тоді повертають одні й ті самі DTO, а printer друкує їх однаково. Режим і далі змінює тільки джерело даних.
6. Готовність клієнта: критерії
Фраза «клієнт готовий» звучить приємно, але сама по собі нічого не гарантує. В одному проєкті «готовий» означає «один запит працює, якщо інтернет хороший». В іншому — «два режими, негативні сценарії, зрозуміла діагностика і єдиний формат результату». Нам потрібен другий варіант, тому що інакше наступний етап проєкту будуватиметься на піску.
Критерії завершеності — це не бюрократія. Це спосіб не обманути себе. Якщо критерії є, ви швидше помічаєте, де «дірка», і не ловите сюрпризи під час першої ж зміни коду. Для нашої клієнтської фази критерії досить приземлені: команди працюють, режими однакові, provider-деталі ізольовані, а виведення передбачуване.
Нижче — таблиця «як це виглядає на практиці». Це не «домашка», а шпаргалка, за якою ви самі можете подумки перевірити проєкт.
| Критерій | Як виглядає в real режимі | Як виглядає в mock режимі | Що це означає для проєкту |
|---|---|---|---|
| Команда catalog search ... працює | Приходить список результатів | Приходить список результатів | Команда не залежить від джерела даних |
| Команда catalog details <id> працює | Приходить картка книги | Приходить картка книги | Деталі відокремлені від транспортної частини |
| Формат результату однаковий | Друкується через CatalogConsolePrinter | Друкується через CatalogConsolePrinter | Виведення не розгалужується за режимом |
| Немає витоків provider DTO | Назовні не повертаються provider-типи | Назовні не повертаються provider-типи | Зовнішній контракт «запертий» усередині клієнта |
| Mock використовує JSON + mapping | JSON читається mapper-ом | JSON читається mapper-ом | Mock перевіряє реальний шлях даних |
| Порожній результат обробляється спокійно | Друк «Нічого не знайдено» | Друк «Нічого не знайдено» | Немає «падінь від порожнечі» |
| Діагностика зрозуміла | Є операція/режим/ціль/підсумок/час | Є операція/режим/джерело/підсумок/час | У разі проблеми ви розумієте, де зламалося |
Коли search/details, діагностика і режими тримаються на одному внутрішньому контракті, клієнтська частина перестає бути крихким набором демо-класів. Такий шар уже можна спокійно розкладати по пакетах, виносити з точки входу і збирати залежностями вручну — без страху, що real і mock раптово розійдуться в поведінці.
Зверніть увагу на один тонкий момент. Коли ми говоримо «однакова поведінка», ми не обіцяємо, що реальні дані й мокові дані збігатимуться «буква в букву». Ми обіцяємо, що форма і правила поведінки збігаються. І це набагато важливіше.
Наприклад, у real режимі catalog search clean code може повернути 20 результатів. У mock режимі — 1 або 3. Це нормально. Але якщо real повертає List<CatalogBookSearchItem>, а mock раптово повертає «рядок з одним результатом» — це вже ненормально, тому що код вище клієнта перестає бути єдиним.
Ще один момент: mock-дані мають повторювати саме ту частину контракту, яку ми реально читаємо. Якщо в mapping ми використовуємо doc.key() і doc.title(), а в sample JSON у книги немає key, то mock-режим або впаде, або дасть дивні null-значення. І це насправді добра перевірка: mock-режим нагадує вам, що контракт — це договір, а не «ну там якісь поля».
7. Типові помилки під час роботи з клієнтом
Помилка №1: mock-режим починає повертати «готові рядки», а не дані.
Це виглядає спокусливо: швидше написати return List.of(...) або взагалі System.out.println("MOCK RESULT"), ніж возитися з JSON. Але тоді mock не перевіряє ваш JSON mapping і перетворюється на іграшкову заглушку. Підсумок зазвичай такий: «У mock усе зелене, у real усе падає», і ви не розумієте чому.
Помилка №2: різні формати виведення для real і mock.
Часто це стається не спеціально: у real-режимі ви виводите «красиво», а в mock — просто друкуєте обʼєкт цілком або інші поля. Ніби дрібниця, але на практиці це ламає головну ідею: режим змінює джерело, а не поведінку. Якщо формат виведення різний, ви мимоволі починаєте додавати умови if(useMock) по всьому коду, і проєкт знову перетворюється на два проєкти.
Помилка №3: provider DTO «протікають» назовні, бо так простіше.
Найпідступніший варіант — коли ви спочатку повертаєте provider DTO «на хвилинку», щоб швидше зробити виведення, а потім забуваєте це виправити. За кілька днів provider DTO опиняються в ReadLaterApplication, у принтері, в обробниках команд. Після цього будь-який чх зовнішнього API стане вашим чхом. Виправляти це потім неприємно: доведеться вичищати типи з багатьох місць.
Помилка №4: читання mock-файлів через File, через що все працює тільки в IDE.
В IDE шлях src/main/resources/... «видно», і здається, що все добре. Але після збирання це вже не «файл на диску», а ресурс усередині JAR. У підсумку mock-режим відвалюється в найневдаліший момент. Якщо ви читаєте sample JSON через getResourceAsStream, ви уникаєте цієї проблеми і отримуєте однакову поведінку під час запуску з IDE та під час запуску зібраного артефакту.
Помилка №5: не обробляється порожній результат і «відсутність книги».
Новачок часто тестує лише happy-path: «Ну я ж знайшов clean code». А потім вводить щось неіснуюче — і клієнт або падає, або друкує «null null null», або мовчить. Порожній список — це нормальний результат пошуку, а не помилка. І якщо ви обробляєте його однаково в real і mock, ви отримуєте той самий знак зрілості: застосунок лишається передбачуваним навіть тоді, коли нічого не знайдено.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ