1. Вибір починається з дефекту
Коли ви додаєте перевірку, правильна мета завжди одна: назвати помилку через спостережувану поведінку. Не «треба б покрити сервіс тестами». Не «давайте використаємо @SpringBootTest, щоб було надійно». А що саме має стати неможливим або, навпаки, гарантованим? Публічний endpoint не повинен показувати DRAFT. Approve не повинен працювати зі DRAFT. Невалідний JSON не повинен тихо проходити далі. У slug не повинні зʼявлятися випадкові дублікати. Саме таке формулювання одразу стає інженерним.
Це здається дрібницею, але вона рятує від величезної кількості шумних тестів. Щойно ви починаєте з інструмента, а не з дефекту, тест майже неминуче підпорядковується смаку автора. Один любить моки, інший — повний контекст, третій — контейнери «про всяк випадок». Правило мінімально достатнього тесту спеціально ламає цю звичку. Спочатку дефект. Потім місце ризику. Лише після цього — глибина перевірки.
2. Перше обовʼязкове запитання: де живе ризик?
Це запитання звучить просто, але на ньому тримається половина всієї стратегії. Ризик може жити в локальному правилі, на HTTP-межі, у JSON-контракті, у запиті до бази, у security-обмеженні, у конфігурації або у зовнішній інтеграції. Поки ви не назвали місце ризику, будь-який наступний вибір буде ворожінням.
У ContentHub це дуже добре видно на контрастах. Помилка генерації slug — це локальна логіка. Неправильний 404 на public read — це межа web-рівня. Витік чернетки в public-каталог часто виявляється ризиком даних або ризиком видимості в сервісі. Можливість редагувати чужу статтю — це вже доступ за власником. Падіння submit через таймаут moderation-сервісу — ризик зовнішньої межі.
Формально все це — помилки бекенду. Практично — це дуже різні зони, і саме тому для них потрібна різна глибина перевірки.
3. Друге обовʼязкове запитання: що має бути реальним?
Після локалізації ризику залишається другий ключовий вибір. Яка частина системи має працювати по-справжньому, щоб помилку стало видно? Для slug нічого, крім Java-коду, реальним робити не треба. Для HTTP-помилки та validation, навпаки, потрібна справжня межа web-рівня. Для запиту до бази потрібен реальний data-layer. Для звʼязування кількох частин — достатньо широкого контексту. Для PostgreSQL-специфіки — реальний PostgreSQL, хоча б у контейнері.
Ось де студенти найчастіше або недотягують, або перетягують. Недотягують, коли мокують саме ту межу, де дефект і мав би проявитися. Перетягують, коли підіймають увесь бекенд заради однієї локальної функції. Обидва варіанти неприємні по-своєму. Перший створює хибну впевненість, другий — дорогий шум. Мінімально достатній тест тримається рівно посередині: реальним стає тільки те, без чого ризик не можна чесно побачити ⚖️
Якщо хочеться побачити цю ідею зовсім як схему, вона виглядає так:
flowchart TD
A["Є конкретна помилка"] --> B{"Де живе ризик?"}
B -->|Локальна логіка| U["Юніт"]
B -->|HTTP / JSON / validation| W["Зріз web/json"]
B -->|Запити / constraints / mappings| D["Зріз data"]
B -->|Стик кількох шарів / звʼязування| I["Інтеграція"]
B -->|Реальний HTTP server| L["Живий сервер"]
B -->|Поведінка зовнішньої інфраструктури| C["Контейнер"]
4. Корисне уточнювальне запитання: що я хочу побачити зовні?
Є ще одне запитання, яке формально не є обовʼязковим, але дуже допомагає на практиці: яка спостережувана поведінка має змінитися, якщо баг зʼявиться знову? Воно захищає від дуже липкої звички тестувати деталі реалізації замість результату.
Наприклад, якщо ви перевіряєте створення чернетки, важливі статус DRAFT, коректний slug, а іноді ще автор і timestamps. Не так важливо, скільки разів усередині викликався якийсь приватний helper. Якщо ви перевіряєте відмову в доступі, важливі заборона для користувача і коректна відповідь API. Не так важливо, який внутрішній метод security-політики було викликано першим. Щойно це запитання звучить уголос, тести стають і стійкішими, і кориснішими.
5. Приклад №1: slug — локальна логіка, отже й тест локальний ⚡
Якщо помилка живе в рядковому перетворенні, не треба тягнути за нею Spring. Це базовий приклад мінімально достатнього тесту: рівно настільки малого, наскільки дозволяє природа ризику.
class SlugService {
String toSlug(String title) {
// Прибираємо пробіли по краях, щоб " Hello " не перетворювалося на "-hello-"
return title.trim()
// Приводимо до нижнього регістру: slug зазвичай не залежить від регістру
.toLowerCase()
// Замінюємо пробіли на дефіси: це і є базова «читабельність» slug
.replace(" ", "-");
}
}
Якщо завтра Hello World раптом перетвориться не на hello-world, а на hello--world або Hello World, це чиста локальна регресія. Її найчесніше ловити unit-тестом без контексту, бази й HTTP. Будь-який ширший тест у цій ситуації не додає нової інформації. Він просто повільніший і гучніший.
І це хороший приклад того, як правило економить сили. Ви не робите «маленький» тест тому, що економите на якості. Ви робите його тому, що для цього ризику вже достатньо саме такої глибини. Саме так і виглядає інженерна зрілість, а не жадібність до інфраструктури.
6. Приклад №2: зміна статусу — теж спочатку локальне правило
Тепер візьмімо доменне правило approve. Якщо за вашим бізнес-процесом публікація дозволена лише зі IN_REVIEW, то перша лінія захисту знову локальна.
class PublicationPolicy {
boolean canApprove(ArticleStatus status) {
// Явне доменне правило: approve дозволений лише зі стану «на ревʼю»
return status == ArticleStatus.IN_REVIEW;
// Будь-яке розширення або послаблення умови тут = пряма зміна бізнес-правила
}
}
Тут мінімально достатній тест знову на рівні unit. Він дешевий, швидкий і бʼє точно в смислову точку поломки. Якщо правило послаблять, ви одразу побачите проблему. Жоден тест із повним контекстом не пояснить цю помилку краще, ніж локальна перевірка самого правила.
Але тут є важлива тонкість. Unit-тест правила не замінює ширшої перевірки всієї операції approve. Він просто закриває свою зону ризику. Пізніше вам може знадобитися окрема перевірка, що endpoint не дає аноніму або редактору виконати approve, що сервіс справді використовує політику і що публікація зберігається. Один користувацький сценарій уже починає розпадатися на кілька чесних доказів.
7. Приклад №3: 404, validation і JSON-помилки живуть на web-межі
Якщо ризик звучить так: «клієнт має отримати 404, а не 500» або «некоректний request має отримати 400 із зрозумілим error payload», unit-тест уже занадто вузький. Він може перевірити внутрішню логіку обробки, але не реальну поведінку HTTP-межі. Отже, мінімально достатнім стає web-зріз шару — той рівень, де реальними будуть binding, validation, error mapping і серіалізація відповіді.
Це дуже важливий поворот у логіці. Багато розробників намагаються «протестувати контролер unit-тестом», тобто просто викликати метод як звичайний Java-код. Іноді це має сенс як допоміжна перевірка. Але якщо вас цікавлять маршрут, status code, формат помилки і поведінка запиту на межі MVC, такий тест занадто слабкий. Він не робить реальною ту частину системи, де живе ризик.
Саме тут правило мінімально достатнього тесту захищає від хибної економії. Ви не зобовʼязані підіймати увесь бекенд. Але й викликати голий метод контролера вже недостатньо. Отже, потрібен рівно шар web-infrastructure — не менше і не більше.
8. Приклад №4: public бачить чернетку
Дуже показова ситуація для ContentHub: public endpoint показує статтю, яка не повинна бути публічною. Здається, що відповідь очевидна — «потрібен integration-тест». Іноді так. Але мінімально достатній вибір тут залежить від того, де саме ви спроєктували правило видимості.
Якщо у вас є окрема VisibilityPolicy, яка вирішує, чи можна показати статтю анонімному користувачеві, перший захист логічно поставити на рівні unit-тесту цієї політики. Якщо ж гарантія публічності має приходити із запиту до бази — наприклад, через findBySlugAndStatus(slug, PUBLISHED) — тоді unit-тест уже нічого чесно не доведе. Вам потрібен тест шару даних, де реальними будуть запит і його поведінка на даних.
Ось чому правило називається не «найменший тест взагалі», а «мінімально достатній». Іноді локального тесту вистачить. Іноді — ні. І саме архітектурне рішення визначає, де далі має стояти захист. Це дуже сильна ідея для першого рівня: один і той самий симптом може вимагати різної глибини тесту залежно від того, де ви розмістили відповідальність.
9. Приклад №5: доступ до чужої статті — одна проблема, два ризики 🔒
Редактор не повинен змінювати чужу статтю. Тут здається, що достатньо однієї перевірки доступу, але на практиці у проблеми одразу дві площини. По-перше, є локальне правило ownership: чи збігається поточний користувач з автором статті. По-друге, є публічна поведінка API: чи справді endpoint повертає заборону тому, кому має. Ці ризики схожі, але не ідентичні.
Локальне правило ownership цілком чесно живе в unit-тесті. Воно просте, швидке і легко діагностується. Але воно не доводить, що ваш web-шар або security-конфігурація справді застосовують це правило до реального запиту. Тому поверх локальної перевірки часто потрібен ще один тест на межі доступу. Не «тому що дублювання надійне», а тому що захищаються різні зони ризику.
Це хороший приклад проти наївної ідеї «на кожну можливість потрібен один тест». Ні, іноді одна користувацька можливість розумно покривається кількома тестами, якщо в неї є кілька незалежних зон поломки. Мінімально достатній підхід не забороняє цього. Він забороняє лише безглузде дублювання без нової інформації.
10. Дві крайності, які ламають стратегію
У правила мінімально достатнього тесту є два природні вороги. Перший — звичка тягнути повний Spring context куди завгодно. Вона виглядає «надійною», але насправді часто просто ховає локальну помилку за дорогим і шумним запуском. Другий ворог — бажання замокати взагалі все. Таке рішення швидке, але часто виймає з тесту саме ту межу, де помилка і мала стати видимою.
Повний контекст потрібен там, де ризик, який вас цікавить, справді живе у звʼязуванні, конфігурації або спільній роботі кількох шарів. Моки потрібні там, де зовнішня залежність не є предметом поточної перевірки. Щойно ви перестаєте тримати ці умови в голові, стратегія розпадається. Залишаються або важкі тести без точності, або швидкі тести без реальності. Обидва шляхи дають поганий спокій.
11. Шпаргалка першого дня 🧪
Щоб усе це не лишилося просто «розумною думкою», збережіть собі коротку памʼятку. Її зручно буквально тримати поруч, поки не зʼявиться автоматизм.
1. Назвіть помилку як спостережувану поведінку. Не «протестувати сервіс», а «public endpoint не повинен показувати DRAFT».
2. Локалізуйте ризик. Це локальна логіка, HTTP-межа, дані, доступ, звʼязування чи зовнішня інтеграція?
3. Зробіть реальною лише ту частину системи, де живе ризик. Усе інше не піднімаємо без причини.
4. Перевірте, чи дає ширший тест нову інформацію. Якщо ні, ви вже на потрібній глибині.
І ось компактна матриця, з якою зручно стартувати в ContentHub:
| Якщо помилка схожа на це | Де зазвичай живе ризик | Що має бути реальним | Перший кандидат |
|---|---|---|---|
| slug формується неправильно | локальна логіка | звичайний Java-код | unit |
| approve дозволений із неправильного статусу | доменне правило | policy / service-логіка | unit |
| API віддає не той status code або error payload | web-межа | MVC, validation і обробка помилок у шарі | slice web-layer |
| JSON-контракт тихо змінився | серіалізація DTO | JSON-шар | slice JSON |
| публічна видача повернула зайві записи | query / видимість даних | шар даних і реальні запити | slice data-layer |
| не спрацював доступ за роллю або власником | межа доступу | локальне правило і/або межа web/security | unit + вузький access-тест |
| ланцюжок controller → service → repository зламався на звʼязуванні | стик шарів | ширший контекст застосунку | integration |
| клієнт має побачити поведінку через реальний HTTP-сервер | transport boundary | реальний сервер і клієнтський виклик | live-server |
| поведінка залежить від реального PostgreSQL | зовнішня інфраструктура | PostgreSQL у контейнері | container |
Це і є перший переносний артефакт курсу. Не список анотацій, а робоча лінза для ухвалення рішення. Якщо після першого дня ви забираєте з собою саме її, день уже відпрацював недарма 📎
12. Типові помилки під час вибору глибини тесту 🚧
Помилка № 1: плутати «мінімально достатній» із «мінімально можливим».
Занадто вузький тест може бути швидким, але марним. Якщо ризик живе в базі, не треба вдавати, що HashMap її замінює.
Помилка № 2: обирати рівень тесту за особистими вподобаннями.
Любов до моків або до повного контексту — не аргумент. Аргумент тільки один: де живе ризик і що має бути реальним.
Помилка № 3: тестувати внутрішні виклики замість спостережуваної поведінки.
Так тести стають крихкими і ламаються від звичайного рефакторингу, хоча продуктовий результат не змінюється.
Помилка № 4: вважати, що один широкий тест завжди дешевший, ніж кілька точних.
Зазвичай навпаки. Кілька дешевих і точних перевірок дають більше сигналу і менше шуму, ніж один важкий універсальний сценарій.
Помилка № 5: забувати, що одна користувацька можливість може містити кілька незалежних ризиків.
Якщо в можливості є і доменне правило, і access boundary, і data-ризик, не треба штучно зводити все до одного тесту. Потрібно чесно захистити кожну окрему зону.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ