Пошук уроків, статей та іншого контенту
Використаєте spies та stubs для контролю викликів, аргументів і заздалегідь визначених відповідей.
Під час тестування важливо перевіряти не лише результат роботи функції, а й те, як саме вона взаємодіяла з іншими компонентами.
Наприклад, метод реєстрації користувача може надсилати електронний лист через окремий клієнт:
await emailClient.send(user.email, 'Вітаємо!');У тесті зазвичай потрібно перевірити:
чи був викликаний
скільки разів його викликали;
з якими аргументами;
яку відповідь повернув зовнішній компонент;
як код поводиться, якщо зовнішній компонент повертає помилку.
Для цього використовують spies та stubs.
Spy — це об'єкт або функція, які стежать за викликами, але зазвичай зберігають оригінальну поведінку.
Spy дає змогу перевірити:
кількість викликів;
аргументи кожного виклику;
контекст виклику;
результат виконання оригінальної функції.
У Node.js для створення spy можна використовувати вбудований Mock API з модуля node:test.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const calculator = {
add(a, b) {
return a + b;
},
};
test('перевіряє виклик методу add', (t) => {
const addSpy = t.mock.method(calculator, 'add');
const result = calculator.add(2, 3);
assert.equal(result, 5);
assert.equal(addSpy.mock.callCount(), 1);
assert.deepEqual(addSpy.mock.calls[0].arguments, [2, 3]);
});Запустити тест можна командою:
node --test calculator.test.jsУ цьому прикладі:
t.mock.method(calculator, 'add') замінює метод контрольованою обгорткою.
Оригінальний метод продовжує виконуватися.
addSpy.mock.callCount() повертає кількість викликів.
addSpy.mock.calls містить інформацію про всі виклики.
addSpy.mock.calls[0].arguments містить аргументи першого виклику.
Масив mock.calls зберігає дані про кожен виклик у порядку його виконання.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const logger = {
info(message) {
return `INFO: ${message}`;
},
};
test('перевіряє всі повідомлення логера', (t) => {
const infoSpy = t.mock.method(logger, 'info');
logger.info('Користувача створено');
logger.info('Лист надіслано');
assert.equal(infoSpy.mock.callCount(), 2);
assert.deepEqual(
infoSpy.mock.calls.map((call) => call.arguments),
[
['Користувача створено'],
['Лист надіслано'],
],
);
});Такий тест перевіряє не тільки кількість викликів, а й порядок та аргументи.
Stub — це заміна справжньої функції або методу на контрольовану реалізацію.
На відміну від spy, stub не обов'язково виконує оригінальну логіку. Він може:
повернути наперед визначене значення;
повернути Promise;
викинути помилку;
записати виклики для подальшої перевірки.
Stub корисний, коли справжня залежність:
звертається до бази даних;
виконує HTTP-запит;
надсилає електронні листи;
залежить від поточного часу;
є повільною або нестабільною.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const userRepository = {
async findById(id) {
throw new Error('У тесті не потрібно звертатися до бази даних');
},
};
async function getUserName(id) {
const user = await userRepository.findById(id);
return user.name;
}
test('повертає ім’я користувача зі stub-відповіді', async (t) => {
const findByIdStub = t.mock.method(
userRepository,
'findById',
async (id) => {
return {
id,
name: 'Олена',
};
},
);
const name = await getUserName(42);
assert.equal(name, 'Олена');
assert.equal(findByIdStub.mock.callCount(), 1);
assert.deepEqual(findByIdStub.mock.calls[0].arguments, [42]);
});Тест не використовує справжню базу даних. Замість цього findById завжди повертає визначений об'єкт.
Передана реалізація отримує ті самі аргументи, з якими викликали замінений метод.
Stub дає змогу перевірити сценарій, у якому залежність не працює.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const paymentClient = {
async charge() {
throw new Error('Справжній платіжний сервіс не використовується в тесті');
},
};
async function payOrder(orderId, amount) {
try {
await paymentClient.charge(orderId, amount);
return { success: true };
} catch (error) {
return {
success: false,
reason: error.message,
};
}
}
test('повертає помилку, якщо платіжний сервіс недоступний', async (t) => {
const chargeStub = t.mock.method(
paymentClient,
'charge',
async () => {
throw new Error('Платіжний сервіс недоступний');
},
);
const result = await payOrder('order-100', 500);
assert.deepEqual(result, {
success: false,
reason: 'Платіжний сервіс недоступний',
});
assert.equal(chargeStub.mock.callCount(), 1);
assert.deepEqual(
chargeStub.mock.calls[0].arguments,
['order-100', 500],
);
});Тут stub імітує помилку зовнішнього сервісу. Завдяки цьому тест перевіряє обробку винятків без реального платежу.
На практиці часто потрібно одночасно:
замінити залежність, щоб вона не виконувала реальну операцію;
перевірити, як саме її викликали.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const emailClient = {
async send(to, subject) {
throw new Error('Не можна надсилати справжні листи під час тесту');
},
};
async function registerUser(user) {
await emailClient.send(
user.email,
'Ласкаво просимо',
);
return {
id: 1,
email: user.email,
};
}
test('реєструє користувача та надсилає лист', async (t) => {
const sendStub = t.mock.method(
emailClient,
'send',
async () => ({ messageId: 'message-1' }),
);
const result = await registerUser({
email: 'olena@example.com',
});
assert.deepEqual(result, {
id: 1,
email: 'olena@example.com',
});
assert.equal(sendStub.mock.callCount(), 1);
assert.deepEqual(
sendStub.mock.calls[0].arguments,
[
'olena@example.com',
'Ласкаво просимо',
],
);
});У цьому прикладі sendStub має дві ролі:
повертає контрольовану відповідь замість реального надсилання листа;
записує інформацію про виклик для перевірок.
Перевіряйте аргументи настільки точно, наскільки це важливо для поведінки коду.
Для простих значень підійде assert.equal:
assert.equal(
sendStub.mock.calls[0].arguments[0],
'olena@example.com',
);Для масивів та об'єктів використовуйте assert.deepEqual:
assert.deepEqual(
sendStub.mock.calls[0].arguments,
['olena@example.com', 'Ласкаво просимо'],
);Можна перевірити окремі виклики:
const firstCall = sendStub.mock.calls[0];
const secondCall = sendStub.mock.calls[1];
assert.deepEqual(firstCall.arguments, ['first@example.com']);
assert.deepEqual(secondCall.arguments, ['second@example.com']);Stub для асинхронного методу повинен повертати Promise. Найзручніше оголосити його як async-функцію:
const findStub = t.mock.method(
repository,
'find',
async (id) => {
return { id, status: 'active' };
},
);Якщо потрібно імітувати помилку:
const findStub = t.mock.method(
repository,
'find',
async () => {
throw new Error('Помилка сховища');
},
);Під час тестування не забувайте використовувати await:
const user = await service.getUser(10);Інакше тест може завершитися раніше, ніж буде виконаний асинхронний код.
Mock API, доступний через контекст тесту t, автоматично відновлює замінені методи після завершення тесту.
test('метод автоматично відновлюється після тесту', (t) => {
const spy = t.mock.method(calculator, 'add');
calculator.add(1, 2);
assert.equal(spy.mock.callCount(), 1);
});Інший тест не успадкує цей spy. Це важливо, тому що стан mock-об'єктів не повинен переходити між тестами.
Якщо mock створено поза контекстом тесту через загальний об'єкт mock, його потрібно відновлювати вручну після використання. Для більшості тестів зручніше створювати mocks через t.mock, щоб життєвим циклом займався тестовий runner.
Використовуйте spy, коли:
оригінальна функція безпечна для виконання;
потрібно перевірити факт виклику;
потрібно зберегти справжню поведінку;
результат роботи оригінальної функції важливий для тесту.
Використовуйте stub, коли:
оригінальна функція має зовнішні побічні ефекти;
потрібно повернути конкретні дані;
потрібно перевірити помилковий сценарій;
реальна залежність повільна, недоступна або непередбачувана.
Тест із spy або stub зазвичай має таку послідовність:
Підготувати залежність.
Замінити її методом t.mock.method.
Виконати код, який тестується.
Перевірити результат.
Перевірити кількість викликів.
Перевірити аргументи.
const dependencyStub = t.mock.method(
dependency,
'method',
async () => expectedValue,
);
const result = await codeUnderTest(input);
assert.deepEqual(result, expectedResult);
assert.equal(dependencyStub.mock.callCount(), 1);
assert.deepEqual(
dependencyStub.mock.calls[0].arguments,
[input],
);Якщо тест надсилає справжній HTTP-запит або звертається до реальної бази даних, він стає повільним і залежним від зовнішнього середовища.
Замість цього замініть залежність stub-ом і перевірте лише взаємодію з нею.
Результат може бути правильним навіть тоді, коли залежність викликали з неправильними аргументами.
Перевіряйте і результат, і важливі виклики:
assert.deepEqual(result, expectedResult);
assert.deepEqual(
dependencyStub.mock.calls[0].arguments,
expectedArguments,
);Не потрібно перевіряти кожен внутрішній виклик. Перевіряйте лише ті взаємодії, які є частиною очікуваної поведінки.
Наприклад, для реєстрації важливо перевірити, що лист надіслано на правильну адресу, але не обов'язково перевіряти кожну локальну функцію форматування тексту.
awaitНеправильно:
const result = service.loadUser(1);
assert.equal(result.name, 'Олена');Правильно:
const result = await service.loadUser(1);
assert.equal(result.name, 'Олена');Асинхронні stub-и також потрібно очікувати через await.
Перевірка лише того, що метод викликали хоча б раз, може приховати помилку повторного виклику.
Якщо очікується один виклик, перевіряйте саме це:
assert.equal(stub.mock.callCount(), 1);Spy стежить за викликами функції та зазвичай зберігає її оригінальну поведінку.
Stub замінює функцію контрольованою реалізацією.
t.mock.method(object, methodName) створює контрольований метод у тесті.
mock.callCount() повертає кількість викликів.
mock.calls містить інформацію про виклики.
mock.calls[index].arguments містить аргументи конкретного виклику.
Stub-и дають змогу задавати успішні відповіді та помилки.
Для асинхронних залежностей використовуйте async stub-и та await.
Створення mocks через t.mock допомагає автоматично відновлювати оригінальні методи після тесту.