- Писать модульные тесты с
describe,itиexpectи выбирать нужный матчер (toBe,toEqual,toThrow) - Тестировать асинхронный код и ошибки, изолировать код от внешнего мира с помощью моков, шпионов и фейковых таймеров
- Следовать хорошим практикам: Arrange–Act–Assert, пирамида тестов, покрытие и TDD
Ты исправляешь ошибку в функции скидки — и где-то в другом месте ломается страница оплаты. Проверять сайт вручную после каждого изменения — это часы. Автоматические тесты проверяют сотни случаев за секунду при каждом сохранении файла. Самые популярные инструменты тестирования для JavaScript — Vitest и Jest; их API почти одинаковы, поэтому всё из этого урока работает в обоих.
Первый модульный тест
Модульный тест (unit test) проверяет небольшую часть — обычно одну функцию — изолированно. Файл теста лежит рядом с кодом: price.test.js для price.js. describe группирует тесты, it (или test) описывает одно поведение, а expect(значение).toBe(ожидаемое) выполняет проверку. npx vitest запускает тесты и следит за файлами.
// price.js
export function applyDiscount(price, percent) {
if (percent < 0 || percent > 100) throw new RangeError('Percent must be 0-100');
return Math.round(price * (100 - percent)) / 100;
}
// price.test.js
import { describe, it, expect } from 'vitest';
import { applyDiscount } from './price.js';
describe('applyDiscount', () => {
it('reduces the price by the given percent', () => {
expect(applyDiscount(200, 10)).toBe(180);
});
it('rounds to cents', () => {
expect(applyDiscount(9.99, 15)).toBe(8.49);
});
it('rejects a percent above 100', () => {
expect(() => applyDiscount(50, 120)).toThrow(RangeError);
});
}); ✓ price.test.js (3 tests) 3ms
Test Files 1 passed (1)
Tests 3 passed (3)toThrow вызов нужно обернуть в стрелочную функцию — иначе ошибка вылетит раньше, чем выполнится expect.Как работает expect
Инструмент тестирования — не магия: it вызывает твою функцию внутри try...catch, а expect выбрасывает ошибку, если условие не выполнено. Мини-версия ниже показывает это и раскрывает два важных различия: toBe сравнивает через Object.is (та же ссылка), а toEqual — по значению; а дробные числа сравнивать строгим равенством рискованно.
const results = [];
function it(name, fn) {
try {
fn();
results.push(`✓ ${name}`);
} catch (error) {
results.push(`✗ ${name}: ${error.message}`);
}
}
function expect(actual) {
const show = (v) => JSON.stringify(v);
return {
toBe(expected) {
if (!Object.is(actual, expected)) throw new Error(`expected ${show(expected)}, got ${show(actual)}`);
},
toEqual(expected) {
if (show(actual) !== show(expected)) throw new Error(`expected ${show(expected)}, got ${show(actual)}`);
},
};
}
it('adds numbers', () => expect(2 + 3).toBe(5));
it('compares arrays by value', () => expect([1, 2]).toEqual([1, 2]));
it('toBe needs the same array', () => expect([1, 2]).toBe([1, 2]));
it('decimals are tricky', () => expect(0.1 + 0.2).toBe(0.3));
console.log(results.join('\n'));▸ Ожидаемый результат
✓ adds numbers ✓ compares arrays by value ✗ toBe needs the same array: expected [1,2], got [1,2] ✗ decimals are tricky: expected 0.3, got 0.30000000000000004
toBeCloseTo(0.3). Наш toEqual упрощён — Vitest и Jest сравнивают глубже и аккуратнее.| Матчер | Что проверяет |
|---|---|
toBe(x) | равенство через Object.is: примитивы, та же ссылка |
toEqual(x) | глубокое равенство объектов и массивов |
toBeCloseTo(x) | приблизительное равенство дробей |
toContain(x), toHaveLength(n) | элемент массива или часть строки; длина |
toThrow(Type) | что функция выбрасывает ошибку |
resolves / rejects | результат или ошибка промиса |
.not | отрицает любую проверку: expect(x).not.toBe(0) |
Асинхронный код, моки и шпионы
Асинхронный тест — это просто async-функция: дождись результата через await или напиши await expect(promise).rejects.toThrow(). Модульный тест не должен отправлять настоящие письма, обращаться к настоящему серверу или ждать реальное время. Для этого есть моки (поддельные функции, vi.fn()), шпионы (следят за вызовами существующего метода, vi.spyOn) и фейковые таймеры (vi.useFakeTimers()). Самый простой путь — передавать зависимость параметром: тогда в тесте вместо настоящей функции подставляешь мок.
// notify.js
export async function notifyUser(user, sendEmail) {
return sendEmail(user.email, `Welcome, ${user.name}!`);
}
// notify.test.js
import { describe, it, expect, vi } from 'vitest';
import { notifyUser } from './notify.js';
describe('notifyUser', () => {
it('sends one welcome email', async () => {
const sendEmail = vi.fn().mockResolvedValue({ ok: true });
await notifyUser({ email: 'leyla@mail.az', name: 'Leyla' }, sendEmail);
expect(sendEmail).toHaveBeenCalledTimes(1);
expect(sendEmail).toHaveBeenCalledWith('leyla@mail.az', 'Welcome, Leyla!');
});
it('fails when the mail service is down', async () => {
const sendEmail = vi.fn().mockRejectedValue(new Error('SMTP down'));
await expect(notifyUser({ email: 'a@mail.az', name: 'Elvin' }, sendEmail)).rejects.toThrow('SMTP down');
});
}); ✓ notify.test.js (2 tests) 4ms
Test Files 1 passed (1)
Tests 2 passed (2)jest вместо vi: jest.fn(), jest.spyOn(), jest.useFakeTimers().Правила хороших тестов
- Arrange–Act–Assert: подготовь, выполни, проверь — в каждом тесте эти три части.
- Один тест — одно поведение, а название говорит, что ожидается: «rejects a percent above 100».
- Тестируй поведение, а не внутреннее устройство, — тогда тесты переживут рефакторинг.
- Пирамида тестов: много быстрых модульных тестов, меньше интеграционных и несколько сквозных (end-to-end).
Тесты красные: в isLeapYear ошибка. Не трогая тесты, исправь функцию, чтобы все четыре теста стали зелёными. Правило: год, делящийся на 4, високосный, но годы, делящиеся на 100, високосные, только если делятся и на 400.
function isLeapYear(year) {
return year % 4 === 0;
}
const results = [];
function it(name, fn) {
try {
fn();
results.push(`✓ ${name}`);
} catch (error) {
results.push(`✗ ${name}: ${error.message}`);
}
}
const expect = (actual) => ({
toBe(expected) {
if (actual !== expected) throw new Error(`expected ${expected}, got ${actual}`);
},
});
it('2024 is a leap year', () => expect(isLeapYear(2024)).toBe(true));
it('2023 is not a leap year', () => expect(isLeapYear(2023)).toBe(false));
it('1900 is not a leap year', () => expect(isLeapYear(1900)).toBe(false));
it('2000 is a leap year', () => expect(isLeapYear(2000)).toBe(true));
console.log(results.join('\n'));▸ Ожидаемый результат
✓ 2024 is a leap year ✓ 2023 is not a leap year ✓ 1900 is not a leap year ✓ 2000 is a leap year
Напиши своего шпиона: createSpy() возвращает функцию, которая при каждом вызове записывает свои аргументы в массив spy.calls. Как показывает проверка, notifyAll должна писать только подписчикам — выводятся число вызовов и аргументы второго вызова.
function createSpy() {
const spy = (...args) => {
// record args in spy.calls
};
spy.calls = [];
return spy;
}
function notifyAll(users, send) {
for (const user of users) {
if (user.subscribed) send(user.email, 'New lesson is out!');
}
}
const send = createSpy();
notifyAll([
{ email: 'aysel@mail.az', subscribed: true },
{ email: 'murad@mail.az', subscribed: false },
{ email: 'leyla@mail.az', subscribed: true },
], send);
console.log('calls:', send.calls.length);
console.log(send.calls[1]);▸ Ожидаемый результат
calls: 2 [ 'leyla@mail.az', 'New lesson is out!' ]
Главное
- Модульный тест проверяет одно небольшое поведение:
describeгруппирует,itописывает,expect(...).matcher()проверяет. toBeсравнивает черезObject.is,toEqual— по значению; для дробей используйtoBeCloseTo, для ошибок —toThrow.- В асинхронных тестах —
awaitилиresolves/rejects; моки, шпионы и фейковые таймеры изолируют код от сети, времени и случайности. - Arrange–Act–Assert, одно поведение на тест, независимые тесты; много модульных, меньше интеграционных и сквозных тестов.
- Покрытие показывает лишь, какие строки выполнялись; TDD — это красный → зелёный → рефакторинг.
Проверь себя
Вопросов: 10. Каждый правильный ответ приносит XP.
expect([1, 2]).toBe([1, 2])?