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