Пошук уроків, статей та іншого контенту
Застосуєте TDD: сформулюєте вимогу тестом, реалізуєте мінімальний код і безпечно проведете refactoring.
TDD (Test-Driven Development) — підхід, у якому тест з’являється до реалізації функціональності.
Цикл TDD складається з трьох кроків:
Red — написати тест, який описує нову вимогу, і переконатися, що він падає.
Green — реалізувати мінімальний код, достатній для проходження тесту.
Refactor — покращити структуру коду, не змінюючи його зовнішню поведінку.
Ключова ідея: тест не просто перевіряє вже написаний код, а формулює очікувану поведінку системи.
У Node.js для цього можна використовувати вбудовані модулі:
node:test — тестовий раннер;
node:assert/strict — строгі перевірки результатів.
Зовнішній тестовий фреймворк для прикладів уроку не потрібен.
Уявімо модуль розрахунку вартості доставки.
Вимоги:
стандартна доставка коштує 599 центів;
для замовлень від 5000 центів стандартна доставка безкоштовна;
експрес-доставка коштує 1299 центів;
експрес-доставка ніколи не стає безкоштовною;
метод доставки може бути лише standard або express.
Спочатку створимо структуру проєкту:
tdd-node/
├── package.json
├── src/
│ └── delivery-fee.js
└── test/
└── delivery-fee.test.jsФайл package.json:
{
"name": "tdd-node-example",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "node --test"
}
}Починаємо з тесту, а не з реалізації.
Файл test/delivery-fee.test.js:
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateDeliveryFee } = require('../src/delivery-fee');
test('розрахунок вартості доставки', async (t) => {
await t.test('повертає стандартну вартість для малого замовлення', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 4999,
method: 'standard'
}),
599
);
});
await t.test('робить стандартну доставку безкоштовною на порозі', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 5000,
method: 'standard'
}),
0
);
});
await t.test('робить стандартну доставку безкоштовною для більшого замовлення', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 7500,
method: 'standard'
}),
0
);
});
await t.test('повертає фіксовану ціну експрес-доставки', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 10000,
method: 'express'
}),
1299
);
});
await t.test('відхиляє невідомий метод доставки', () => {
assert.throws(
() => calculateDeliveryFee({
subtotalCents: 1000,
method: 'drone'
}),
{
name: 'RangeError'
}
);
});
await t.test('відхиляє від’ємну суму замовлення', () => {
assert.throws(
() => calculateDeliveryFee({
subtotalCents: -1,
method: 'standard'
}),
{
name: 'TypeError'
}
);
});
});На цьому етапі файл src/delivery-fee.js ще не існує. Запуск тестів:
npm testзавершиться помилкою імпорту модуля. Це очікуваний стан Red: вимога вже зафіксована тестом, але реалізації ще немає.
Тепер пишемо лише код, необхідний для проходження поточних тестів.
Файл src/delivery-fee.js:
const STANDARD_FEE_CENTS = 599;
const EXPRESS_FEE_CENTS = 1299;
const FREE_DELIVERY_THRESHOLD_CENTS = 5000;
function calculateDeliveryFee(order) {
if (!order || !Number.isInteger(order.subtotalCents) || order.subtotalCents < 0) {
throw new TypeError('subtotalCents має бути невід’ємним цілим числом');
}
if (order.method !== 'standard' && order.method !== 'express') {
throw new RangeError('Невідомий метод доставки');
}
if (order.method === 'express') {
return EXPRESS_FEE_CENTS;
}
if (order.subtotalCents >= FREE_DELIVERY_THRESHOLD_CENTS) {
return 0;
}
return STANDARD_FEE_CENTS;
}
module.exports = {
calculateDeliveryFee
};Запускаємо тести ще раз:
npm testЯкщо всі тести проходять, ми досягли стану Green.
На цьому етапі не потрібно:
створювати класи;
додавати конфігураційні файли;
вводити фабрики або стратегії;
обробляти методи доставки, яких немає у вимозі;
повертати додаткові дані, наприклад опис доставки.
Код реалізує лише поведінку, яку перевіряють тести.
Це важливий принцип TDD: спочатку реалізується потрібна поведінка, а не передбачувана майбутня архітектура.
Припустімо, бізнес додає новий метод — pickup.
Нова вимога:
самовивіз коштує 0 центів;
сума замовлення для самовивозу все одно має бути валідною.
Спочатку змінюємо тест:
await t.test('самовивіз є безкоштовним', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 100,
method: 'pickup'
}),
0
);
});Цей тест спочатку впаде, тому що поточна реалізація вважає pickup невідомим методом.
Тепер оновлюємо реалізацію найпростішим способом:
if (
order.method !== 'standard' &&
order.method !== 'express' &&
order.method !== 'pickup'
) {
throw new RangeError('Невідомий метод доставки');
}
if (order.method === 'pickup') {
return 0;
}Після запуску тестів маємо знову стан Green.
Поточний код працює, але логіка методів доставки розподілена між умовами:
if (order.method !== 'standard' && ...)
if (order.method === 'express')
if (order.method === 'pickup')Кожен новий метод вимагатиме змін у кількох місцях. Це збільшує ризик помилки.
Після проходження тестів можна безпечно виконати refactoring: описати правила методів в одному об’єкті.
const DELIVERY_METHODS = Object.freeze({
standard: {
feeCents: 599,
freeAboveCents: 5000
},
express: {
feeCents: 1299,
freeAboveCents: null
},
pickup: {
feeCents: 0,
freeAboveCents: null
}
});
function validateOrder(order) {
if (
!order ||
!Number.isInteger(order.subtotalCents) ||
order.subtotalCents < 0
) {
throw new TypeError('subtotalCents має бути невід’ємним цілим числом');
}
if (!Object.hasOwn(DELIVERY_METHODS, order.method)) {
throw new RangeError('Невідомий метод доставки');
}
}
function calculateDeliveryFee(order) {
validateOrder(order);
const policy = DELIVERY_METHODS[order.method];
if (
policy.freeAboveCents !== null &&
order.subtotalCents >= policy.freeAboveCents
) {
return 0;
}
return policy.feeCents;
}
module.exports = {
calculateDeliveryFee
};Тести залишаються незмінними. Це важливо: зовнішня поведінка модуля не змінилася, змінилася лише внутрішня структура.
Знову запускаємо:
npm testЯкщо тести проходять, refactoring не порушив контракт модуля.
Якісний TDD-тест перевіряє не лише типовий сценарій.
Для правила «від 5000 центів доставка безкоштовна» особливо важливі три значення:
4999 — ще платна;
5000 — безкоштовна;
5001 — безкоштовна.
Такі тести захищають від помилок у використанні операторів:
subtotalCents > 5000замість:
subtotalCents >= 5000Також варто перевіряти невалідні вхідні дані:
test('відхиляє некоректний subtotalCents', async (t) => {
const invalidValues = [
undefined,
null,
-1,
10.5,
'1000',
NaN
];
for (const subtotalCents of invalidValues) {
await t.test(`відхиляє значення ${String(subtotalCents)}`, () => {
assert.throws(
() => calculateDeliveryFee({
subtotalCents,
method: 'standard'
}),
{
name: 'TypeError'
}
);
});
}
});Тестування невалідних даних потрібне не для збільшення кількості тестів, а для чіткого визначення контракту функції.
Refactoring під час TDD має виконуватися невеликими кроками.
Перед зміною структури переконайтеся, що тести перевіряють:
звичайні сценарії;
граничні значення;
помилкові вхідні дані;
значення, які повертаються назовні;
типи помилок, якщо вони є частиною контракту.
Під час refactoring не слід одночасно змінювати бізнес-правила.
Безпечні зміни:
виділити функцію валідації;
перейменувати внутрішню змінну;
замінити повторювані умови конфігурацією;
винести константи;
розділити велику функцію на менші.
Небезпечна зміна — одночасно змінити алгоритм і вимогу. Якщо правило змінилося, спочатку треба змінити або додати тест, отримати Red, а потім реалізувати нову поведінку.
Не варто накопичувати кілька великих змін і запускати тести лише наприкінці. Якщо тест впав після однієї невеликої зміни, причину легко знайти.
Практичний цикл виглядає так:
написати один тест
→ запустити тести
→ реалізувати мінімальну поведінку
→ запустити тести
→ зробити один refactoring-крок
→ запустити тестиФінальний src/delivery-fee.js:
const DELIVERY_METHODS = Object.freeze({
standard: {
feeCents: 599,
freeAboveCents: 5000
},
express: {
feeCents: 1299,
freeAboveCents: null
},
pickup: {
feeCents: 0,
freeAboveCents: null
}
});
function validateOrder(order) {
if (
!order ||
!Number.isInteger(order.subtotalCents) ||
order.subtotalCents < 0
) {
throw new TypeError('subtotalCents має бути невід’ємним цілим числом');
}
if (!Object.hasOwn(DELIVERY_METHODS, order.method)) {
throw new RangeError('Невідомий метод доставки');
}
}
function calculateDeliveryFee(order) {
validateOrder(order);
const policy = DELIVERY_METHODS[order.method];
if (
policy.freeAboveCents !== null &&
order.subtotalCents >= policy.freeAboveCents
) {
return 0;
}
return policy.feeCents;
}
module.exports = {
calculateDeliveryFee
};Фінальний test/delivery-fee.test.js:
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateDeliveryFee } = require('../src/delivery-fee');
test('розрахунок вартості доставки', async (t) => {
await t.test('повертає стандартну вартість для малого замовлення', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 4999,
method: 'standard'
}),
599
);
});
await t.test('робить стандартну доставку безкоштовною на порозі', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 5000,
method: 'standard'
}),
0
);
});
await t.test('експрес-доставка не залежить від суми замовлення', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 10000,
method: 'express'
}),
1299
);
});
await t.test('самовивіз є безкоштовним', () => {
assert.equal(
calculateDeliveryFee({
subtotalCents: 100,
method: 'pickup'
}),
0
);
});
await t.test('відхиляє невідомий метод доставки', () => {
assert.throws(
() => calculateDeliveryFee({
subtotalCents: 1000,
method: 'drone'
}),
{
name: 'RangeError'
}
);
});
await t.test('відхиляє від’ємну суму замовлення', () => {
assert.throws(
() => calculateDeliveryFee({
subtotalCents: -1,
method: 'standard'
}),
{
name: 'TypeError'
}
);
});
});Запуск:
npm testЦей приклад використовує лише стандартні можливості Node.js і може бути виконаний без встановлення додаткових залежностей.
Якщо спочатку написати весь код, а потім тест, тест часто лише повторює структуру реалізації. У TDD тест має походити з вимоги, а не з уже обраного алгоритму.
Мета етапу Green — пройти поточний тест, а не реалізувати всі можливі майбутні сценарії.
Передчасна універсальність ускладнює refactoring і приховує справжні вимоги.
Тест має перевіряти публічну поведінку:
assert.equal(calculateDeliveryFee(order), 599);Не варто перевіряти, що всередині використовується конкретна константа, об’єкт або кількість умов. Інакше будь-який нешкідливий refactoring вимагатиме переписування тестів.
Перевірка лише замовлення на 1000 центів не гарантує коректність правила для порога 5000. Граничні значення часто виявляють помилки в операторах порівняння.
Якщо після refactoring змінилося бізнес-правило, це вже не лише refactoring. Таку зміну потрібно оформити окремим тестом і пройти цикл Red → Green → Refactor.
У прикладі суми зберігаються в центах як цілі числа. Це усуває помилки, пов’язані з арифметикою чисел із плаваючою комою.
TDD у Node.js — це послідовний цикл:
тестом сформулювати одну конкретну вимогу;
переконатися, що тест падає;
написати мінімальну реалізацію;
переконатися, що тест проходить;
покращити структуру коду без зміни поведінки;
знову запустити весь набір тестів.
Для практичного TDD важливо:
тестувати публічний контракт;
включати граничні та негативні сценарії;
зберігати тести незалежними;
робити невеликі refactoring-кроки;
не змішувати зміну вимоги зі зміною структури коду;
використовувати вбудований node:test, якщо зовнішній фреймворк не потрібен.