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