Пошук уроків, статей та іншого контенту
Напишете ізольовані unit-тести для функцій, сервісів і модулів без залежності від зовнішніх систем.
Unit-тест перевіряє найменшу логічно завершену частину програми:
функцію;
метод;
модуль;
сервіс із підставними залежностями.
Unit-тест не повинен залежати від:
бази даних;
HTTP-запитів;
файлової системи;
реального часу;
інших зовнішніх систем.
Тест має бути швидким, передбачуваним і запускатися однаково на локальному комп’ютері та в CI.
Наприклад, замість підключення сервісу до справжньої бази даних ми передаємо йому простий об’єкт із тестовими функціями.
Node.js має вбудований модуль node:test. Для базових unit-тестів не потрібно встановлювати додаткові бібліотеки.
Використовуватимемо також модуль node:assert/strict для перевірки результатів.
Створимо таку структуру проєкту:
project/
├── package.json
├── src/
│ ├── discount.js
│ └── userService.js
└── test/
└── unit.test.jsФайл package.json:
{
"name": "unit-testing-example",
"version": "1.0.0",
"scripts": {
"test": "node --test"
}
}Для прикладів потрібен Node.js 18 або новіший.
Створимо функцію, яка обчислює кінцеву ціну товару після знижки.
Файл src/discount.js:
function calculateFinalPrice(price, discountPercent) {
if (!Number.isFinite(price) || price < 0) {
throw new TypeError('Ціна має бути невід’ємним числом');
}
if (
!Number.isFinite(discountPercent) ||
discountPercent < 0 ||
discountPercent > 100
) {
throw new TypeError('Знижка має бути числом від 0 до 100');
}
const finalPrice = price * (1 - discountPercent / 100);
return Number(finalPrice.toFixed(2));
}
module.exports = {
calculateFinalPrice
};Тест перевіряє кілька сценаріїв:
звичайне обчислення;
відсутність знижки;
знижка 100%;
некоректні аргументи.
Файл test/unit.test.js:
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateFinalPrice } = require('../src/discount');
test('обчислює ціну зі знижкою', () => {
const result = calculateFinalPrice(1000, 15);
assert.strictEqual(result, 850);
});
test('повертає початкову ціну без знижки', () => {
const result = calculateFinalPrice(499.99, 0);
assert.strictEqual(result, 499.99);
});
test('повертає нульову ціну для знижки 100%', () => {
const result = calculateFinalPrice(250, 100);
assert.strictEqual(result, 0);
});
test('відхиляє від’ємну ціну', () => {
assert.throws(
() => calculateFinalPrice(-10, 10),
{
name: 'TypeError',
message: 'Ціна має бути невід’ємним числом'
}
);
});
test('відхиляє знижку понад 100%', () => {
assert.throws(
() => calculateFinalPrice(100, 101),
{
name: 'TypeError',
message: 'Знижка має бути числом від 0 до 100'
}
);
});Запустити тести можна командою:
npm testАбо без npm:
node --testNode.js автоматично знаходить файли тестів із типовими іменами, зокрема файли в каталозі test.
assertНайчастіше використовують такі методи:
assert.strictEqualПеревіряє строге порівняння значень:
assert.strictEqual(2 + 2, 4);Для об’єктів цей метод перевіряє посилання, а не вміст.
assert.deepStrictEqualПорівнює структуру та значення об’єктів або масивів:
assert.deepStrictEqual(
{ name: 'Олена', age: 25 },
{ name: 'Олена', age: 25 }
);assert.throwsПеревіряє, що синхронна функція викидає помилку:
assert.throws(
() => calculateFinalPrice(-1, 10),
TypeError
);Перевіряє, що асинхронна функція завершується відхиленою обіцянкою:
await assert.rejects(
asyncFunction(),
/Помилка/
);Сервіси часто використовують репозиторії, клієнти API або інші модулі. Щоб unit-тест залишався ізольованим, залежність передають у сервіс ззовні.
Такий підхід називається впровадженням залежностей.
Створимо сервіс реєстрації користувача.
Файл src/userService.js:
async function registerUser(userData, userRepository) {
const { name, email } = userData;
const existingUser = await userRepository.findByEmail(email);
if (existingUser) {
throw new Error('Користувач із таким email уже існує');
}
const user = {
name,
email
};
return userRepository.create(user);
}
module.exports = {
registerUser
};Сервіс очікує, що userRepository має два методи:
findByEmail(email);
create(user).
У production-коді репозиторій міг би звертатися до бази даних. У unit-тесті ми передамо замість нього простий об’єкт.
Додамо тести до test/unit.test.js:
const { registerUser } = require('../src/userService');
test('реєструє нового користувача', async () => {
let searchedEmail;
let createdUser;
const userRepository = {
async findByEmail(email) {
searchedEmail = email;
return null;
},
async create(user) {
createdUser = user;
return {
id: 1,
...user
};
}
};
const result = await registerUser(
{
name: 'Олена',
email: 'olena@example.com'
},
userRepository
);
assert.strictEqual(searchedEmail, 'olena@example.com');
assert.deepStrictEqual(createdUser, {
name: 'Олена',
email: 'olena@example.com'
});
assert.deepStrictEqual(result, {
id: 1,
name: 'Олена',
email: 'olena@example.com'
});
});
test('не реєструє користувача з уже зайнятим email', async () => {
const userRepository = {
async findByEmail() {
return {
id: 10,
name: 'Інший користувач',
email: 'olena@example.com'
};
},
async create() {
throw new Error('Цей метод не повинен викликатися');
}
};
await assert.rejects(
registerUser(
{
name: 'Олена',
email: 'olena@example.com'
},
userRepository
),
{
message: 'Користувач із таким email уже існує'
}
);
});У цьому прикладі userRepository — це fake, тобто спрощена тестова реалізація залежності.
Тест не підключається до бази даних. Він контролює всі відповіді репозиторію та перевіряє:
чи передав сервіс правильний email до findByEmail;
чи сформував правильний об’єкт користувача;
чи викликав створення користувача;
чи не дозволив дублювання email.
Кожен тест має бути незалежним від інших тестів.
Поганий підхід — використовувати спільний змінний стан:
const users = [];
test('додає користувача', () => {
users.push({ email: 'test@example.com' });
});
test('перевіряє кількість користувачів', () => {
assert.strictEqual(users.length, 1);
});Такий тест може залежати від:
порядку запуску тестів;
результатів попередніх тестів;
того, чи очистився стан після помилки.
Краще створювати тестові дані всередині кожного тесту:
test('працює з окремими тестовими даними', () => {
const users = [];
users.push({ email: 'test@example.com' });
assert.strictEqual(users.length, 1);
});Для кожного сценарію створюйте власний fake або новий стан залежності.
Функції test можна групувати за допомогою test.describe:
const test = require('node:test');
const assert = require('node:assert/strict');
test.describe('calculateFinalPrice', () => {
test('застосовує знижку', () => {
assert.strictEqual(calculateFinalPrice(200, 25), 150);
});
test('відхиляє некоректну знижку', () => {
assert.throws(() => calculateFinalPrice(200, -1));
});
});Для невеликих файлів звичайних окремих викликів test достатньо. Групування стає корисним, коли в одному файлі перевіряється кілька модулів або багато сценаріїв.
Для кожної функції або сервісу варто перевірити:
основний успішний сценарій;
граничні значення;
некоректні аргументи;
очікувані помилки;
поведінку залежностей;
результат асинхронної операції.
Наприклад, для registerUser важливі такі сценарії:
користувача можна створити;
користувач із зайнятим email не створюється;
репозиторій отримує правильні дані;
метод create не викликається, якщо користувач уже існує.
Не потрібно перевіряти реалізацію кожного рядка. Важливо перевіряти зовнішню поведінку модуля: які дані він приймає, що повертає та які помилки генерує.
Зручно мислити про тест у три етапи:
Arrange — підготувати дані та залежності.
Act — викликати функцію або метод.
Assert — перевірити результат.
Приклад:
test('обчислює кінцеву ціну', () => {
// Arrange
const price = 500;
const discount = 20;
// Act
const result = calculateFinalPrice(price, discount);
// Assert
assert.strictEqual(result, 400);
});Коментарі в коді не є обов’язковими, але така структура допомагає зробити тест зрозумілим.
Unit-тест не повинен відкривати з’єднання з базою. Це робить тест повільним і залежним від налаштування середовища.
Замість цього передайте fake-репозиторій.
Тест не повинен залежати від доступності зовнішнього API або конкретної відповіді сервера. Передайте сервісу підставний клієнт або функцію.
Якщо тест одночасно перевіряє сервіс, базу даних і HTTP-клієнт, складно визначити причину помилки.
Unit-тест має зосереджуватися на одному модулі, а його залежності потрібно замінювати тестовими реалізаціями.
Кожен тест має самостійно створювати потрібні дані та не покладатися на результати інших тестів.
Не варто тестувати приватні змінні або конкретну кількість внутрішніх викликів, якщо це не впливає на поведінку модуля.
Перевіряйте результат, помилки та важливі дані, передані залежностям.
Якщо тест має багато підготовчих даних і перевіряє кілька різних сценаріїв, його складно підтримувати. Краще створити кілька коротких тестів із чіткими назвами.
Unit-тест перевіряє окрему функцію, модуль або сервіс.
Unit-тести не повинні залежати від бази даних, мережі та інших зовнішніх систем.
У Node.js для базових тестів можна використовувати вбудований node:test.
Для перевірок використовують node:assert/strict.
Залежності сервісу зручно передавати через аргументи.
Fake-об’єкти дозволяють ізолювати сервіс від реальної інфраструктури.
Кожен тест має бути незалежним і перевіряти один зрозумілий сценарій.
Хороший тест перевіряє поведінку модуля, а не його внутрішню реалізацію.