JavaScript · Синтаксис · Просунутий рівень
Тестування в JavaScript
Написання та структурування модульних тестів за допомогою Jest/Vitest. Охоплює describe/it/expect, моки, асинхронні тести та підроблені таймери.
Короткий вступ до теми та пояснення перед вправами (вправи нижче):
jest.fn(), spyOn, мокування модулів, фальшиві таймери, test.each
#Патерн AAA, тестований код, ізоляція, помилки, CLI довідник
#Вправи:
describe / it / expect: базова структура тестів
#Напишіть функцію `sum(a, b)` та повний набір тестів для неї. Використайте `describe` для групування, `it` для кожного випадку та `expect(...).toBe(...)` для тверджень. Охопіть: позитивні числа, нуль, від'ємні числа.
// sum.js
function sum(a, b) {
return a + b;
}
// sum.test.js
describe('sum', () => {
it('складає два позитивні числа', () => {
// напишіть expect
});
it('повертає число при додаванні нуля', () => {
// напишіть expect
});
it('обробляє від\'ємні числа', () => {
// напишіть expect
});
});
Рішення
function sum(a, b) {
return a + b;
}
describe('sum', () => {
it('складає два позитивні числа', () => {
expect(sum(2, 3)).toBe(5);
});
it('повертає число при додаванні нуля', () => {
expect(sum(5, 0)).toBe(5);
});
it('обробляє від\'ємні числа', () => {
expect(sum(-1, -2)).toBe(-3);
});
});
Матчери expect: toBe, toEqual, toContain, toThrow
#Напишіть тести, що демонструють чотири матчери: `toBe` (примітивна рівність), `toEqual` (глибока рівність об'єктів), `toContain` (включення в масив/рядок), `toThrow` (функція кидає виняток). Використайте функцію `getUser()` та `divide(a, b)`, що кидає при ділення на нуль.
function getUser() {
return { name: 'Alice', age: 30, tags: ['admin', 'user'] };
}
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
describe('matchers', () => {
it('toBe: строга рівність', () => { /* */ });
it('toEqual: глибока рівність об\'єктів', () => { /* */ });
it('toContain: включення в масив', () => { /* */ });
it('toThrow: функція кидає виняток', () => { /* */ });
});
Рішення
function getUser() {
return { name: 'Alice', age: 30, tags: ['admin', 'user'] };
}
function divide(a, b) {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
describe('matchers', () => {
it('toBe: строга рівність', () => {
expect(2 + 2).toBe(4);
});
it('toEqual: глибока рівність', () => {
expect(getUser()).toEqual({ name: 'Alice', age: 30, tags: ['admin', 'user'] });
});
it('toContain: включення', () => {
expect(getUser().tags).toContain('admin');
});
it('toThrow: виняток', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
jest.fn(): функції-моки
#Створіть клас `UserService` з методом `notify(user)`. У тесті замокайте метод `notify` за допомогою `jest.fn()`. Викличте `notify` двічі та стверджуйте: `toHaveBeenCalled`, `toHaveBeenCalledTimes(2)`, `toHaveBeenCalledWith` для конкретних аргументів.
class UserService {
notify(user) {
console.log(`Notifying ${user.name}`);
}
register(user) {
this.notify(user);
return { ...user, registered: true };
}
}
describe('UserService', () => {
it('викликає notify при реєстрації користувача', () => {
const service = new UserService();
service.notify = jest.fn();
service.register({ name: 'Alice' });
service.register({ name: 'Bob' });
// перевірте що notify викликався
// перевірте що notify викликався двічі
// перевірте що перший виклик мав { name: 'Alice' }
});
});
Рішення
class UserService {
notify(user) { console.log(`Notifying ${user.name}`); }
register(user) {
this.notify(user);
return { ...user, registered: true };
}
}
describe('UserService', () => {
it('викликає notify при реєстрації', () => {
const service = new UserService();
service.notify = jest.fn();
service.register({ name: 'Alice' });
service.register({ name: 'Bob' });
expect(service.notify).toHaveBeenCalled();
expect(service.notify).toHaveBeenCalledTimes(2);
expect(service.notify).toHaveBeenCalledWith({ name: 'Alice' });
});
});
Повернені значення моків: mockReturnValue та mockResolvedValue
#Напишіть асинхронну функцію `fetchPrice(api, productId)`, що викликає `api.getProduct`. У тесті замініть `api.getProduct` на `jest.fn()`, що повертає resolved Promise. Стверджуйте що функція повертає правильну ціну з мокованої відповіді.
async function fetchPrice(api, productId) {
const product = await api.getProduct(productId);
return product.price;
}
describe('fetchPrice', () => {
it('повертає ціну з відповіді API', async () => {
const mockApi = {
getProduct: jest.fn().mockResolvedValue({ id: 1, price: 9.99 }),
};
const price = await fetchPrice(mockApi, 1);
// стверджуйте ціну
// стверджуйте що getProduct викликався з 1
});
});
Рішення
async function fetchPrice(api, productId) {
const product = await api.getProduct(productId);
return product.price;
}
describe('fetchPrice', () => {
it('повертає ціну з відповіді API', async () => {
const mockApi = {
getProduct: jest.fn().mockResolvedValue({ id: 1, price: 9.99 }),
};
const price = await fetchPrice(mockApi, 1);
expect(price).toBe(9.99);
expect(mockApi.getProduct).toHaveBeenCalledWith(1);
});
});
Асинхронні тести: тестування async функцій
#Напишіть асинхронну функцію `loadUser(id, fetch)`, що отримує дані з mock API. Напишіть асинхронний тест (з `async/await`), що мокає fetch, викликає `loadUser` та перевіряє результат. Також напишіть тест, що перевіряє кидання виключення при невірному ID.
async function loadUser(id, fetch) {
const res = await fetch(`/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
describe('loadUser', () => {
it('повертає дані користувача при успіху', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
// викличте loadUser, перевірте результат
});
it('кидає виключення коли відповідь не ok', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: false });
// перевірте що відхиляє/кидає
});
});
Рішення
async function loadUser(id, fetch) {
const res = await fetch(`/users/${id}`);
if (!res.ok) throw new Error('User not found');
return res.json();
}
describe('loadUser', () => {
it('повертає дані при успіху', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
const user = await loadUser(1, mockFetch);
expect(user).toEqual({ id: 1, name: 'Alice' });
});
it('кидає коли відповідь не ok', async () => {
const mockFetch = jest.fn().mockResolvedValue({ ok: false });
await expect(loadUser(999, mockFetch)).rejects.toThrow('User not found');
});
});
Підроблені таймери: тестування setTimeout/setInterval
#Напишіть функцію `delay(ms)`, що повертає Promise, що резолвиться через `ms` мілісекунд. Напишіть тест з `jest.useFakeTimers()` та `jest.runAllTimers()`, що резолвить затримку без реального очікування.
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('delay', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('резолвиться після заданої затримки', async () => {
let resolved = false;
delay(1000).then(() => { resolved = true; });
expect(resolved).toBe(false);
// просуньте таймери
// тепер resolved має бути true
});
});
Рішення
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
describe('delay', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('резолвиться після затримки', async () => {
let resolved = false;
const p = delay(1000).then(() => { resolved = true; });
expect(resolved).toBe(false);
jest.runAllTimers();
await p;
expect(resolved).toBe(true);
});
});
beforeEach / afterEach: налаштування та очищення тестів
#Напишіть клас `Counter` з методами `increment()`, `decrement()` та гетером `value`. Використайте `beforeEach` для створення нового лічильника перед кожним тестом. Напишіть три тести: increment, decrement, reset. Покажіть що тести ізольовані — кожен починає з нуля.
class Counter {
constructor() { this._value = 0; }
increment() { this._value++; }
decrement() { this._value--; }
reset() { this._value = 0; }
get value() { return this._value; }
}
describe('Counter', () => {
let counter;
beforeEach(() => {
// створіть новий лічильник
});
it('збільшує', () => { /* */ });
it('зменшує', () => { /* */ });
it('скидає', () => { /* */ });
});
Рішення
class Counter {
constructor() { this._value = 0; }
increment() { this._value++; }
decrement() { this._value--; }
reset() { this._value = 0; }
get value() { return this._value; }
}
describe('Counter', () => {
let counter;
beforeEach(() => {
counter = new Counter();
});
it('збільшує', () => {
counter.increment();
counter.increment();
expect(counter.value).toBe(2);
});
it('зменшує', () => {
counter.increment();
counter.decrement();
expect(counter.value).toBe(0);
});
it('скидає до нуля', () => {
counter.increment();
counter.reset();
expect(counter.value).toBe(0);
});
});
jest.spyOn: шпигування без заміни
#Напишіть клас `Logger` з методом `log(msg)`, що також викликає `console.log`. Використайте `jest.spyOn(console, 'log')` для шпигування без заміни. Стверджуйте що виклик відбувся з правильним повідомленням. Відновіть оригінал в `afterEach`.
class Logger {
log(msg) {
console.log(`[LOG] ${msg}`);
}
}
describe('Logger', () => {
let spy;
beforeEach(() => {
spy = jest.spyOn(console, 'log');
});
afterEach(() => { spy.mockRestore(); });
it('логує з префіксом [LOG]', () => {
const logger = new Logger();
logger.log('hello');
// стверджуйте що console.log викликався з '[LOG] hello'
});
});
Рішення
class Logger {
log(msg) { console.log(`[LOG] ${msg}`); }
}
describe('Logger', () => {
let spy;
beforeEach(() => {
spy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => { spy.mockRestore(); });
it('логує з префіксом [LOG]', () => {
const logger = new Logger();
logger.log('hello');
expect(spy).toHaveBeenCalledWith('[LOG] hello');
});
});
test.each: параметризовані тести
#Напишіть функцію `isPalindrome(str)`. Використайте `test.each` для запуску одного твердження з кількома вхідними даними: `'racecar'`, `'hello'`, `'level'`, `'world'`, `'noon'`. Таблиця тестів має містити вхідний рядок та очікуваний булевий результат.
function isPalindrome(str) {
const clean = str.toLowerCase().replace(/[^a-z0-9]/g, '');
return clean === clean.split('').reverse().join('');
}
describe('isPalindrome', () => {
test.each([
// [вхід, очікуваний]
])('%s => %s', (input, expected) => {
expect(isPalindrome(input)).toBe(expected);
});
});
Рішення
function isPalindrome(str) {
const clean = str.toLowerCase().replace(/[^a-z0-9]/g, '');
return clean === clean.split('').reverse().join('');
}
describe('isPalindrome', () => {
test.each([
['racecar', true],
['hello', false],
['level', true],
['world', false],
['noon', true],
])('%s => %s', (input, expected) => {
expect(isPalindrome(input)).toBe(expected);
});
});
Впровадження залежностей: архітектура тестованого коду
#Рефакторуйте функцію `sendEmail(to, subject)` щоб вона була тестованою БЕЗ моку глобалів. Замість прямого виклику `nodemailer.sendMail`, приймайте залежність `mailer`. Напишіть тест, що передає mock mailer та перевіряє правильність виклику. Це ключовий патерн, що робить код тестованим.
// важко тестувати — викликає глобальний nodemailer напряму:
// async function sendEmail(to, subject) {
// await nodemailer.sendMail({ to, subject });
// }
// тестована версія: приймає mailer як залежність
async function sendEmail(mailer, to, subject) {
await mailer.sendMail({ to, subject });
}
describe('sendEmail', () => {
it('викликає sendMail з правильними параметрами', async () => {
const mockMailer = { sendMail: jest.fn().mockResolvedValue(undefined) };
await sendEmail(mockMailer, '[email protected]', 'Hello');
// перевірте що mockMailer.sendMail викликався з правильним об'єктом
});
});
Рішення
async function sendEmail(mailer, to, subject) {
await mailer.sendMail({ to, subject });
}
describe('sendEmail', () => {
it('викликає sendMail з правильними параметрами', async () => {
const mockMailer = { sendMail: jest.fn().mockResolvedValue(undefined) };
await sendEmail(mockMailer, '[email protected]', 'Hello');
expect(mockMailer.sendMail).toHaveBeenCalledWith({
to: '[email protected]',
subject: 'Hello',
});
});
});