Пошук уроків, статей та іншого контенту
Виміряєте Code Coverage, визначите корисні метрики та уникнете формального покриття заради відсотків.
Code Coverage — це набір метрик, який показує, яка частина коду була виконана під час запуску автоматизованих тестів.
Покриття допомагає знайти:
функції, які взагалі не тестуються;
невиконані гілки умов;
код, для якого немає жодного тестового сценарію;
ділянки, де потрібні додаткові перевірки.
Однак високий відсоток покриття не гарантує якість тестів. Тест може виконати рядок коду, але не перевірити правильність отриманого результату.
Наприклад, такий тест збільшує покриття, але майже нічого не перевіряє:
calculateDiscount(1200, true);Тест не містить жодного твердження. Він лише запускає функцію.
Якісний тест має перевіряти очікувану поведінку:
assert.equal(calculateDiscount(1200, true), 0.2);Інструменти для Node.js зазвичай показують кілька метрик.
Показує, який відсоток рядків було виконано під час тестів.
Якщо покриття рядків становить 90%, це означає, що тестовий набір виконав приблизно 90% інструментованих рядків коду.
Недолік метрики: один виконаний рядок може містити кілька різних сценаріїв, які не були перевірені.
Показує, який відсоток функцій був викликаний тестами.
Ця метрика допомагає швидко знайти повністю неперевірені функції, але не показує, чи перевірені різні результати їхньої роботи.
Показує, який відсоток виконуваних операторів був виконаний.
Ця метрика подібна до покриття рядків, але точніше працює для випадків, коли в одному рядку міститься кілька операторів.
Показує, чи були виконані різні шляхи виконання умов:
if та else;
тернарні оператори;
логічні умови;
гілки в конструкціях switch.
Саме покриття гілок часто виявляє слабкі тести. Рядок з умовою може бути виконаний, але лише одна з його гілок може бути перевірена.
Для проєктів Node.js часто використовують пакет c8. Він збирає дані покриття за допомогою вбудованого механізму V8.
Встановіть його як залежність для розробки:
npm install --save-dev c8Для прикладу використаємо вбудований тестовий модуль node:test, доступний у сучасних версіях Node.js.
Структура файлів:
project/
├── discount.js
├── discount.test.js
└── package.jsonФайл package.json:
{
"name": "coverage-example",
"version": "1.0.0",
"scripts": {
"test": "node --test",
"coverage": "c8 --reporter=text --reporter=html npm test"
},
"devDependencies": {
"c8": "^10.1.2"
}
}Версію c8 можна встановити командою npm install, не редагуючи її вручну.
Файл discount.js:
function calculateDiscount(total, isMember) {
if (!Number.isFinite(total) || total < 0) {
throw new TypeError('Total must be a non-negative number');
}
if (total >= 1000) {
return isMember ? 0.2 : 0.1;
}
if (isMember) {
return 0.05;
}
return 0;
}
function calculateFinalPrice(total, isMember) {
const discount = calculateDiscount(total, isMember);
return total * (1 - discount);
}
module.exports = {
calculateDiscount,
calculateFinalPrice
};У функції є кілька важливих сценаріїв:
некоректна сума;
велике замовлення учасника програми;
велике замовлення звичайного клієнта;
мале замовлення учасника програми;
мале замовлення звичайного клієнта.
Файл discount.test.js:
const test = require('node:test');
const assert = require('node:assert/strict');
const {
calculateDiscount,
calculateFinalPrice
} = require('./discount');
test('повертає знижку для великого замовлення учасника', () => {
assert.equal(calculateDiscount(1200, true), 0.2);
});
test('повертає знижку для великого замовлення звичайного клієнта', () => {
assert.equal(calculateDiscount(1200, false), 0.1);
});
test('повертає знижку для малого замовлення учасника', () => {
assert.equal(calculateDiscount(500, true), 0.05);
});
test('не повертає знижку для малого замовлення звичайного клієнта', () => {
assert.equal(calculateDiscount(500, false), 0);
});
test('обчислює фінальну ціну після знижки', () => {
assert.equal(calculateFinalPrice(1200, true), 960);
});
test('відхиляє від’ємну суму', () => {
assert.throws(
() => calculateDiscount(-1, false),
{
name: 'TypeError',
message: 'Total must be a non-negative number'
}
);
});
test('відхиляє нечислове значення', () => {
assert.throws(
() => calculateDiscount('1200', true),
{
name: 'TypeError',
message: 'Total must be a non-negative number'
}
);
});Запустіть тести:
npm testЗапустіть перевірку покриття:
npm run coverageУ терміналі c8 покаже приблизно такі категорії:
Statements : 100%
Branches : 100%
Functions : 100%
Lines : 100%Точний формат і кількість рядків залежать від версії Node.js та c8.
Також буде створено директорію coverage з HTML-звітом. Вона дозволяє побачити, які конкретно рядки або гілки не були виконані.
Покриття показує виконання коду, а не правильність тестів.
Розглянемо функцію:
function isEligibleForFreeDelivery(total) {
return total >= 1000;
}Такий тест забезпечить виконання функції:
test('перевіряє безкоштовну доставку', () => {
assert.equal(isEligibleForFreeDelivery(1200), true);
});Функція матиме повне покриття рядків і функцій. Але тест не перевіряє випадок, коли сума менша за поріг:
test('безкоштовна доставка недоступна для малого замовлення', () => {
assert.equal(isEligibleForFreeDelivery(500), false);
});У простій функції це очевидно. У складнішому коді пропущена гілка може містити:
неправильне оброблення помилки;
некоректне значення за замовчуванням;
помилку на граничному значенні;
небезпечний сценарій доступу до даних.
Тому показник покриття потрібно використовувати як інструмент пошуку прогалин, а не як заміну аналізу тестів.
Тест має містити твердження про очікувану поведінку.
Погано:
test('обробляє замовлення', () => {
calculateFinalPrice(1200, true);
});Добре:
test('застосовує знижку до фінальної ціни', () => {
assert.equal(calculateFinalPrice(1200, true), 960);
});Позитивний сценарій перевіряє коректні вхідні дані.
Негативний сценарій перевіряє, як код поводиться з помилковими або неприйнятними даними.
Для calculateDiscount такими сценаріями є:
правильна сума;
від’ємна сума;
рядок замість числа;
NaN;
Infinity.
Помилки часто виникають саме на межах умов.
Для умови total >= 1000 важливі значення:
999;
1000;
1001.
Приклад:
test('не застосовує велику знижку до суми 999', () => {
assert.equal(calculateDiscount(999, true), 0.05);
});
test('застосовує велику знижку до суми 1000', () => {
assert.equal(calculateDiscount(1000, true), 0.2);
});Якщо код містить умову, тести мають перевіряти кожен суттєвий результат цієї умови.
Для такого коду:
if (isMember) {
return 0.05;
}
return 0;потрібні тести і для isMember === true, і для isMember === false.
Важливо перевіряти не тільки факт помилки, а й за потреби її тип або повідомлення.
test('повертає помилку для NaN', () => {
assert.throws(
() => calculateDiscount(Number.NaN, false),
TypeError
);
});Так тест фіксує контракт функції: для некоректного значення має виникнути саме TypeError.
Кожен тест має бути зрозумілим і не залежати від порядку запуску інших тестів.
Не варто:
використовувати результат попереднього тесту;
покладатися на змінний глобальний стан;
змінювати спільний об’єкт без відновлення;
робити тести залежними від поточного часу або випадкових значень.
Незалежні тести легше запускати окремо, повторювати та підтримувати.
Тест не повинен повторювати алгоритм функції.
Якщо функція обчислює знижку формулою, тест має містити очікуване бізнес-значення, а не копіювати цю формулу:
assert.equal(calculateFinalPrice(1200, true), 960);Якщо тест повторює ту саму помилкову логіку, що й production-код, він може не виявити дефект.
Низьке покриття може означати:
функціональність не має тестів;
існують невраховані сценарії;
у коді є недосяжні або зайві гілки;
тестовий набір перевіряє лише основний сценарій.
Не кожен рядок потребує окремого тесту. Наприклад, простий делегуючий код може не вимагати великої кількості сценаріїв. Але кожна важлива поведінка програми повинна мати перевірку.
Під час аналізу звіту ставте запитання:
Чому ця гілка не була виконана?
Це реальний сценарій користувача чи недосяжний код?
Яка помилка може виникнути в цій гілці?
Чи потрібен тест для граничного значення?
Чи перевіряє наявний тест результат?
У командному проєкті можна встановити мінімальні пороги покриття. Якщо результат нижчий за поріг, команда або система CI вважатиме перевірку невдалою.
Приклад команди:
npx c8 --check-coverage --lines 90 --functions 90 --branches 85 --statements 90 npm testТут встановлено мінімальні значення:
90% рядків;
90% функцій;
85% гілок;
90% операторів.
Пороги корисні для запобігання поступовому погіршенню покриття. Але вони не повинні стимулювати додавання формальних тестів без перевірки результатів.
Краще мати 85% покриття з тестами важливих сценаріїв, ніж 100% покриття з тестами, які лише запускають код.
Високий відсоток не доводить, що поведінка перевірена.
Як уникати: аналізуйте твердження, граничні значення, помилки та всі важливі гілки.
assertТакий тест може виконати код, але не перевірити його результат.
Як уникати: кожен тест повинен містити чітке очікування.
Код може працювати для правильних даних, але аварійно завершуватися для неправильних.
Як уникати: додавайте тести для помилкових даних і очікуваних винятків.
Покриття рядків може бути високим, хоча частина умов ніколи не перевіряється.
Як уникати: переглядайте метрику Branches і тестуйте обидві сторони умов.
Низький поріг не захищає від погіршення якості. Безумовний поріг у 100% може заохочувати формальні тести.
Як уникати: встановлюйте реалістичні пороги та переглядайте їх разом із якістю тестових сценаріїв.
Тести, прив’язані до конкретної структури коду, ламаються після безпечного рефакторингу.
Як уникати: перевіряйте публічний результат функції та її контракт.
Code Coverage показує, яка частина коду була виконана тестами.
Основні метрики — рядки, оператори, функції та гілки.
Покриття гілок допомагає знаходити неперевірені шляхи виконання.
Високий відсоток сам по собі не означає якісні тести.
Якісні тести перевіряють результати, помилки, межі та різні варіанти поведінки.
Пороги покриття корисні як захист від погіршення, але не повинні бути єдиною ціллю.
Звіт покриття потрібно використовувати для пошуку пропущених сценаріїв, а не для формального досягнення певного відсотка.