1. Тестів багато, а впевненості все одно мало
Багато тестів ще не означає багато впевненості — і з цього місця зазвичай починається доросле інженерне життя. Ви відкриваєте PR, бачите десять нових тестів, три фікстури і пару зелених галочок — а спокою всередині немає. Це правильне відчуття. Воно означає, що ви перестали плутати кількість тестів із якістю перевірки.
До цього моменту у вас уже є майже вся конструкція: ризик вибрано, перший сценарій проганяється через red → green → refactor, рівень перевірки зрозумілий, роль smoke/regression/E2E у suite теж. І все одно цього мало, коли тести живляться випадковими даними, граничні випадки вибрано навмання, а самі перевірки ніхто не ревʼює як окремий артефакт.
Проблема майже завжди одна й та сама: тести є, але вони не складаються в доказ. Один перевіряє надто щасливий сценарій, інший дивиться на внутрішній метод замість поведінки, третій бере дані, яких у реальній задачі не буває, а четвертий лишається зеленим, поки баг спокійно живе своїм найкращим життям.
Корисно тримати в голові дуже простий ланцюжок:
ризик зміни → сценарій → тестові дані → assertion → review самого тесту → evidence у PR
Коли десь у цьому ланцюжку є діра, впевненість падає. Візьмімо Commerce OS: ви правите endpoint повернення коштів POST /api/orders/{id}/refund. Перевірили тільки «оператор із повними правами надіслав коректну суму по валідному замовленню» — у вас є тест, але немає картини ризику. Баг ховається в повторному запиті, у вичерпаному вікні повернення, у некоректній сумі, у ситуації без прав, на межі дат. І ось тоді десять зелених тестів раптово виявляються декоративною гірляндою.
Тому сьогоднішня тема не про «написати ще». Вона про те, як відокремити шум від сигналу. Хороший тест — це інженерне твердження: за таких даних, у такому сценарії система зобов’язана повестися ось так і не зробити ось цього. Не читається твердження — тест слабкий, навіть якщо збірка його любить.
2. Edge cases: граничні випадки без енциклопедії болю
Коли ви вперше починаєте думати про граничні випадки системно, хочеться протестувати взагалі все. Порожні значення, null, від’ємні числа, величезні числа, неправильні ролі, два запити підряд, три підряд, таймаут, опівніч, високосний рік, ретроградний Меркурій. Ось тут важливо зупинитися: ми не пишемо енциклопедію болю, ми зменшуємо ризик.
Для цього зручно брати не нескінченний список, а робочу таксономію категорій — і вибирати лише ті випадки, які дійсно пов’язані з поточною зміною. Для повернення коштів у Commerce OS це виглядає так:
| Категорія | Про що запитати себе | Приклад для refund flow | Що саме доводимо |
|---|---|---|---|
| Порожні та некоректні вхідні дані | Що буде, коли вхід формально є, але бізнес-сенсу в ньому немає? | amount = 0, від’ємна сума, порожній reason | Валідація спрацьовує, сміття не проходить |
| Межа значення | Де проходить тонка межа «можна/не можна»? | сума дорівнює повній вартості замовлення, повернення в останній день вікна | На межі система не помиляється на одиницю |
| Права доступу | Хто взагалі має право виконувати цю дію? | оператор без ролі REFUND_MANAGER | Помилка доступу і відсутність побічних ефектів |
| Повторний запит | Що буде під час подвійного кліку або retry? | одне й те саме повернення надіслали двічі | Немає дубля, операція ідемпотентна |
| Зовнішня залежність | Що станеться, коли зовнішній сервіс підведе? | платіжний провайдер повернув timeout | Система коректно повідомляє про помилку і не «губить» стан |
| Час і дата | Чи не ховається баг на календарній межі? | замовлення оформлено пізно ввечері, вікно повернення рахується за UTC | Політика повернення рахується однаково й передбачувано |
Зверніть увагу на важливу річ: це не список «що тестувати завжди». Це питання під конкретну зміну. PR не зачіпає розрахунок часу — тест на timezone тут зайвий. А якщо ви саме змінювали бізнес-правило повернення за строком — timezone раптово стає головним героєм вечора.
Саме тут Claude може бути по-справжньому корисним. Не як автомат із кнопкою «згенеруй двадцять тестів», а як помічник у збиранні кандидатів. У Workflow Kit можна дати йому такий запит:
Прочитайте TASK_SPEC.md і погоджену test strategy.
Для endpoint POST /api/orders/{id}/refund перелічіть кандидатів у граничні випадки за категоріями:
неправильне введення, межі значень, права доступу, повторний запит, timeout, час і дата.
Не пишіть тестовий код.
Поверніть таблицю: сценарій -> ризик -> рекомендований рівень тесту.
Це хороший запит, бо він просить спочатку думати, а не друкувати. Скажете одразу «згенеруй тести на edge cases» — Claude це і зробить, і половина дублюватиме одна одну або перевірятиме другорядне. Модель у цьому місці чесно виконує замовлення. Просто замовлення було надто широким.
3. Negative scenario: контракт, а не помилка
З негативними сценаріями в новачків зазвичай трапляється одна й та сама маленька трагікомедія. Зʼявляється тест, який перевіряє, що API повернув 400 Bad Request, — і всім здається, що роботу зроблено. Але негативний сценарій — це не тільки код помилки. Це ще й питання: що система не зробила, поки повертала цю помилку.
Уявіть, що в Commerce OS повернення на суму 0 має бути відхилене. Тест тільки на статус відповіді кращий, ніж нічого. Але він не помітить неприємну річ: сервер повернув 400, а запис про повернення все одно встиг створити. Такий баг особливо любить жити там, де валідація і побічний ефект стали в неправильному порядку.
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
long before = refundRepository.count();
mockMvc.perform(post("/api/orders/42/refund")
.contentType("application/json")
.content("{\"amount\":0}"))
.andExpect(status().isBadRequest());
assertThat(refundRepository.count()).isEqualTo(before); // зайвий запис не створили
Тут тест уже помітно сильніший: він перевіряє і зовнішній контракт, і відсутність побічного ефекту. Це і є інженерна зрілість негативного сценарію — ви фіксуєте, що має бути заборонено, і водночас стежите, щоб система не накоїла справ дорогою.
Той самий підхід працює і для прав доступу. Оператор без потрібної ролі намагається виконати повернення — негативний сценарій відповідає щонайменше на два запитання: який статус отримав клієнт і що не сталося в системі. Чи не пішов запит до зовнішнього сервісу? Чи не створився запис? Чи не змінився статус замовлення?
Іноді тут дуже допомагає короткий сервісний тест:
import static org.mockito.Mockito.verifyNoInteractions;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
assertThatThrownBy(() -> refundService.requestRefund(orderId, 5000, userWithoutRole))
.isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(paymentProvider); // до зовнішнього сервісу не ходили
Зверніть увагу, наскільки кориснішим став тест після другого рядка. Перший каже: «помилка є». Другий: «і назовні ми дорогою нічого не зламали». Це вже перевірка контракту безпеки.
Саме тому формулювання «negative scenario» корисніше, ніж «тест на помилку». Помилка — видима частина. Контракт — це і зовнішня відповідь, і внутрішні заборони, і відсутність небажаних наслідків.
4. Claude як помічник із даними, а не фабрика
Коли розмова заходить про тестові дані, дуже хочеться доручити йому взагалі все. Імена замовлень, суми, email, статуси, дати, фікстури JSON, значення для параметризованих тестів. У цьому бажанні немає нічого поганого — рівно до моменту, поки ви не починаєте приймати все згенероване без ревʼю.
У тестових даних є два обов’язки. Бути достатньо реалістичними, щоб сценарій був упізнаваним. І достатньо чистими, щоб не тягнути в репозиторій продові хвости, реальні листи, токени, внутрішні ID та інше цифрове сміття з логів.
Тому хороші фікстури зазвичай виглядають нуднувато. І це комплімент.
{
"orderId": "ord-test-1042",
"customerEmail": "demo@example.com",
"amount": 5000,
"currency": "USD",
"reason": "damaged_item"
}
Такий файл не намагається виглядати «майже як production». Він зрозумілий, безпечний, стабільний. А anna.petrova@real-shop-client.com або номер справжнього замовлення з логу — це вже не реалістичність, а витік дисципліни.
Коли базовий red → green → refactor уже закріплено в памʼяті проєкту, поверх нього корисно додати ще один шар — правила для test data та узгодження сценаріїв. Не заміна базовим TDD-правилам, а розширення. Сама test strategy може жити в окремому файлі, секції TASK_SPEC.md або в описі PR — важливі узгоджені сценарії, а не назва документа.
Дуже корисно закріпити це в CLAUDE.md проєкту, щоб правило жило не лише у вашій памʼяті:
## Правила тестів
- Спочатку переліч сценарії та ризики, потім пиши тести.
- Не використовуй реальні email, токени та номери замовлень із production.
- Для bugfix зберігай базовий ритм: спочатку падаючий regression test.
- Якщо сценарій не погоджено в узгодженій test strategy, не додавай його автоматично.
Такий розділ робить дві важливі речі. Обмежує надмірну ініціативу Claude. І перетворює якість тестових даних із «настрою розробника» на правило репозиторію.
Ще один практичний прийом — просити у Claude спочатку лише перелік кандидатів, а код фікстур окремо. Це зменшує спокусу проковтнути все оптом: спочатку список сценаріїв за категоріями ризику, потім обираєте шість потрібних, і лише потім — «під ці шість сценаріїв запропонуй фікстури». Ритм трохи повільніший, якість помітно вища. Як і майже все в інженерії, це обмін: мінус пʼять хвилин зараз, зате потім набагато менше сорому.
5. Test quality review: перевіряємо самі тести
Це, мабуть, найважливіший шматок сьогоднішньої лекції. Коли тести вже написані, у багатьох розробників клацає дивний внутрішній вимикач: «ну тести ж є, далі ревʼюємо тільки production code». Ні. Тести — це теж код, а в AI-assisted розробці ще й окремий об’єкт ризику. Їх теж ревʼюють.
У цього review є одне головне запитання, яке варто повісити собі на монітор: Чи може цей тест пройти, навіть коли баг усе ще живий?
Якщо відповідь «так», тест слабкий. Іноді не марний, але слабкий: він не відрізняє «до» від «після», а отже, не дає впевненості у фіксі.
Зручно розкласти таке review на кілька запитань:
| Запитання до тесту | Навіщо воно потрібне | Червоний прапорець |
|---|---|---|
| Чи перевіряється спостережувана поведінка? | Ми хочемо ловити реальний ризик, а не внутрішню реалізацію | Тест дивиться на приватний метод або формат логу |
| Чи може тест пройти за живого бага? | Він має відрізняти стару поведінку від нової | Тест зелений і до фіксу, і після нього |
| Чи не замокали ми те, що мали перевірити безпосередньо? | Надмірний mock убиває достовірність | Integration-тест «перевіряє» те, що сам і підмінив |
| Чи є осмислена перевірка | Тест має щось доводити | Перевірка зводиться до «не впало виключення» |
| Чи не ховається flaky-джерело? | Шум руйнує довіру до всього suite | Залежність від часу, мережі, random або порядку запуску |
| Назва тесту пояснює сценарій? | Через місяць ви маєте зрозуміти, навіщо він був потрібен | testRefund1 і друзі |
На практиці дуже корисно мати окремий review-шаблон саме для тестів у Workflow Kit. Не загальне «подивись PR», а вузьке і сфокусоване. Фрагмент REVIEW_CHECKLIST.md може бути таким:
## Тести в цьому PR
- [ ] Є assertions на спостережувану поведінку
- [ ] Для bugfix видно: падав до фіксу, проходить після фіксу
- [ ] Немає mock'ів на те, що має перевірятися безпосередньо
- [ ] Немає залежності від часу, мережі та random без контролю
- [ ] Фікстури не містять production-даних
Так, усередині коду це список. Але як артефакт review він працює чудово: короткий, перевірний, не перетворює перевірку на філософський семінар на дві години.
Іноді різницю між слабким і сильним тестом найпростіше відчути на короткому контрасті. Слабкий варіант:
import static org.mockito.Mockito.verify;
refundService.requestRefund(orderId, 5000);
verify(refundRepository).save(any()); // тест зелений, навіть якщо зберегли не те
Сильний:
import static org.assertj.core.api.Assertions.assertThat;
refundService.requestRefund(orderId, 5000);
refundService.requestRefund(orderId, 5000);
assertThat(refundRepository.findByOrderId(orderId)).hasSize(1); // дубль не з'явився
Перший тест доводить тільки, що хтось щось кудись зберіг. Другий уже перевіряє важливу бізнес-поведінку: повторний запит не створив дублікат. Саме другий відповідає на ризик. Перший у найкращому разі дивиться на рух рук.
6. Тестовий diff: скорочувати, а не хвалити
Іноді Claude приносить вам великий і акуратний тестовий diff, і рука сама тягнеться похвалити його за старанність. Але подарунок це далеко не завжди.
Уявіть два PR. У першому — шістнадцять нових тестів на повернення коштів: null, порожній рядок, пробіл, два пробіли, від’ємна сума, нуль, величезна сума, сума на одиницю більша, запит без прав, запит після строку, timeout, повторний запит, три повтори підряд, дивна дата, інша дивна дата, ще одна дивна дата про всяк випадок. У другому — шість тестів, але кожен прив’язаний до конкретного ризику з TEST_STRATEGY.md, використовує чисті дані і читається як чітке твердження. Вгадайте, який PR легше зрозуміти і якому ви скоріше повірите.
Іноді корисно прямо порівняти:
| Шум | Evidence |
|---|---|
| Багато однотипних тестів із майже однаковими assertions | Кілька сценаріїв, кожен пов’язаний із конкретним ризиком |
| Реальні дані з логів і БД | Санітайзовані фікстури зі зрозумілими значеннями |
| Перевірка save() або calledOnce() | Перевірка статусу, побічного ефекту та бізнес-результату |
| Зелений тест після випадкового rerun | Детермінований тест без flaky-джерел |
Хороший набір тестів не зобов’язаний бути великим. Він зобов’язаний бути читабельним. Коли ви відкриваєте тестовий diff у PR, ви маєте бачити не «ще пачку файлів», а аргумент. Ось цей сценарій небезпечний. Ось такі дані його відтворюють. Ось такий результат система зобов’язана показати. Ось що вона не повинна була зробити дорогою.
Читається ця історія — перед вами evidence. Не читається — перед вами просто дуже старанний шум. І в цьому сенсі test quality review не бюрократія, а останній фільтр, який перетворює набір зелених галочок на інженерний доказ.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ