JavaScript · Синтаксис · Просунутий рівень
Тестування в JavaScript
Написання та структурування модульних тестів за допомогою Jest/Vitest. Охоплює describe/it/expect, моки, асинхронні тести та підроблені таймери.
Короткий вступ до теми та пояснення перед вправами (вправи нижче):
jest.fn(), spyOn, мокування модулів, фальшиві таймери, test.each
#Патерн AAA, тестований код, ізоляція, помилки, CLI довідник
#Вправи:
describe / it / expect: базова структура тестів
#// 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
#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(): функції-моки
#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
#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 функцій
#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
#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: налаштування та очищення тестів
#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: шпигування без заміни
#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: параметризовані тести
#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);
});
});
Впровадження залежностей: архітектура тестованого коду
#// важко тестувати — викликає глобальний 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',
});
});
});