JavaScript · Синтаксис · Продвинутый уровень

Тестирование в JavaScript

10 задач

Написание и структурирование модульных тестов с помощью Jest/Vitest. Охватывает describe/it/expect, моки, асинхронные тесты и поддельные таймеры.

Зачем тесты, Jest vs Vitest, структура теста, lifecycle, matchers

#
**Зачем тестирование** Тесты — исполняемая документация. Они доказывают что ваш код делает то что вы думаете, и ловят регрессии при изменениях. Без тестов каждый рефакторинг — риск. С тестами вы можете уверенно менять внутреннее — тест-сьют мгновенно скажет если что-то сломалось. **Jest vs Vitest** Оба используют одинаковый API (`describe`, `it`/`test`, `expect`). Jest — классический выбор (React-экосистема). Vitest — быстрее (в пайплайне Vite, native ESM, без шага трансформации) и является стандартом для проектов на Vite. Все примеры ниже работают в обоих. **Структура теста** ```js describe('MyModule', () => { beforeAll(() => { /* один раз перед всеми тестами */ }); afterAll(() => { /* один раз после всех тестов */ }); beforeEach(() => { /* перед каждым тестом — сброс состояния */ }); afterEach(() => { /* после каждого теста — очистка */ }); it('does X', () => { expect(fn(input)).toBe(expectedValue); }); it('throws on bad input', () => { expect(() => fn(bad)).toThrow('error message'); }); it('async test', async () => { const result = await asyncFn(); expect(result).toEqual({ ok: true }); }); }); ``` **Ключевые matchers** | Matcher | Для чего | |---|---| | `toBe(x)` | Примитивы, та же ссылка (`Object.is`) | | `toEqual(x)` | Глубокое сравнение объектов/массивов | | `toContain(x)` | Массив содержит x; строка содержит подстроку | | `toHaveLength(n)` | Длина массива/строки | | `toThrow(msg)` | Функция бросает исключение (обернуть: `() => fn()`) | | `toBeCloseTo(n)` | Сравнение float | | `resolves.toBe(x)` | Async разрешается до x | | `rejects.toThrow(msg)` | Async отклоняется |

jest.fn(), spyOn, мокирование модулей, фиктивные таймеры, test.each

#
**Мокирование с `jest.fn()` и `jest.spyOn()`** ```js const mock = jest.fn(); mock.mockReturnValue(42); mock.mockResolvedValue({ ok: true }); mock.mockRejectedValue(new Error()); mock.mockImplementation(x => x * 2); const spy = jest.spyOn(obj, 'method'); spy.mockImplementation(() => 'fake'); spy.mockRestore(); expect(mock).toHaveBeenCalled(); expect(mock).toHaveBeenCalledTimes(2); expect(mock).toHaveBeenCalledWith(arg1, arg2); ``` **Мокирование модулей с `jest.mock()`** ```js jest.mock('./api'); import { fetchUser } from './api'; fetchUser.mockResolvedValue({ id: 1, name: 'Alice' }); it('loads user', async () => { const user = await loadUserComponent(1); expect(user.name).toBe('Alice'); expect(fetchUser).toHaveBeenCalledWith(1); }); // Частичное мокирование: jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), // оставить реальные dangerousOp: jest.fn(), // замокировать только это })); ``` **Фиктивные таймеры** ```js beforeEach(() => jest.useFakeTimers()); afterEach(() => jest.useRealTimers()); it('calls after delay', () => { const fn = jest.fn(); setTimeout(fn, 1000); expect(fn).not.toHaveBeenCalled(); jest.advanceTimersByTime(1000); expect(fn).toHaveBeenCalledTimes(1); }); ``` **`test.each` — параметризованные тесты** ```js test.each([ [1, 1, 2], [2, 3, 5], [-1, 1, 0], ])('sum(%i, %i) = %i', (a, b, expected) => { expect(sum(a, b)).toBe(expected); }); ```

Паттерн AAA, тестируемый код, изоляция, ошибки, CLI справочник

#
**AAA — Arrange, Act, Assert** ```js it('returns doubled value', () => { // Arrange — подготовка данных и моков const input = 21; const double = jest.fn().mockReturnValue(42); // Act — запуск тестируемого кода const result = double(input); // Assert — проверка результата expect(result).toBe(42); expect(double).toHaveBeenCalledWith(21); }); ``` **Что делает код тестируемым** - **Внедряйте зависимости** — передавайте их как аргументы, не хардкодьте `import` - **Чистые функции** — одинаковый вход → одинаковый выход, легко unit-тестировать - **Маленькие, focused функции** — одна ответственность - **Избегайте глобального состояния** — общее мутабельное состояние → чувствительность к порядку тестов ```js // Трудно тестировать — захардкоженная зависимость async function getUser(id) { return fetch(`/api/users/${id}`).then(r => r.json()); } // Легко тестировать — внедрённая зависимость async function getUser(id, fetcher = fetch) { return fetcher(`/api/users/${id}`).then(r => r.json()); } ``` **Распространённые ошибки** - Забытый `await` в async-тесте — тест проходит без проверки - Использование `toBe` для объектов — всегда провал; используйте `toEqual` - Не вызывать `spy.mockRestore()` — загрязняет последующие тесты - Не оборачивать `toThrow` в `() =>`: `expect(() => fn(x)).toThrow()`, не `expect(fn(x)).toThrow()` - Тестирование деталей реализации вместо поведения **Изоляция тестов** ```js test.skip('not ready yet', () => { /* пропускается */ }); test.only('focus on this', () => { /* только этот запускается */ }); ``` **CLI справочник** | Команда | Что делает | |---|---| | `jest` / `vitest` | Все тесты | | `jest path/to/file` | Один файл | | `jest -t 'pattern'` | Тесты по имени | | `jest --watch` | Перезапуск при изменении | | `jest --coverage` | Отчёт покрытия | | `vitest --ui` | Browser UI |
01

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);
  });
});
02

Матчеры 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');
  });
});
03

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

Возвращаемые значения моков: 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);
  });
});
05

Асинхронные тесты: тестирование 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');
  });
});
06

Поддельные таймеры: тестирование 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);
  });
});
07

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);
  });
});
08

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

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);
  });
});
10

Внедрение зависимостей: архитектура тестируемого кода

#

Отрефакторьте функцию `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',
    });
  });
});