vyvchy
    Теми розділу

    02 · Тест-дизайн

    Білоскринькові техніки: покриття коду

    Зміст

    У pull request автотестів зʼявляється коментар від CI: «coverage впав з 84% до 81%, мердж заблоковано». Що саме впало? Чи означають ці 84%, що 84% поведінки продукту перевірено? І чому розробник поруч каже, що їхні 100% на модулі оплати «нічого не гарантують»? Без розуміння метрик покриття коду ці розмови звучать як шаманство — а на співбесідах рівня middle питання «чим statement coverage відрізняється від branch coverage» стало класикою, бо за дві хвилини показує, чи розуміє кандидат, що вимірює метрика, якою всі звітують.

    Це глава-поглиблення: при першому проході розділу її можна пропустити — базовий трек від класів еквівалентності до вибору технік самодостатній без неї. Повертайтеся сюди, коли почнете писати автотести або коли у вашій команді coverage стане критерієм якості пайплайна. Водночас це єдина глава на сайті, де тема покриття коду розібрана повністю, — решта глав лише посилаються сюди.

    Що таке білоскринькові техніки

    Усі техніки попередніх глав — специфікаційні (black-box): тест-базисом служать вимоги, а код — чорна скринька. Білоскринькові, або структурні, техніки (white-box, structure-based) перевертають картину: тест-базис — сам код, його внутрішня структура. Питання «що перевірити?» перетворюється на «які частини коду мої тести реально виконали, а які — ні» (класифікацію технік ми вводили в главі «Тест-дизайн: від вимоги до перевірки»).

    Звідси два практичні застосування:

    • Дизайн тестів: дивимось на код і додаємо тести, поки не виконається кожен оператор, кожна гілка — залежно від обраного рівня.
    • Вимірювання: запускаємо наявні тести (спроєктовані будь-якою технікою) під інструментом покриття й дивимося, що вони зачепили. Саме так coverage найчастіше живе в командах — як метрика, а не як техніка дизайну.

    Кожен рівень покриття відповідає на питання «виконано чи ні» для своєї одиниці вимірювання: оператор, гілка, умова, шлях. Що дрібніша одиниця — то сильніша гарантія і то дорожчий набір тестів.

    Покриття операторів (statement coverage)

    Найслабша і найпростіша метрика: частка виконуваних операторів (statements) коду, які тести виконали хоча б раз.

    statement coverage = виконані оператори / усі виконувані оператори × 100%
    

    Візьмімо функцію знижки:

    function applyDiscount(price: number, isVip: boolean): number {
      let total = price;          // оператор 1
      if (isVip) {                // оператор 2
        total = total * 0.9;      // оператор 3
      }
      return total;               // оператор 4
    }

    Один тест applyDiscount(100, true) виконує всі чотири оператори — 100% statement coverage досягнуто одним тестом. Звучить як перемога, але зверніть увагу: сценарій «не VIP» ми не запускали взагалі. Якби хтось зламав код так, що знижка застосовується всім, — тест лишився б зеленим.

    Покриття гілок (branch coverage) і чому 100% statement ≠ 100% branch

    Покриття гілок (branch coverage) вимагає, щоб кожне рішення в коді відпрацювало в обидва боки: кожен if — і true, і false; кожна умова циклу — і «зайшли», і «пропустили». На практиці його ототожнюють з покриттям рішень (decision coverage): формально ISTQB розрізняє ці метрики, але на рівні 100% вони збігаються. Одиниця вимірювання — не оператор, а ребро на графі потоку керування (control flow graph).

    Подивимось на applyDiscount як на граф:

    true

    false

    total = price

    isVip?

    total = total * 0.9

    return total

    true

    false

    total = price

    isVip?

    total = total * 0.9

    return total

    Тест applyDiscount(100, true) проходить маршрут A → B → C → D. Усі вузли (оператори) відвідано — 100% statement. Але ребро B → D, гілка false, лишилося непокритим: branch coverage — 50%. Ось і вся відповідь на класичне співбесідне «чому 100% statement не гарантує 100% branch»: if без else виконує всі оператори на гілці true, а порожня гілка false операторів не має — statement-метриці нема за що зачепитися. Потрібен другий тест, applyDiscount(100, false).

    Звідси правило підпорядкування (subsumption): 100% branch coverage автоматично дає 100% statement coverage, а навпаки — ні. Branch — строго сильніша метрика. Саме тому в силабусі ISTQB CTFL 4.0 (розділ 4.3) із білоскринькових технік лишили саме цю пару — statement і branch: перша як мінімальна база, друга як розумний робочий стандарт.

    Практична вага для тестувальника: гілка false у if без else — це той самий «негативний сценарій», який ми звикли шукати специфікаційними техніками. Порожня гілка — улюблене місце багів на кшталт «забули обробити відсутність знижки/токена/зʼєднання».

    Покриття умов і MC/DC

    Рішення бувають складеними. Ускладнимо код:

    function freeShipping(isVip: boolean, total: number): boolean {
      return isVip && total > 1000;
    }

    Тут одне рішення (весь вираз), але дві атомарні умови: isVip і total > 1000. Branch coverage вимагає, щоб рішення загалом було і true, і false, — два тести. Але при цьому окрема умова може жодного разу не «сіпнутися»: пара тестів (true, 1500) і (true, 500) дає обидва результати рішення, а умова isVip завжди true — якщо її випадково інвертують, ці тести нічого не помітять.

    Рівні, що працюють з умовами:

    РівеньВимогаМінімум тестів для A && B
    Покриття умов (condition coverage)Кожна атомарна умова була і true, і false2
    MC/DC (modified condition/decision coverage)Кожна умова була в обох значеннях і незалежно вплинула на результат рішення3
    Повне комбінаторне (multiple condition coverage)Усі комбінації значень умов4

    Пастка condition coverage: тести (true, 500) і (false, 1500) дають кожній умові обидва значення — формально 100% — але рішення обидва рази false. Покриття умов саме по собі не гарантує навіть покриття гілок. (Нюанс реального коду: && у JS/TS обчислюється ліниво — short-circuit, — тож при isVip = false друга умова взагалі не виконується; класична теорія покриття умов цим нехтує, а інструменти на кшталт Istanbul рахують кожен операнд окремо.)

    MC/DC закриває цю діру: для кожної умови має існувати пара тестів, де змінюється лише вона — і разом з нею змінюється результат рішення. Для isVip && total > 1000:

    1. (true, 1500) → true
    2. (false, 1500) → false — порівняно з першим змінився лише isVip, результат перевернувся: isVip впливає;
    3. (true, 500) → false — порівняно з першим змінилася лише друга умова, результат перевернувся: вона теж впливає.

    Три тести замість чотирьох комбінаторних. Загальне правило: для рішення з n умов MC/DC досяжне щонайменше за n + 1 тестів, тоді як повний перебір коштує 2^n — для восьми умов це 9 тестів проти 256. Саме за цю економію зі збереженням сильної гарантії MC/DC вимагають стандарти критичного ПЗ: авіаційний DO-178C приписує MC/DC для софту найвищого рівня критичності (Level A). У вебі MC/DC руками майже не рахують, але на співбесіді це улюблена перевірка «чи розуміє кандидат різницю між рішенням і умовою».

    Ієрархія сили метрик:

    Statement

    Branch / Decision

    MC/DC

    Multiple condition

    Statement

    Branch / Decision

    MC/DC

    Multiple condition

    Кожна наступна включає гарантії попередньої і коштує дорожче. Окремо від цього ланцюжка стоїть покриття шляхів — про нього далі.

    Шляхи і цикломатична складність

    Покриття шляхів (path coverage) вимагає виконати кожен маршрут через функцію від входу до виходу. Це найсильніша з метрик потоку керування — і практично недосяжна: три послідовні незалежні if дають 2 × 2 × 2 = 8 шляхів, десять — уже 1024, а цикл із заздалегідь невідомою кількістю ітерацій робить число шляхів практично нескінченним. Тому 100% path coverage — теоретичний орієнтир, а не робоча ціль.

    Компромісну відповідь дав Томас Маккейб ще 1976 року: цикломатична складність (cyclomatic complexity) — кількість лінійно незалежних шляхів через код. Для графа потоку керування рахується як E − N + 2 (ребра мінус вузли плюс два), але на практиці простіше: кількість бінарних рішень у коді плюс один. Функція без розгалужень має складність 1; наш applyDiscount з одним if — 2; функція з if, циклом і && в умові — 4.

    Чим це корисно QA:

    • Оцінка кількості тестів. Цикломатична складність — це верхня межа кількості тестів, потрібних для повного branch coverage, і нижня межа кількості усіх шляхів. Бачите функцію зі складністю 12 — щонайменше стільки ж базисних маршрутів варто мати на увазі, коли оцінюєте повноту тестів.
    • Сигнал ризику. Висока складність зазвичай іде в парі з дефектністю і болем супроводу. Лінтери (наприклад, правило complexity в ESLint) вміють її рахувати; функція-монстр зі складністю 20+ — аргумент і за рефакторинг, і за пріоритетне тестове покриття цього місця. Це прямий місток до ризик-орієнтованого підходу з розділу «Основи тестування»: складність — один з індикаторів «ймовірності» в матриці ризиків.

    Як читати coverage-звіт

    В екосистемі JS/TS покриття рахують Istanbul (його CLI-обгортка — nyc), c8 поверх вбудованого механізму покриття V8, а тест-ранери на кшталт Jest чи Vitest мають ці інструменти під капотом. Звіт зазвичай показує чотири числа:

    МетрикаЩо рахує
    StatementsЧастку виконаних операторів
    BranchesЧастку відпрацьованих гілок рішень
    FunctionsЧастку викликаних функцій
    LinesЧастку виконаних фізичних рядків

    Lines і statements — не синоніми: у рядку if (a) b(); два оператори, і рядкова метрика покаже 100%, коли виконався лише перший. Через це branch — найчесніше з чотирьох чисел: воно падає першим і саме за ним видно недотестовані сценарії.

    У HTML-звіті Istanbul непокриті оператори підсвічені червоним, а біля рішень стоять маркери I та E — гілка if або else, яку жоден тест не виконав. Робочий сценарій читання звіту — не «дивитися на відсоток», а спускатися у файли критичних модулів і дивитися, які саме гілки лишилися сірими: часто це обробники помилок, ранні return на невалідних даних і catch-блоки — тобто рівно ті негативні сценарії, які найдорожче пропустити.

    Покриття можна зняти й з e2e-прогону: у Playwright є API page.coverage.startJSCoverage() / stopJSCoverage(), який збирає дані про виконаний у браузері код (працює лише в Chromium-браузерах). Так можна побачити, які частини фронтенду ваша e2e-сюїта взагалі не торкається. У CI-пайплайні на покриття вішають пороги — quality gate, що валить білд при падінні нижче заданого відсотка; механіку таких гейтів розбирає розділ «Git і CI/CD».

    І головне розмежування: покриття коду відповідає на питання «що з написаного виконано», покриття вимог — «що з обіцяного перевірено». Це різні осі: можна мати 100% по коду і нуль по вимозі, яку забули реалізувати, — коду ж немає, вимірювати нічого. Про другу вісь — глава «Покриття вимог і трасування».

    Пастка «100% покриття»

    Найважливіше, що треба винести: покриття вимірює виконання, а не перевірку. Тест, який викликає функцію і нічого не assert-ить, дає рівно те саме покриття, що й тест із суворими перевірками. Доведений до абсурду приклад:

    test('coverage без перевірки', async () => {
      applyDiscount(100, true);
      applyDiscount(100, false);
      // жодного expect — а branch coverage 100%
    });

    Метрика зелена, гарантій — нуль. Тому «у нас 95% coverage» саме по собі не каже про якість тестів нічого: це метрика повноти виконання, необхідна, але не достатня умова. Висока цифра корисна навпаки — як детектор дірок: 60% branch у модулі оплати — це факт, що 40% гілок не виконує жоден тест, і ось це вже привід діяти.

    Друга половина пастки — Гудхартова: щойно відсоток покриття стає KPI, команда починає оптимізувати відсоток, а не якість. Зʼявляються тести на геттери, тести без асертів, виключення «незручних» файлів зі збору. Здорова практика — тримати поріг як запобіжник від деградації (не даємо новому коду вливатися зовсім без тестів), а рішення про глибину перевірок приймати за ризиком, не за цифрою.

    Типові помилки

    • Виглядає як «100% statement — усе протестовано», а насправді непокритою може бути половина гілок: if без else дає повний statement одним позитивним тестом, а негативний сценарій не виконаний жодного разу.
    • Виглядає як «coverage 90%, тести якісні», а насправді покриття не бачить асертів: сюїта може виконувати код і нічого не перевіряти. Відсоток вимірює виконання, якість перевірок — тільки ревʼю.
    • Виглядає як «100% покриття коду = продукт перевірено», а насправді метрика сліпа до відсутнього коду: нереалізована вимога — це нуль рядків, і на відсоток вона не впливає. Пропуски ловить трасування вимог, не coverage.
    • Виглядає як «line coverage і statement coverage — одне й те саме», а насправді одиниці різні: у рядку може сидіти кілька операторів, і рядкова метрика систематично оптимістичніша.
    • Виглядає як «100% branch = перевірені всі комбінації умов», а насправді branch дивиться лише на результат рішення загалом; вплив кожної атомарної умови гарантує тільки MC/DC, а всі комбінації — multiple condition coverage за ціною 2^n.

    Підсумок

    • Білоскринькові техніки беруть за тест-базис код: одиниця покриття — оператор, гілка, умова або шлях; що дрібніша одиниця, то сильніша гарантія і дорожчий набір тестів.
    • Ієрархія сили: statement ← branch ← MC/DC ← multiple condition; 100% сильнішої метрики гарантує 100% слабшої, навпаки — ні. Класичний доказ — if без else.
    • MC/DC вимагає, щоб кожна атомарна умова незалежно перевернула результат рішення: n + 1 тестів замість 2^n — стандарт критичних систем (DO-178C, Level A).
    • Цикломатична складність = кількість лінійно незалежних шляхів (рішення + 1): верхня межа тестів для повного branch coverage і сигнал ризику для пріоритизації.
    • Покриття вимірює виконання, а не перевірку, і не бачить ненаписаного коду: низький відсоток — це факт і привід діяти, високий — ще не гарантія якості.

    Що питають на співбесіді

    • «Чим statement coverage відрізняється від branch coverage?» — питання-фільтр. Сильна відповідь обовʼязково містить приклад if без else: один позитивний тест дає 100% операторів і 50% гілок. Інтервʼюер дивиться, чи кандидат розуміє механізм, а не завчив означення.
    • «100% покриття коду означає, що багів немає?» — чекають упевненого «ні» з двома причинами: покриття не перевіряє асерти і не бачить нереалізованих вимог. Бонус — згадати, що низьке покриття інформативніше за високе.
    • «Що таке MC/DC і навіщо воно, якщо є branch coverage?» — перевірка глибини для middle+. Достатньо пояснити різницю «рішення vs атомарна умова» на прикладі A && B і згадати n + 1 проти 2^n; контекст авіоніки — плюс у карму.
    • «Що таке цикломатична складність і як нею користується QA?» — очікують «кількість незалежних шляхів, рішення плюс один» і практичний кут: оцінка мінімального числа тестів та індикатор ризикових місць коду.
    • «У проєкті coverage 85%. Що ви з цим числом зробите?» — питання на зрілість. Слабка відповідь — «підніму до 100%». Сильна — «подивлюся branch-покриття критичних модулів, знайду непокриті гілки обробки помилок, звірю з покриттям вимог і ризиками». Інтервʼюер шукає людину, яка працює зі звітом, а не з відсотком.

    Джерела

    • ISTQB CTFL Syllabus v4.0 — розділ 4.3 «White-Box Test Techniques»: statement testing, branch testing і цінність білоскринькового підходу; ця глава покриває його і розширює до MC/DC та шляхів.
    • ISTQB Glossary — канонічні означення statement coverage, branch coverage, MC/DC, cyclomatic complexity.
    • Istanbul — документація класичного інструмента покриття для JS/TS: метрики, формати звітів, пороги.
    • Playwright: Coverage API — збір покриття JS/CSS з браузера в e2e-тестах (Chromium).