Тестування та verification: стратегія, TDD і рівні тестів
Один зелений regression test відповідає лише на одне запитання. Навколо зміни потрібен набір доказів, де кожен тест ловить конкретний ризик і перетинає межу, на якій цей ризик можна побачити.
Розберемо грошовий сценарій сервісу повернень: повторний refund із тим самим idempotency key не має створити другу операцію. На ньому видно, чому кількість тестів і coverage самі по собі нічого не гарантують.
- почнемо test strategy з можливої шкоди, а не з кількості cases;
- розведемо unit, integration, API та E2E відповідно до меж ризику;
- проведемо чесний red-green-refine без test oracle, що виправляє сам себе;
- перевіримо mocks, дані, side effects і flaky signal;
- зберемо evidence map, яку можна захищати на review.
Зелені тести можуть нічого не довести
На вигляд усе чудово: AI додав 18 тестів, suite зелений, coverage зріс. Повторний refund усе одно створює два записи в ledger. Сигнали гарні, але жоден тест не перетнув грошовий ризик.
Tests: 127 passed
Coverage: 91%
Production outcome:
refund rows: 2
ledger rows: 2
Кількість тестів не відповідає, який наслідок вони здатні помітити. Coverage показує виконаний рядок, але не силу assertion і не правильність test oracle.
- зручні гілки дають дешевий green, але можуть обходити ризиковану межу;
- 100 вузьких тестів не замінюють один тест на реальній transaction boundary;
- test diff небезпечний тим, що здатен підтвердити власну помилку.
Спочатку ризик, потім test strategy
Сервіс отримує повторний POST /refunds із тим самим idempotency key. Можливі поломки нерівноцінні. Шкода задає ціну, а recent changes, risky modules та integrations підвищують імовірність дефекту.
| Ризик | Ціна | Що спостерігаємо |
|---|---|---|
| подвійний refund | висока | один refund і один запис ledger |
| нестабільний текст помилки | низька | стабільний API contract |
Головний ризик одразу перетворюється на коротку стратегію. Для невеликого PR вона вміщується в його опис, окремий документ не обов'язковий.
Risk: duplicate money movement
Scenario: completed retry + concurrent in-flight request
Level: integration + one API contract check
Evidence: one refund id, one ledger row
Not covered: real payment provider
Короткий зв'язок risk-signal сильніший за довгий каталог cases. Рядок Not covered задає межу твердження "перевірено".
Межа ризику визначає рівень тесту
Не поспішайте з питанням "це unit чи integration?" Спочатку назвіть місце, де система може збрехати, а потім беріть мінімальний рівень, який це місце перетинає.
| Ризик живе в... | Рівень | Приклад |
|---|---|---|
| чистому правилі | unit | refund доступний протягом 14 днів |
| транзакції та БД | integration | один key створює один запис |
| публічному HTTP contract нашого сервісу | API | status, body і повторний запит |
| зібраному шляху | E2E | оператор створює refund і бачить результат |
Один endpoint може вимагати кількох рівнів, але кожен тест має відповідати на інше запитання. Інакше це дорогий дубль, а не додаткова впевненість. Назви рівнів звіряйте з conventions репозиторію.
Найдешевший тест має перетнути ризик
"Усе покрити E2E" дає повільний і крихкий suite. "Усе опустити в unit" дає швидкий suite, який не помічає зламаного з'єднання компонентів. Рухайтеся знизу вгору до першої спостережуваної межі.
Схема не призначає тест автоматично. Вона змушує назвати, де виникає signal. Test pyramid тут працює як бюджет швидкості, локалізації та супроводу, а не як норматив відсотків.
- високий рівень виправданий лише додатковою впевненістю;
- умови, надійно перевірені вузькими тестами, не копіюються на кожному наступному рівні;
- дефект із broad test варто закріпити вужчим regression test, якщо це можливо.
Mock може стерти саме той ризик, який треба перевірити
На вигляд усе переконливо. Тест підміняє RefundRepository і налаштовує його завжди повертати наявний refund. Він зелений, але унікальний індекс і транзакція в реальній БД жодного разу не брали участі.
| Межа ризику | Що залишити справжнім | Що можна замінити |
|---|---|---|
| правило в service | service | repository stub |
| унікальність і transaction | repository + test DB | payment provider fake |
| публічний HTTP contract нашого сервісу | HTTP adapter | керований provider stub |
Stub задає відповідь залежності, mock додатково перевіряє взаємодію, fake виконує спрощену робочу реалізацію. У коді перший oracle фіксує внутрішній виклик, другий спостерігає state через справжню test DB.
verify(repository).save(any());
assertThat(repository.findByOrderId(orderId))
.hasSize(1);
Ізоляція підвищує точність, лише доки не ізолює предмет доказу. Якщо ризик у БД, interaction assertion не дає integration evidence.
E2E залишає за собою критичний шлях
E2E найближчий до користувача, але найдальший від причини падіння. Один червоний signal може прийти з UI, API, service, БД або test environment.
Цей ланцюжок потрібен для одного критичного шляху: оператор створює refund, бачить результат, повтор не створює другу операцію. Решту edge cases цього flow вже перевіряють вузькі тести.
- browser E2E потрібен, коли UI додає власний ризик;
- API test дешевший, коли ризик міститься в публічному service contract;
- широкий тест підвищує впевненість у зібраній системі, але знижує точність діагнозу.
Smoke і regression - ролі, а не рівні
Список unit, integration, smoke, regression, E2E змішує два виміри. Тому питання "що вище, smoke чи integration" не має відповіді.
| Вісь | Відповідає на запитання | Приклади |
|---|---|---|
| охоплення | яка частина системи бере участь? | unit, integration, API, E2E |
| роль | яке рішення дає набір? | smoke, regression |
Один тест може мати рівень integration і роль regression. Smoke suite може містити швидкий API check і один E2E. Суперечка про назву часто приховує два запитання, які так і не поставили: яку межу перетинаємо і яке рішення приймає suite.
Готово? Межу й роль набору обрали. Тепер потрібен перший тест, чий red доведе, що ризик справді можна спостерігати.
TDD починається з red, якому можна довіряти
Regression test до виправлення корисний лише за чесного першого сигналу. Важлива не хронологія файла, а причина падіння.
Goal-driven TDD дає Claude machine-checkable termination condition: target test і решта suite зелені, lint проходить, diff залишається в scope. Після чесного red забороняємо AI змінювати test file, інакше він може переписати oracle замість production code.
- тест спочатку падає за старої поведінки з очікуваної причини;
- production change робить зеленим саме цей сценарій;
- автоматичний loop доречний для deterministic behavior та обмеженого scope; UX, архітектура й sensitive auth/payment/migration залишаються step-by-step.
Red теж уміє брехати
Червоний? Так. Корисний? Ні. AI написав integration test повторного refund, але перший запит використовує order зі статусом pending. Сценарій падає на валідації й не доходить до idempotency.
# Irrelevant red
Expected: duplicate ledger entry
Received: RefundNotAllowed: order status is pending
# After fixing the fixture
first refund: 201, refund_id=r_1042
second refund: 201, refund_id=r_1043
ledger rows: 2
Тепер тест дійшов до ризикованої межі й показав реальний дефект. Виправляти fixture у red-фазі можна, якщо тест наближається до початкового ризику, а не послаблює вимогу.
- читайте message, stack і фактичний шлях до зміни production-коду;
- не приймайте один status за доказ потрібної помилки;
- перевіряйте пов'язаний outcome: той самий refund id і один запис ledger.
Green задає межу зміни
Після чесного red AI пропонує "заодно" винести repository, перейменувати DTO й оновити спільний helper. Тест стане зеленим, але причинність розмажеться по шести файлах.
| Green, який можна захистити | Green, який створює новий ризик |
|---|---|
| атомарна перевірка key у поточній transaction boundary | новий repository abstraction |
| цільовий тест, потім сусідній модуль | спільні helpers і DTO в тому самому diff |
| refine окремим читабельним кроком | cleanup до доведення поведінки |
Мінімальний green потрібен не заради аскетизму. Він зберігає зв'язок між red і причиною зміни. Новий abstraction виправданий лише конкретним повторенням або ризиком, а не самим фактом пройденого тесту.
TDD не зобов'язаний бути першим ходом
Якщо contract ще невідомий, перший test фіксує випадкову здогадку, і далі дослідженням керує вже вона, а не вимога. У такій ситуації спочатку доречний короткий spike.
- зовнішній API невідомий, і форма відповіді ще досліджується;
- UX-поведінку обирають після показу варіанта;
- прототип можна викинути після перевірки ідеї;
- legacy-код не можна запустити ізольовано без попереднього дослідження.
Spike обмежений у часі й не видається за готову реалізацію. Коли contract стабілізувався, корисну поведінку фіксують тестом, а дослідницький код видаляють або переписують.
І це нормально: TDD знижує невизначеність реалізації, але не обирає product contract.
AI-тест проходить перехресну перевірку
Production diff відповідає "що змінилося". Test diff відповідає ще й "що вважатиметься правильним", тому його review суворіший.
- Чи міг би тест пройти, якщо початковий баг досі існує?
- Чи падав він за старої поведінки з очікуваної причини?
- Чи спостерігає outcome, а не внутрішній порядок викликів?
- Чи не замокали межу, заради якої створено тест?
- Чи збігається стиль з наявною test suite?
Спочатку попросіть AI спроєктувати кандидатів, потім реалізувати один пріоритетний сценарій. Так test strategy видно ще до появи великого diff.
First, list the risks of retrying the same refund request, including
authorization and concurrency. Map candidate tests to unit,
integration, and API levels. Do not change production code or add
a framework. Implement one risk in the existing test-suite style.
Після review кандидатів беремо найдорожчий неперевірений ризик: два запити можуть одночасно перетнути idempotency boundary.
Негативний сценарій перевіряє replay і race
Ось де послідовний retry обманює. Він не закриває race: два concurrent requests можуть одночасно побачити, що запису ще немає.
Completed replay:
first completes; retry -> same response, refund_id = r_1042
Concurrent in-flight:
release two requests through one barrier
outcomes = one success + one 409 Conflict
Assertions per case:
refund rows = 1
ledger rows = 1
provider calls = 1
Completed replay повертає попередній результат. In-flight request отримує окремий contract: 409 Conflict. Fixtures ізольовані; новий side effect заборонений.
- barrier одночасно випускає два запити до однієї межі;
- fixture фіксує eligible order, однаковий key, clock і provider;
- відповідь, рядки БД і зовнішній виклик перевіряються разом;
- оператор без refund permission отримує
403, а refund, ledger row і provider call не з'являються; - дані залишаються синтетичними, без secrets і персональних записів.
Небезпечний check-then-insert може пройти послідовний test. Один barrier-сценарій ловить race без load test.
Flaky red перестає бути доказом
Тест падає один раз із десяти. Команда звикає натискати rerun і одного разу перезапускає вже справжній дефект, доки тест не стане зеленим. Червоний signal втрачає зв'язок зі зміною коду.
- спільний state і порядок запуску змінюють результат між прогонами;
- wall clock, random і фіксовані sleep додають nondeterminism;
- реальна мережа й нестабільне середовище дають failure поза перевірюваною поведінкою;
- seed, timing, environment і logs зберігаються до діагностики.
Rerun корисний як інструмент розслідування, але не перетворює початковий failure на пройдену перевірку. Тимчасова quarantine допустима лише з owner, причиною та строком виправлення.
Нестабільний signal не можна додати до evidence map як доказ. Спочатку треба пояснити джерело nondeterminism.
До PR потрапляє карта доказів
Список 127 tests passed залишається додатком. Головне твердження PR пов'язує ризик, рівень тесту й спостережуваний результат.
Risk A: duplicate refund and ledger entry
Integration replay: completed retry -> same refund id
Integration race: concurrent requests -> one refund, one ledger row
API contract: completed replay -> same result; in-flight -> 409
Risk B: unauthorized refund
API permission: unauthorized operator -> 403, zero side effects
E2E smoke: one critical operator flow
Evidence: honest red -> green, fixed inputs, repeated runs consistent
Not covered: real payment provider
Якщо ризик не закритий, так і пишемо. Він отримує ручну перевірку, follow-up або явне прийняття. Test review окремо перевіряє false green, false red, mocks, data та nondeterminism.