Тестування та verification: стратегія, TDD і рівні тестів

Один зелений regression test відповідає лише на одне запитання. Навколо зміни потрібен набір доказів, де кожен тест ловить конкретний ризик і перетинає межу, на якій цей ризик можна побачити.

Розберемо грошовий сценарій сервісу повернень: повторний refund із тим самим idempotency key не має створити другу операцію. На ньому видно, чому кількість тестів і coverage самі по собі нічого не гарантують.

Сьогодні пройдемо: рівень 19. Test strategy, goal-driven TDD, рівні unit/integration/API, ролі E2E/smoke/regression і test quality review.

Зелені тести можуть нічого не довести

На вигляд усе чудово: AI додав 18 тестів, suite зелений, coverage зріс. Повторний refund усе одно створює два записи в ledger. Сигнали гарні, але жоден тест не перетнув грошовий ризик.

Tests: 127 passed
Coverage: 91%

Production outcome:
refund rows: 2
ledger rows: 2

Кількість тестів не відповідає, який наслідок вони здатні помітити. Coverage показує виконаний рядок, але не силу assertion і не правильність test oracle.


Спочатку ризик, потім 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?" Спочатку назвіть місце, де система може збрехати, а потім беріть мінімальний рівень, який це місце перетинає.

Ризик живе в...РівеньПриклад
чистому правиліunitrefund доступний протягом 14 днів
транзакції та БДintegrationодин key створює один запис
публічному HTTP contract нашого сервісуAPIstatus, body і повторний запит
зібраному шляхуE2Eоператор створює refund і бачить результат

Один endpoint може вимагати кількох рівнів, але кожен тест має відповідати на інше запитання. Інакше це дорогий дубль, а не додаткова впевненість. Назви рівнів звіряйте з conventions репозиторію.


Найдешевший тест має перетнути ризик

"Усе покрити E2E" дає повільний і крихкий suite. "Усе опустити в unit" дає швидкий suite, який не помічає зламаного з'єднання компонентів. Рухайтеся знизу вгору до першої спостережуваної межі.

flowchart LR A["Ризик"] --> B{"Ризик у чистому правилі?"} B -->|Так| C["Unit"] B -->|Ні| D{"Досить перевірити одну межу?"} D -->|Так| E["Integration або API"] D -->|Ні| F{"Потрібен зібраний шлях?"} F -->|Так| G["E2E критичного шляху"] F -->|Ні| H["Інший verification або uncovered risk"]

Схема не призначає тест автоматично. Вона змушує назвати, де виникає signal. Test pyramid тут працює як бюджет швидкості, локалізації та супроводу, а не як норматив відсотків.


Mock може стерти саме той ризик, який треба перевірити

На вигляд усе переконливо. Тест підміняє RefundRepository і налаштовує його завжди повертати наявний refund. Він зелений, але унікальний індекс і транзакція в реальній БД жодного разу не брали участі.

Межа ризикуЩо залишити справжнімЩо можна замінити
правило в serviceservicerepository stub
унікальність і transactionrepository + test DBpayment 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.

flowchart LR UI["UI"] --> API["API"] API --> S["Refund service"] S --> DB["Test DB"] S --> P["Provider fake"]

Цей ланцюжок потрібен для одного критичного шляху: оператор створює refund, бачить результат, повтор не створює другу операцію. Решту edge cases цього flow вже перевіряють вузькі тести.


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 до виправлення корисний лише за чесного першого сигналу. Важлива не хронологія файла, а причина падіння.

flowchart LR R["RED"] --> Q{"Падає з очікуваної причини?"} Q -->|Ні| R Q -->|Так| G["GREEN: мінімальний change"] G --> F["REFINE за зеленого signal"] F --> R

Goal-driven TDD дає Claude machine-checkable termination condition: target test і решта suite зелені, lint проходить, diff залишається в scope. Після чесного red забороняємо AI змінювати test file, інакше він може переписати oracle замість production code.

Червоний колір без правильної причини так само небезпечний, як false green: він створює переконливу історію для нерелевантного тесту.

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-фазі можна, якщо тест наближається до початкового ризику, а не послаблює вимогу.


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.

Spike обмежений у часі й не видається за готову реалізацію. Коли contract стабілізувався, корисну поведінку фіксують тестом, а дослідницький код видаляють або переписують.

"Спочатку код, тести потім" - не універсальна лазівка. Для зрозумілого грошового правила й відтворюваного бага TDD дешевше втримує причинність.

І це нормально: TDD знижує невизначеність реалізації, але не обирає product contract.


AI-тест проходить перехресну перевірку

Production diff відповідає "що змінилося". Test diff відповідає ще й "що вважатиметься правильним", тому його review суворіший.

  1. Чи міг би тест пройти, якщо початковий баг досі існує?
  2. Чи падав він за старої поведінки з очікуваної причини?
  3. Чи спостерігає outcome, а не внутрішній порядок викликів?
  4. Чи не замокали межу, заради якої створено тест?
  5. Чи збігається стиль з наявною 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 заборонений.

Небезпечний check-then-insert може пройти послідовний test. Один barrier-сценарій ловить race без load test.


Flaky red перестає бути доказом

Тест падає один раз із десяти. Команда звикає натискати rerun і одного разу перезапускає вже справжній дефект, доки тест не стане зеленим. Червоний signal втрачає зв'язок зі зміною коду.

Rerun корисний як інструмент розслідування, але не перетворює початковий failure на пройдену перевірку. Тимчасова quarantine допустима лише з owner, причиною та строком виправлення.

Flaky test навчає команду ігнорувати червоний колір. Це небезпечніше за кілька втрачених хвилин на прогін.

Нестабільний 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.

Подумки видаліть будь-який тест і запитайте: "Яке твердження про change перестане бути доведеним?" Якщо відповіді немає, тест дублює evidence або перевіряє не той ризик.