Пошук уроків, статей та іншого контенту
Застосуєте mocking, щоб ізолювати код від API, файлової системи, часу та інших зовнішніх залежностей.
Код застосунку часто залежить від ресурсів, які не належать самому модулю:
HTTP API;
файлової системи;
поточного часу;
бази даних;
генератора випадкових значень;
змінних середовища.
Під час тестування така залежність створює проблеми:
тест може бути повільним;
результат може залежати від стану системи;
потрібне мережеве з’єднання;
тест може змінити реальні файли;
помилку важко відтворити.
Mocking — це заміна реальної залежності контрольованим об’єктом або функцією, яка поводиться так, як потрібно тесту.
Наприклад, замість реального API тест використовує функцію, що завжди повертає заздалегідь підготовлену відповідь.
У практиці ці терміни часто використовують разом:
Stub повертає підготовлене значення.
Spy записує інформацію про виклики функції.
Mock зазвичай поєднує обидві можливості: дозволяє задати поведінку та перевірити виклики.
У Node.js для цього можна використовувати вбудований модуль
node:testmock.fn()const { mock } = require('node:test');
const sendEmail = mock.fn(async (recipient) => {
return `Лист надіслано: ${recipient}`;
});
sendEmail('user@example.com').then((result) => {
console.log(result);
console.log(sendEmail.mock.callCount());
});sendEmail виконує підготовлену логіку, а sendEmail.mock містить інформацію про виклики.
Найзручніший спосіб зробити код тестованим — передавати залежності через параметри функції або конструктора.
Розглянемо сервіс, який:
отримує користувача з API;
записує подію у файл;
додає до результату час отримання.
Замість того щоб безпосередньо викликати глобальний fetch, імпортувати файлову систему всередині функції та створювати new Date(), передамо ці залежності в сервіс.
function createUserService({
fetchImpl,
fileSystem,
now,
}) {
return {
async getUser(id) {
const response = await fetchImpl(
`https://api.example.com/users/${encodeURIComponent(id)}`,
);
if (!response.ok) {
throw new Error(`API повернуло статус ${response.status}`);
}
const user = await response.json();
const fetchedAt = now().toISOString();
await fileSystem.appendFile(
'audit.log',
`${JSON.stringify({
event: 'user_fetched',
userId: id,
fetchedAt,
})}\n`,
'utf8',
);
return {
...user,
fetchedAt,
};
},
};
}
module.exports = {
createUserService,
};Тепер сервіс не знає, чи є fetchImpl глобальним fetch, чи тестовою функцією. Так само йому не важливо, чи fileSystem працює з реальним диском.
У робочому коді сервіс можна створити з реальними залежностями:
const fs = require('node:fs/promises');
const { createUserService } = require('./user-service');
const userService = createUserService({
fetchImpl: fetch,
fileSystem: fs,
now: () => new Date(),
});Створимо файл user-service.test.js. У ньому будуть і сервіс, і тести, тому приклад можна запустити без додаткових налаштувань.
const { test, mock } = require('node:test');
const assert = require('node:assert/strict');
function createUserService({
fetchImpl,
fileSystem,
now,
}) {
return {
async getUser(id) {
const response = await fetchImpl(
`https://api.example.com/users/${encodeURIComponent(id)}`,
);
if (!response.ok) {
throw new Error(`API повернуло статус ${response.status}`);
}
const user = await response.json();
const fetchedAt = now().toISOString();
await fileSystem.appendFile(
'audit.log',
`${JSON.stringify({
event: 'user_fetched',
userId: id,
fetchedAt,
})}\n`,
'utf8',
);
return {
...user,
fetchedAt,
};
},
};
}
test('отримує користувача та записує подію в журнал', async () => {
const fetchMock = mock.fn(async (url) => {
assert.equal(url, 'https://api.example.com/users/user%2F42');
return {
ok: true,
status: 200,
async json() {
return {
id: 'user/42',
name: 'Olena',
};
},
};
});
const appendFileMock = mock.fn(async () => {});
const nowMock = mock.fn(() => {
return new Date('2025-01-15T10:30:00.000Z');
});
const userService = createUserService({
fetchImpl: fetchMock,
fileSystem: {
appendFile: appendFileMock,
},
now: nowMock,
});
const result = await userService.getUser('user/42');
assert.deepEqual(result, {
id: 'user/42',
name: 'Olena',
fetchedAt: '2025-01-15T10:30:00.000Z',
});
assert.equal(fetchMock.mock.callCount(), 1);
assert.equal(appendFileMock.mock.callCount(), 1);
assert.equal(nowMock.mock.callCount(), 1);
assert.deepEqual(appendFileMock.mock.calls[0].arguments, [
'audit.log',
'{"event":"user_fetched","userId":"user/42","fetchedAt":"2025-01-15T10:30:00.000Z"}\n',
'utf8',
]);
});
test('не записує подію, якщо API повернуло помилку', async () => {
const fetchMock = mock.fn(async () => {
return {
ok: false,
status: 503,
};
});
const appendFileMock = mock.fn(async () => {});
const userService = createUserService({
fetchImpl: fetchMock,
fileSystem: {
appendFile: appendFileMock,
},
now: () => new Date('2025-01-15T10:30:00.000Z'),
});
await assert.rejects(
userService.getUser('user-1'),
/API повернуло статус 503/,
);
assert.equal(fetchMock.mock.callCount(), 1);
assert.equal(appendFileMock.mock.callCount(), 0);
});Запуск:
node --test user-service.test.jsУ цьому прикладі:
мережевий запит не виконується;
файл audit.log не створюється;
тест не залежить від поточного часу;
кожну залежність можна перевірити окремо.
Mock зберігає виклики в mock.calls. Кожен елемент має поле arguments.
const { test, mock } = require('node:test');
const assert = require('node:assert/strict');
test('перевіряє аргументи виклику', () => {
const loggerMock = mock.fn();
loggerMock('user-created', { id: 10 });
assert.equal(loggerMock.mock.callCount(), 1);
assert.deepEqual(loggerMock.mock.calls[0].arguments, [
'user-created',
{ id: 10 },
]);
});Це дозволяє перевіряти не лише результат функції, а й взаємодію з залежністю:
чи викликався API;
з яким URL;
чи записався правильний файл;
чи передалися правильні параметри.
Перевіряйте взаємодії лише тоді, коли вони є важливою частиною поведінки. Надмірна перевірка внутрішніх викликів робить тести крихкими.
Кожен тест може створити власний mock із потрібною поведінкою.
Наприклад, API може повернути користувача:
const fetchMock = mock.fn(async () => ({
ok: true,
status: 200,
async json() {
return { id: 'user-1' };
},
}));Або помилку:
const fetchMock = mock.fn(async () => ({
ok: false,
status: 500,
}));Можна також змусити залежність відхилити проміс:
const fetchMock = mock.fn(async () => {
throw new Error('Мережа недоступна');
});Так тест легко перевіряє сценарії, які важко стабільно відтворити з реальною мережею.
Необов’язково підміняти весь модуль node:fs. Часто достатньо передати сервісу мінімальний об’єкт із потрібним методом.
Якщо код використовує лише appendFile, mock може містити тільки appendFile:
const fileSystemMock = {
appendFile: mock.fn(async (fileName, content, encoding) => {
assert.equal(fileName, 'audit.log');
assert.equal(encoding, 'utf8');
assert.match(content, /user_fetched/);
}),
};Це має дві переваги:
тест явно показує, яка частина файлової системи потрібна;
тест не може випадково змінити реальні файли.
Якщо модуль використовує readFile, writeFile або інший метод, додайте до mock лише відповідну функцію з потрібним результатом.
Без mocking поточного часу тест може бути нестабільним:
const fetchedAt = new Date().toISOString();Результат залежатиме від моменту запуску тесту. Передавання функції now вирішує проблему:
const fixedDate = new Date('2025-01-15T10:30:00.000Z');
const nowMock = mock.fn(() => fixedDate);Тепер тест завжди отримує однакову дату й може точно перевірити результат.
Краще передавати функцію now, а не готовий об’єкт Date. У такому разі production-код викликає її саме в момент, коли йому потрібен час, а тест повністю контролює результат.
Mock може не лише повертати значення, а й перевіряти аргументи або моделювати помилки.
const { mock } = require('node:test');
const parseUserIdMock = mock.fn((id) => {
if (typeof id !== 'string' || id.length === 0) {
throw new Error('Некоректний ідентифікатор');
}
return id.toUpperCase();
});
console.log(parseUserIdMock('user-1'));Для асинхронних залежностей використовуйте async-функції, щоб поведінка mock була схожою на реальну:
const readConfigMock = mock.fn(async () => {
return '{"enabled":true}';
});Не варто робити асинхронну mock-функцію синхронною, якщо production-код очікує проміс. Інакше тест може не виявити помилки в роботі з await.
Mock не повинен замінювати всю логіку застосунку. Його використовують на межах між кодом і зовнішнім світом.
Зазвичай варто mock-ати:
HTTP-клієнт;
файлову систему;
базу даних;
час;
випадковість;
відправлення повідомлень або листів.
Не потрібно mock-ати прості чисті функції, які:
отримують аргументи;
не звертаються до зовнішніх ресурсів;
повертають результат;
мають передбачувану поведінку.
Такі функції краще тестувати напряму.
Якщо тест викликає справжній API, він може:
працювати повільно;
падати через проблеми мережі;
залежати від даних на сервері;
змінювати реальний стан системи.
Підміняйте HTTP-залежність mock-функцією.
Тест не повинен створювати або змінювати робочі файли без особливої потреби. Передайте сервісу mock файлової системи.
Перевірка значення, яке формується через new Date(), може стати нестабільною. Передавайте функцію now і повертайте фіксовану дату в тесті.
Mock потрібно передати сервісу до виконання тестованої операції:
const service = createUserService({
fetchImpl: fetchMock,
fileSystem: fileSystemMock,
now: nowMock,
});
await service.getUser('user-1');Якщо сервіс уже отримав реальний fetch або реальну файлову систему, пізніше створений mock не вплине на попередньо збережене посилання.
Тест, який перевіряє кожен внутрішній виклик, може зламатися після безпечного рефакторингу.
Перевіряйте:
результат;
важливі помилки;
критичні взаємодії із зовнішніми залежностями.
Не використовуйте один mock для всіх тестів, якщо він зберігає виклики або змінює поведінку. Створюйте mock усередині кожного тесту. Це забезпечує ізоляцію та не дає результатам тестів впливати один на одного.
Mocking замінює зовнішню залежність контрольованою реалізацією.
mock.fn() створює функцію, яка може повертати значення та зберігати інформацію про виклики.
Передавання залежностей через параметри спрощує тестування.
API, файлову систему й час краще ізолювати від модульних тестів.
mock.calls дозволяє перевірити аргументи викликів.
Кожен тест повинен мати власні mock-об’єкти та передбачувану поведінку.
Найцінніше — тестувати поведінку модуля, а не всі деталі його внутрішньої реалізації.