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',
});
});
});