Пошук уроків, статей та іншого контенту
Протестуєте Promise, async/await, помилки та часові сценарії без нестабільних тестів.
Асинхронна функція завжди повертає Promise, навіть якщо всередині використовується async/await.
Тому тест має дочекатися завершення операції:
const result = await someAsyncFunction();або повернути Promise з тесту:
return someAsyncFunction().then(result => {
// перевірки
});Найзручніший варіант — async-тест із await.
Для прикладів використаємо вбудовані модулі Node.js:
node:test — тестовий раннер;
node:assert/strict — строгі перевірки.
Зовнішні бібліотеки не потрібні.
Розглянемо асинхронну функцію, яка отримує користувача через клієнт API:
async function getDisplayName(id, client) {
const user = await client.fetchUser(id);
if (!user) {
throw new Error('Користувача не знайдено');
}
return `${user.firstName} ${user.lastName}`;
}У production-коді client.fetchUser міг би виконувати HTTP-запит. У тесті мережа не потрібна — передамо заміну, яка повертає заздалегідь підготовлений Promise.
const fakeClient = {
fetchUser: async id => ({
id,
firstName: 'Олена',
lastName: 'Коваль',
}),
};Тест очікуваного результату:
const name = await getDisplayName(42, fakeClient);
assert.equal(name, 'Олена Коваль');Важливо, що await стоїть саме перед викликом тестованої функції. Без нього перевірка могла б виконатися до завершення Promise.
Створіть файл profile.test.js:
const test = require('node:test');
const assert = require('node:assert/strict');
async function getDisplayName(id, client) {
const user = await client.fetchUser(id);
if (!user) {
throw new Error('Користувача не знайдено');
}
return `${user.firstName} ${user.lastName}`;
}
function createReminder({
wait = milliseconds =>
new Promise(resolve => setTimeout(resolve, milliseconds)),
} = {}) {
return {
async send(message, delayMs) {
await wait(delayMs);
return `Надіслано: ${message}`;
},
};
}
function isExpired(createdAt, now, ttlMs) {
return now - createdAt >= ttlMs;
}
test('повертає ім’я користувача після успішного Promise', async () => {
const client = {
fetchUser: async id => ({
id,
firstName: 'Олена',
lastName: 'Коваль',
}),
};
const result = await getDisplayName(42, client);
assert.equal(result, 'Олена Коваль');
});
test('передає правильний ідентифікатор клієнту', async () => {
const requestedIds = [];
const client = {
fetchUser: async id => {
requestedIds.push(id);
return {
firstName: 'Іван',
lastName: 'Мельник',
};
},
};
await getDisplayName(17, client);
assert.deepEqual(requestedIds, [17]);
});
test('перетворює відсутнього користувача на помилку', async () => {
const client = {
fetchUser: async () => null,
};
await assert.rejects(
getDisplayName(404, client),
{
name: 'Error',
message: 'Користувача не знайдено',
},
);
});
test('перевіряє властивості помилки через функцію', async () => {
const expectedError = new Error('Сервіс тимчасово недоступний');
const client = {
fetchUser: async () => {
throw expectedError;
},
};
await assert.rejects(
getDisplayName(42, client),
error => {
assert.equal(error, expectedError);
return true;
},
);
});
test('чекає перед надсиланням нагадування', async () => {
const requestedDelays = [];
const reminder = createReminder({
wait: async milliseconds => {
requestedDelays.push(milliseconds);
},
});
const result = await reminder.send('Зустріч о 10:00', 5000);
assert.equal(result, 'Надіслано: Зустріч о 10:00');
assert.deepEqual(requestedDelays, [5000]);
});
test('визначає момент завершення терміну дії', () => {
const createdAt = 1_000;
const ttlMs = 60_000;
assert.equal(isExpired(createdAt, 60_999, ttlMs), false);
assert.equal(isExpired(createdAt, 61_000, ttlMs), true);
});Запустіть тест:
node --test profile.test.jsУ цьому прикладі перевіряються:
успішне виконання async-функції;
значення, яке повертає Promise;
аргументи, передані асинхронній залежності;
відхилений Promise;
конкретний об’єкт помилки;
сценарій із затримкою;
межа часу завершення терміну дії.
Для помилок використовуйте assert.rejects.
Неправильний підхід:
assert.throws(() => getDisplayName(404, client));assert.throws призначений для синхронних винятків. Асинхронна функція не кидає помилку безпосередньо в момент виклику — вона повертає відхилений Promise.
Правильний підхід:
await assert.rejects(
getDisplayName(404, client),
/Користувача не знайдено/,
);Другий аргумент assert.rejects може бути:
регулярним виразом для перевірки повідомлення;
об’єктом із властивостями помилки;
функцією-перевіркою;
конструктором помилки.
Якщо код використовує власний клас помилки:
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
}
}
async function loadOrder(id, repository) {
const order = await repository.findById(id);
if (!order) {
throw new NotFoundError(`Замовлення ${id} не знайдено`);
}
return order;
}Тест може перевірити тип і повідомлення:
test('кидає NotFoundError для відсутнього замовлення', async () => {
const repository = {
findById: async () => null,
};
await assert.rejects(
loadOrder(10, repository),
error => {
assert.equal(error.name, 'NotFoundError');
assert.equal(error.message, 'Замовлення 10 не знайдено');
return true;
},
);
});Перевіряйте саме важливі властивості помилки. Не варто порівнювати весь текст, якщо він містить динамічні дані, які не мають значення для цього тесту.
Асинхронну залежність можна налаштувати на успішний або невдалий сценарій.
Успішний сценарій:
const client = {
fetchUser: () => Promise.resolve({
firstName: 'Марія',
lastName: 'Шевченко',
}),
};Невдалий сценарій:
const client = {
fetchUser: () => Promise.reject(new Error('Помилка мережі')),
};Обидва варіанти еквівалентні async-функціям у контексті тесту:
const client = {
fetchUser: async () => {
throw new Error('Помилка мережі');
},
};Такі заміни дають змогу перевірити логіку функції незалежно від реальної мережі, бази даних або зовнішнього сервісу.
async/awaitasync/await змінює синтаксис, але не змінює правила тестування Promise.
Тест повинен:
мати ключове слово async;
дочекатися результату через await;
використовувати assert.rejects для помилок.
test('обробляє результат async-функції', async () => {
const client = {
fetchUser: async () => ({
firstName: 'Андрій',
lastName: 'Бондар',
}),
};
const result = await getDisplayName(1, client);
assert.equal(result, 'Андрій Бондар');
});Якщо забути await, тест може завершитися раніше:
test('ненадійний тест', () => {
getDisplayName(1, client).then(result => {
assert.equal(result, 'Андрій Бондар');
});
});У такому варіанті тестовий раннер не обов’язково чекатиме на внутрішній .then(). Перевірка може виконатися вже після завершення тесту або помилка може бути оброблена непередбачувано.
Краще написати:
test('надійний тест', async () => {
const result = await getDisplayName(1, client);
assert.equal(result, 'Андрій Бондар');
});Нестабільний тест залежить від реального часу, навантаження системи або точності таймерів.
Ненадійний приклад:
test('виконує операцію за 100 мс', async () => {
const startedAt = Date.now();
await someAsyncOperation();
const elapsed = Date.now() - startedAt;
assert.ok(elapsed >= 100);
assert.ok(elapsed < 110);
});Такий тест може випадково впасти через:
завантаженість процесора;
роботу CI;
планування процесів операційною системою;
неточність таймерів;
додаткові операції в самій функції.
Якщо функція перевіряє термін дії, передавайте поточний час явно:
function isExpired(createdAt, now, ttlMs) {
return now - createdAt >= ttlMs;
}Тепер тест повністю контролює часовий сценарій:
test('термін дії ще не завершився', () => {
assert.equal(isExpired(1_000, 60_999, 60_000), false);
});
test('термін дії завершився точно на межі', () => {
assert.equal(isExpired(1_000, 61_000, 60_000), true);
});Це краще, ніж чекати реальні 60 секунд.
У прикладі з нагадуванням функція за замовчуванням використовує справжній setTimeout, але в тесті отримує власну реалізацію:
const requestedDelays = [];
const reminder = createReminder({
wait: async milliseconds => {
requestedDelays.push(milliseconds);
},
});Тест не чекає п’ять секунд. Він перевіряє, що код попросив правильну затримку:
const result = await reminder.send('Зустріч', 5000);
assert.equal(result, 'Надіслано: Зустріч');
assert.deepEqual(requestedDelays, [5000]);Цей підхід називається ін’єкцією залежності. Часовий механізм замінюється контрольованою функцією, а тест залишається швидким і передбачуваним.
Якщо кілька незалежних операцій мають виконуватися паралельно, це також варто перевіряти через Promise.all.
async function loadDashboard(client) {
const [profile, notifications] = await Promise.all([
client.fetchProfile(),
client.fetchNotifications(),
]);
return {
profile,
notifications,
};
}Тест:
test('збирає профіль і сповіщення', async () => {
const calls = [];
const client = {
fetchProfile: async () => {
calls.push('profile');
return { name: 'Олена' };
},
fetchNotifications: async () => {
calls.push('notifications');
return [{ id: 1, text: 'Нове повідомлення' }];
},
};
const dashboard = await loadDashboard(client);
assert.deepEqual(dashboard, {
profile: { name: 'Олена' },
notifications: [{ id: 1, text: 'Нове повідомлення' }],
});
assert.deepEqual(calls.sort(), ['notifications', 'profile']);
});Не перевіряйте порядок завершення незалежних Promise, якщо він не є частиною вимог. Порядок завершення може змінюватися.
Реальні таймери доречні в інтеграційних тестах, де потрібно перевірити взаємодію з реальною операційною системою або зовнішнім середовищем.
Для unit-тестів краще:
передавати функцію очікування як залежність;
передавати поточний час параметром або функцією;
перевіряти запитану тривалість, а не фактичний час виконання;
перевіряти поведінку на межі інтервалу: до, точно на межі та після межі.
Не використовуйте великі затримки лише для того, щоб «дати Promise завершитися». Правильно організований тест сам очікує Promise через await.
assert.throws для async-функціїНеправильно:
assert.throws(() => getDisplayName(404, client));Правильно:
await assert.rejects(getDisplayName(404, client));await у тестіНеправильно:
test('перевіряє результат', () => {
getDisplayName(1, client).then(result => {
assert.equal(result, 'Олена Коваль');
});
});Правильно:
test('перевіряє результат', async () => {
const result = await getDisplayName(1, client);
assert.equal(result, 'Олена Коваль');
});Неправильно:
await new Promise(resolve => setTimeout(resolve, 5000));Такий тест повільний і може бути нестабільним.
Краще замінити очікування контрольованою функцією та перевірити передане значення затримки.
Неправильно перевіряти, що операція завершилася, наприклад, за 100–110 мілісекунд. Тривалість виконання залежить від середовища.
Перевіряйте результат, факт виклику залежності або логіку обчислення часу.
Якщо assert виконується всередині Promise, але тест його не повертає й не очікує, помилка може не належати до правильного тестового сценарію.
Кожен Promise у тесті повинен бути:
очікуваний через await;
або повернутий через return.
Асинхронний тест оголошуйте з async.
Результат Promise перевіряйте після await.
Для відхилених Promise використовуйте await assert.rejects(...).
Для залежностей створюйте контрольовані заміни, які повертають Promise.resolve або Promise.reject.
Не перевіряйте точну тривалість реальної операції в unit-тестах.
Передавайте час і функції очікування як залежності, щоб тестувати часові сценарії без затримок.
Перевіряйте граничні значення часу: до завершення терміну, точно на межі та після неї.
Не дозволяйте асинхронним перевіркам виконуватися після завершення тесту.