Перейти до вмісту
·29 хв читання

Гайд з тестування Vitest: Від нуля до впевненості в продакшені

Unit-тести, інтеграційні тести, тестування компонентів з Testing Library, стратегії мокування, налаштування покриття та філософія тестування, що справді покращує програмне забезпечення.

Поділитися:X / TwitterLinkedIn

Більшість команд пишуть тести так само, як більшість людей займаються спортом: вони знають, що повинні, відчувають провину, коли не роблять цього, а коли нарешті беруться — перестарюються в перший день і кидають до п'ятниці. Кодова база закінчується розкиданими поверхневими snapshot-тестами, що ламаються щоразу, коли хтось змінює CSS-клас, кількома інтеграційними тестами, яким ніхто не довіряє, та бейджем покриття в README, який бреше.

Я був по обидва боки. Я випускав проєкти з нуль тестів і обливався потом при кожному деплої. Я також працював у командах, що гналися за 100% покриттям і витрачали більше часу на підтримку тестів, ніж на написання фіч. Жодний підхід не працює. Працює написання правильних тестів, у правильних місцях, з правильними інструментами.

Vitest змінив моє ставлення до тестування в JavaScript. Не тому, що він винайшов нові концепції — фундаментальні принципи не змінилися з часів, коли Кент Бек писав про них десятиліття тому. А тому, що він усунув достатньо тертя, щоб написання тестів перестало відчуватися як рутина й почало відчуватися як частина циклу розробки. Коли ваш тест-раннер такий же швидкий, як ваш dev-сервер, і використовує ту саму конфігурацію, виправдання зникають.

Ця стаття — все, що я знаю про тестування з Vitest, від початкового налаштування до філософії, яка робить це все вартим зусиль.

Чому Vitest замість Jest#

Якщо ви використовували Jest, ви вже знаєте більшість API Vitest. Це зроблено навмисно — Vitest сумісний з Jest на рівні API. describe, it, expect, beforeEach, vi.fn() — все це працює. То навіщо переходити?

Нативна підтримка ESM#

Jest був створений для CommonJS. Він може працювати з ESM, але це вимагає налаштування, експериментальних прапорців і періодичних молитов. Якщо ви використовуєте синтаксис import/export (а це все сучасне), ви напевно боролися з конвеєром трансформації Jest.

Vitest працює на Vite. Vite розуміє ESM нативно. Немає кроку трансформації для вашого вихідного коду — він просто працює. Це важливіше, ніж здається. Половина проблем Jest, які я відлагоджував за роки, зводились до розрішення модулів: SyntaxError: Cannot use import statement outside a module, або моки не працюють, бо модуль вже закешований в іншому форматі.

Та сама конфігурація, що й у dev-сервера#

Якщо ваш проєкт використовує Vite (а якщо ви створюєте React, Vue або Svelte-додаток у 2026 році, то, ймовірно, так), Vitest автоматично читає ваш vite.config.ts. Ваші аліаси шляхів, плагіни та змінні оточення працюють у тестах без будь-якої додаткової конфігурації. З Jest вам потрібно підтримувати паралельну конфігурацію, яка має залишатися синхронізованою з налаштуваннями бандлера. Кожного разу, коли ви додаєте аліас шляху в vite.config.ts, вам потрібно не забути додати відповідний moduleNameMapper у jest.config.ts. Це дрібниця, але дрібниці накопичуються.

Швидкість#

Vitest швидкий. Суттєво швидкий. Не "економить дві секунди" швидкий — "змінює спосіб роботи" швидкий. Він використовує граф модулів Vite, щоб зрозуміти, які тести зачепила зміна файлу, й запускає лише їх. Його watch-режим використовує ту саму інфраструктуру HMR, яка робить dev-сервер Vite миттєвим.

На проєкті з 400+ тестами перехід від Jest до Vitest скоротив наш цикл зворотного зв'язку у watch-режимі з ~4 секунд до менше 500 мс. Це різниця між "Я почекаю, поки тест пройде" і "Я гляну на термінал, поки мої пальці ще на клавіатурі."

Вбудований бенчмаркінг#

Vitest включає bench() з коробки для тестування продуктивності. Не потрібна окрема бібліотека:

typescript
import { bench, describe } from "vitest";
 
describe("string concatenation", () => {
  bench("template literals", () => {
    const name = "world";
    const _result = `hello ${name}`;
  });
 
  bench("string concat", () => {
    const name = "world";
    const _result = "hello " + name;
  });
});

Запустіть за допомогою vitest bench. Це не головна подія, але приємно мати тестування продуктивності в тому самому тулчейні без встановлення benchmark.js та налаштування окремого раннера.

Налаштування#

Встановлення#

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Конфігурація#

Створіть vitest.config.ts у кореневій директорії проєкту:

typescript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    css: false,
  },
});

Або, якщо у вас вже є vite.config.ts, ви можете розширити його:

typescript
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
 
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": "/src",
    },
  },
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

Рішення щодо globals: true#

Коли globals встановлено в true, вам не потрібно імпортувати describe, it, expect, beforeEach тощо — вони доступні скрізь, як і в Jest. Коли false, ви імпортуєте їх явно:

typescript
// globals: false
import { describe, it, expect } from "vitest";
 
describe("math", () => {
  it("adds numbers", () => {
    expect(1 + 1).toBe(2);
  });
});

Я використовую globals: true, бо це зменшує візуальний шум і відповідає очікуванням більшості розробників. Якщо ваша команда цінує явні імпорти, встановіть false — тут немає неправильної відповіді.

Якщо ви використовуєте globals: true, додайте типи Vitest до вашого tsconfig.json, щоб TypeScript їх розпізнавав:

json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

Середовище: jsdom vs happy-dom vs node#

Vitest дозволяє обирати реалізацію DOM для кожного тесту або глобально:

  • node — Без DOM. Для чистої логіки, утиліт, API-маршрутів та всього, що не торкається браузера.
  • jsdom — Стандарт. Повна реалізація DOM. Важча, але повніша.
  • happy-dom — Легша та швидша за jsdom, але менш повна. Деякі крайні випадки (як Range, Selection або IntersectionObserver) можуть не працювати.

Я за замовчуванням використовую jsdom глобально та перевизначаю для кожного файлу, коли потрібен node:

typescript
// src/lib/utils.test.ts
// @vitest-environment node
 
import { formatDate, slugify } from "./utils";
 
describe("slugify", () => {
  it("converts spaces to hyphens", () => {
    expect(slugify("hello world")).toBe("hello-world");
  });
});

Файли налаштування#

Файл налаштування запускається перед кожним тестовим файлом. Тут ви конфігуруєте матчери Testing Library та глобальні моки:

typescript
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
 
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
 
Object.defineProperty(window, "IntersectionObserver", {
  writable: true,
  value: MockIntersectionObserver,
});
 
// Mock window.matchMedia — needed for responsive components
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

Імпорт @testing-library/jest-dom/vitest дає вам матчери на кшталт toBeInTheDocument(), toHaveClass(), toBeVisible() та багато інших. Вони роблять твердження щодо DOM-елементів читабельними та виразними.

Написання якісних тестів#

Патерн AAA#

Кожен тест слідує одній структурі: Arrange (Підготовка), Act (Дія), Assert (Перевірка). Навіть коли ви не пишете явні коментарі, структура повинна бути помітною:

typescript
it("calculates total with tax", () => {
  // Arrange
  const items = [
    { name: "Widget", price: 10 },
    { name: "Gadget", price: 20 },
  ];
  const taxRate = 0.08;
 
  // Act
  const total = calculateTotal(items, taxRate);
 
  // Assert
  expect(total).toBe(32.4);
});

Коли я бачу тест, який змішує підготовку, дію та перевірку в єдиний ланцюжок викликів методів, я знаю, що його буде важко зрозуміти, коли він впаде. Тримайте три фази візуально розділеними, навіть якщо ви не додаєте коментарі.

Іменування тестів#

Є дві школи: it('should calculate total with tax') та it('calculates total with tax'). Префікс "should" — багатослівний без додавання інформації. Коли тест впаде, ви побачите:

FAIL  ✕ calculates total with tax

Це вже повне речення. Додавання "should" лише додає шуму. Я віддаю перевагу прямій формі: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').

Для блоків describe використовуйте ім'я одиниці, що тестується:

typescript
describe("calculateTotal", () => {
  it("sums item prices", () => { /* ... */ });
  it("applies tax rate", () => { /* ... */ });
  it("returns 0 for empty array", () => { /* ... */ });
  it("handles negative prices", () => { /* ... */ });
});

Прочитайте вголос: "calculateTotal sums item prices." "calculateTotal applies tax rate." Якщо речення працює, іменування працює.

Одне твердження на тест чи практичне групування#

Пуристське правило каже: одне твердження на тест. Практичне правило каже: одна концепція на тест. Це різні речі.

typescript
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
  const user = { firstName: "John", lastName: "Doe", title: "Dr." };
  const result = formatDisplayName(user);
 
  expect(result).toContain("John");
  expect(result).toContain("Doe");
  expect(result).toStartWith("Dr.");
});
 
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
  const user = createUser("John");
  expect(user.id).toBeDefined();
 
  const updated = updateUser(user.id, { name: "Jane" });
  expect(updated.name).toBe("Jane");
 
  deleteUser(user.id);
  expect(getUser(user.id)).toBeNull();
});

Перший тест має три твердження, але всі вони перевіряють одну річ: формат відображуваного імені. Якщо будь-яке твердження впаде, ви точно знаєте, що зламано. Другий тест — це три окремих тести, втиснутих разом. Якщо друге твердження впаде, ви не знаєте, чи зламано створення чи оновлення, і третє твердження ніколи не виконується.

Опис тестів як документація#

Якісні тестові набори слугують живою документацією. Людина, незнайома з кодом, повинна мати змогу прочитати описи тестів і зрозуміти поведінку функціоналу:

typescript
describe("PasswordValidator", () => {
  describe("minimum length", () => {
    it("rejects passwords shorter than 8 characters", () => { /* ... */ });
    it("accepts passwords with exactly 8 characters", () => { /* ... */ });
  });
 
  describe("character requirements", () => {
    it("requires at least one uppercase letter", () => { /* ... */ });
    it("requires at least one number", () => { /* ... */ });
    it("requires at least one special character", () => { /* ... */ });
  });
 
  describe("common password check", () => {
    it("rejects passwords in the common passwords list", () => { /* ... */ });
    it("performs case-insensitive comparison", () => { /* ... */ });
  });
});

Коли цей тестовий набір запускається, вивід читається як специфікація. Ось що є метою.

Мокування#

Мокування — найпотужніший і водночас найнебезпечніший інструмент у вашому тестовому наборі інструментів. При правильному використанні він ізолює одиницю, що тестується, і робить тести швидкими та детермінованими. При неправильному — створює тести, які проходять незалежно від того, що робить код.

vi.fn() — Створення мок-функцій#

Найпростіший мок — це функція, що записує свої виклики:

typescript
const mockCallback = vi.fn();
 
// Call it
mockCallback("hello", 42);
mockCallback("world");
 
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");

Ви можете задати їй значення, що повертається:

typescript
const mockFetch = vi.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ id: 1, name: "Test" }),
});

Або змусити її повертати різні значення при послідовних викликах:

typescript
const mockRandom = vi.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.5)
  .mockReturnValueOnce(0.9);

vi.spyOn() — Спостереження за реальними методами#

Коли ви хочете спостерігати за методом без заміни його поведінки:

typescript
const consoleSpy = vi.spyOn(console, "warn");
 
validateInput("");
 
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();

spyOn зберігає оригінальну реалізацію за замовчуванням. Ви можете перевизначити її за допомогою .mockImplementation(), коли потрібно, але потім відновіть оригінал за допомогою .mockRestore().

vi.mock() — Мокування на рівні модуля#

Це головна зброя. vi.mock() замінює цілий модуль:

typescript
// Mock the entire module
vi.mock("@/lib/api", () => ({
  fetchUsers: vi.fn().mockResolvedValue([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
  ]),
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
 
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
 
describe("UserList", () => {
  it("displays users from API", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(2);
  });
});

Vitest автоматично піднімає виклики vi.mock() на початок файлу. Це означає, що мок стає на місце до виконання будь-яких імпортів. Вам не потрібно турбуватися про порядок імпортів.

Автоматичне мокування#

Якщо ви просто хочете, щоб кожен експорт був замінений на vi.fn():

typescript
vi.mock("@/lib/analytics");
 
import { trackEvent, trackPageView } from "@/lib/analytics";
 
it("tracks form submission", () => {
  submitForm();
  expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});

Без фабричної функції Vitest автоматично мокує всі експорти. Кожна експортована функція стає vi.fn(), що повертає undefined. Це корисно для модулів, які ви хочете заглушити (як аналітика або логування) без зазначення кожної функції.

Очищення vs Скидання vs Відновлення#

Це заплутує всіх рано чи пізно:

typescript
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
 
// mockClear — resets call history, keeps implementation
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
 
// mockReset — resets call history AND implementation
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
 
// mockRestore — for spies, restores original implementation
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally again

На практиці використовуйте vi.clearAllMocks() у beforeEach для скидання історії викликів між тестами. Використовуйте vi.restoreAllMocks(), якщо ви використовуєте spyOn і хочете повернути оригінали:

typescript
beforeEach(() => {
  vi.clearAllMocks();
});
 
afterEach(() => {
  vi.restoreAllMocks();
});

Небезпека надмірного мокування#

Це найважливіша порада щодо мокування, яку я можу дати: кожен мок — це брехня, яку ви розповідаєте своєму тесту. Коли ви мокуєте залежність, ви говорите "Я довіряю, що ця річ працює правильно, тому замінюю її спрощеною версією." Якщо ваше припущення хибне, тест проходить, але функціонал зламаний.

typescript
// Over-mocked — tests nothing useful
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
 
it("processes user input", () => {
  processInput("hello");
  expect(validator.validate).toHaveBeenCalledWith("hello");
  expect(formatter.format).toHaveBeenCalledWith("hello");
});

Цей тест перевіряє, що processInput викликає validate і format. Але що, якщо processInput викликає їх у неправильному порядку? Що, якщо він ігнорує їх повернуті значення? Що, якщо валідація повинна запобігти виконанню кроку форматування? Тест не знає. Ви замокали всю цікаву поведінку.

Правило: мокуйте на межах, а не всередині. Мокуйте мережеві запити, доступ до файлової системи та сторонні сервіси. Не мокуйте свої власні утилітні функції, хіба що є вагома причина (наприклад, вони дорогі у виконанні або мають побічні ефекти).

Тестування React-компонентів#

Основи з Testing Library#

Testing Library нав'язує філософію: тестуйте компоненти так, як з ними взаємодіють користувачі. Ніякої перевірки внутрішнього стану, ніякої інспекції екземплярів компонентів, ніякого поверхневого рендерингу. Ви рендерите компонент і взаємодієте з ним через DOM, як це робив би користувач.

typescript
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
 
describe("Button", () => {
  it("renders with label text", () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
  });
 
  it("applies variant classes", () => {
    render(<Button variant="primary">Submit</Button>);
    const button = screen.getByRole("button");
    expect(button).toHaveClass("bg-primary");
  });
 
  it("is disabled when disabled prop is true", () => {
    render(<Button disabled>Submit</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Запити: getBy vs queryBy vs findBy#

Ось де початківці заплутуються. Є три варіанти запитів, і кожен має конкретний випадок використання:

getBy* — Повертає елемент або кидає помилку, якщо не знайдено. Використовуйте, коли очікуєте, що елемент існує:

typescript
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });

queryBy* — Повертає елемент або null, якщо не знайдено. Використовуйте, коли перевіряєте, що чогось НЕМАЄ:

typescript
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();

findBy* — Повертає Promise. Використовуйте для елементів, що з'являються асинхронно:

typescript
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();

Запити з пріоритетом доступності#

Testing Library надає ці запити в навмисному порядку пріоритету:

  1. getByRole — Найкращий запит. Використовує ARIA-ролі. Якщо ваш компонент не знаходиться за роллю, у нього може бути проблема з доступністю.
  2. getByLabelText — Для елементів форм. Якщо ваш input не має label, спочатку виправте це.
  3. getByPlaceholderText — Прийнятний, але слабший. Плейсхолдери зникають, коли користувач друкує.
  4. getByText — Для неінтерактивних елементів. Знаходить за видимим текстовим вмістом.
  5. getByTestId — Крайній випадок. Використовуйте, коли жоден семантичний запит не працює.
typescript
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
 
// Over this
screen.getByPlaceholderText("Enter your email");
 
// And definitely over this
screen.getByTestId("email-input");

Рейтинг не випадковий. Він відповідає тому, як допоміжні технології навігують сторінкою. Якщо ви можете знайти елемент за його роллю та доступним ім'ям, скрін-рідери теж можуть. Якщо ви можете знайти його лише за test ID, можливо, у вас є прогалина в доступності.

Користувацькі події#

Не використовуйте fireEvent. Використовуйте @testing-library/user-event. Різниця має значення:

typescript
import userEvent from "@testing-library/user-event";
 
describe("SearchInput", () => {
  it("filters results as user types", async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn();
 
    render(<SearchInput onSearch={onSearch} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "vitest");
 
    // user.type fires keydown, keypress, input, keyup for EACH character
    // fireEvent.change just sets the value — skipping realistic event flow
    expect(onSearch).toHaveBeenLastCalledWith("vitest");
  });
 
  it("clears input on escape key", async () => {
    const user = userEvent.setup();
 
    render(<SearchInput onSearch={vi.fn()} />);
 
    const input = screen.getByRole("searchbox");
    await user.type(input, "hello");
    await user.keyboard("{Escape}");
 
    expect(input).toHaveValue("");
  });
});

userEvent симулює повний ланцюжок подій, який справжній браузер би згенерував. fireEvent.change — це одна синтетична подія. Якщо ваш компонент слухає onKeyDown або використовує onInput замість onChange, fireEvent.change не запустить ці обробники, а userEvent.type — запустить.

Завжди викликайте userEvent.setup() на початку і використовуйте повернутий екземпляр user. Це забезпечує правильний порядок подій та відстеження стану.

Тестування взаємодій компонентів#

Реалістичний тест компонента виглядає так:

typescript
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
 
describe("TodoList", () => {
  it("adds a new todo item", async () => {
    const user = userEvent.setup();
    render(<TodoList />);
 
    const input = screen.getByRole("textbox", { name: /new todo/i });
    const addButton = screen.getByRole("button", { name: /add/i });
 
    await user.type(input, "Write tests");
    await user.click(addButton);
 
    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(input).toHaveValue("");
  });
 
  it("marks a todo as completed", async () => {
    const user = userEvent.setup();
    render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
 
    const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
    await user.click(checkbox);
 
    expect(checkbox).toBeChecked();
  });
 
  it("removes completed items when clear button is clicked", async () => {
    const user = userEvent.setup();
    render(
      <TodoList
        initialItems={[
          { id: "1", text: "Done task", done: true },
          { id: "2", text: "Pending task", done: false },
        ]}
      />
    );
 
    await user.click(screen.getByRole("button", { name: /clear completed/i }));
 
    expect(screen.queryByText("Done task")).not.toBeInTheDocument();
    expect(screen.getByText("Pending task")).toBeInTheDocument();
  });
});

Зверніть увагу: жодної інспекції внутрішнього стану, жодного component.setState(), жодної перевірки деталей реалізації. Ми рендеримо, взаємодіємо, перевіряємо те, що побачив би користувач. Якщо компонент рефакторить своє внутрішнє управління станом з useState на useReducer, ці тести все одно проходять. У цьому й суть.

Тестування асинхронного коду#

waitFor#

Коли компонент оновлюється асинхронно, waitFor опитує, доки твердження не пройде:

typescript
import { render, screen, waitFor } from "@testing-library/react";
 
it("loads and displays user profile", async () => {
  render(<UserProfile userId="123" />);
 
  // Initially shows loading
  expect(screen.getByText("Loading...")).toBeInTheDocument();
 
  // Wait for content to appear
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  // Loading indicator should be gone
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});

waitFor повторює колбек кожні 50 мс (за замовчуванням), доки він не пройде або не вичерпається час (1000 мс за замовчуванням). Ви можете налаштувати обидва параметри:

typescript
await waitFor(
  () => expect(screen.getByText("Done")).toBeInTheDocument(),
  { timeout: 3000, interval: 100 }
);

Фейкові таймери#

При тестуванні коду, що використовує setTimeout, setInterval або Date, фейкові таймери дозволяють контролювати час:

typescript
describe("Debounce", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("delays execution by the specified time", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
 
  it("resets timer on subsequent calls", () => {
    const callback = vi.fn();
    const debounced = debounce(callback, 300);
 
    debounced();
    vi.advanceTimersByTime(200);
 
    debounced(); // reset the timer
    vi.advanceTimersByTime(200);
    expect(callback).not.toHaveBeenCalled();
 
    vi.advanceTimersByTime(100);
    expect(callback).toHaveBeenCalledOnce();
  });
});

Важливо: завжди викликайте vi.useRealTimers() у afterEach. Фейкові таймери, що витікають між тестами, спричиняють найбільш заплутані збої, які вам доведеться відлагоджувати.

Тестування з фейковими таймерами та асинхронним рендерингом#

Поєднання фейкових таймерів з тестуванням React-компонентів вимагає обережності. Внутрішнє планування React використовує реальні таймери, тому часто потрібно просувати таймери ТА вирівнювати оновлення React одночасно:

typescript
import { render, screen, act } from "@testing-library/react";
 
describe("Notification", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
 
  afterEach(() => {
    vi.useRealTimers();
  });
 
  it("auto-dismisses after 5 seconds", async () => {
    render(<Notification message="Saved!" autoDismiss={5000} />);
 
    expect(screen.getByText("Saved!")).toBeInTheDocument();
 
    // Advance timers inside act() to flush React updates
    await act(async () => {
      vi.advanceTimersByTime(5000);
    });
 
    expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
  });
});

Мокування API з MSW#

Для тестування отримання даних Mock Service Worker (MSW) перехоплює мережеві запити на мережевому рівні. Це означає, що ваш код fetch/axios компонента виконується точно так, як у продакшені — MSW лише замінює мережеву відповідь:

typescript
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
 
const server = setupServer(
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com" },
      { id: 2, name: "Bob", email: "bob@example.com" },
    ]);
  }),
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: "Alice",
      email: "alice@example.com",
    });
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe("UserList", () => {
  it("displays users from API", async () => {
    render(<UserList />);
 
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(await screen.findByText("Bob")).toBeInTheDocument();
  });
 
  it("shows error state when API fails", async () => {
    // Override the default handler for this one test
    server.use(
      http.get("/api/users", () => {
        return new HttpResponse(null, { status: 500 });
      })
    );
 
    render(<UserList />);
 
    expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
  });
});

MSW кращий за пряме мокування fetch або axios, тому що:

  1. Реальний код отримання даних вашого компонента виконується — ви тестуєте справжню інтеграцію.
  2. Ви можете тестувати обробку помилок, перевизначаючи обробники для кожного тесту.
  3. Ті самі обробники працюють як у тестах, так і в режимі розробки в браузері (Storybook, наприклад).

Інтеграційні тести#

Unit-тести vs інтеграційні тести#

Unit-тест ізолює окрему функцію або компонент і мокує все інше. Інтеграційний тест дозволяє кільком одиницям працювати разом і мокує лише зовнішні межі (мережа, файлова система, бази даних).

Правда така: більшість помилок, які я бачив на продакшені, виникають на межах між одиницями, а не всередині них. Функція працює ідеально ізольовано, але ламається, бо викликач передає дані в дещо іншому форматі. Компонент рендериться чудово з моковими даними, але ламається, коли реальна API-відповідь має додатковий рівень вкладеності.

Інтеграційні тести ловлять ці помилки. Вони повільніші за unit-тести і складніші для відлагодження, коли падають, але дають більше впевненості на один тест.

Тестування кількох компонентів разом#

typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
 
// Only mock the API layer — everything else is real
vi.mock("@/lib/api", () => ({
  checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
 
describe("Shopping Cart Flow", () => {
  const renderCart = (initialItems = []) => {
    return render(
      <CartProvider initialItems={initialItems}>
        <ShoppingCart />
      </CartProvider>
    );
  };
 
  it("displays item count and total", () => {
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
      { id: "2", name: "Mouse", price: 49.99, quantity: 2 },
    ]);
 
    expect(screen.getByText("3 items")).toBeInTheDocument();
    expect(screen.getByText("$179.97")).toBeInTheDocument();
  });
 
  it("updates quantity and recalculates total", async () => {
    const user = userEvent.setup();
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
    await user.click(incrementButton);
 
    expect(screen.getByText("$159.98")).toBeInTheDocument();
  });
 
  it("completes checkout flow", async () => {
    const user = userEvent.setup();
    const { checkout } = await import("@/lib/api");
 
    renderCart([
      { id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
    ]);
 
    await user.click(screen.getByRole("button", { name: /checkout/i }));
 
    expect(checkout).toHaveBeenCalledWith({
      items: [{ id: "1", quantity: 1 }],
    });
 
    expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
    expect(screen.getByText("ORD-123")).toBeInTheDocument();
  });
});

У цьому тесті ShoppingCart, CartProvider та їх внутрішні компоненти (рядки товарів, селектори кількості, відображення підсумків) працюють разом з реальним кодом. Єдиний мок — це API-виклик, бо ми не хочемо робити реальні мережеві запити в тестах.

Коли використовувати інтеграційні vs unit-тести#

Unit-тести використовуйте, коли:

  • Логіка складна і має багато крайніх випадків (парсер дат, стейт-машина, обчислення).
  • Вам потрібен швидкий зворотний зв'язок щодо поведінки конкретної функції.
  • Одиниця відносно ізольована і не сильно залежить від інших одиниць.

Інтеграційні тести використовуйте, коли:

  • Кілька компонентів повинні коректно працювати разом (форма з валідацією та відправленням).
  • Дані проходять через кілька шарів (контекст → компонент → дочірній компонент).
  • Ви тестуєте робочий процес користувача, а не повернуте значення функції.

На практиці здоровий тестовий набір має багато інтеграційних тестів для фіч і unit-тести для складних утиліт. Самі компоненти тестуються через інтеграційні тести — вам не потрібен окремий unit-тест для кожного маленького компонента, якщо інтеграційний тест його використовує.

Покриття#

Запуск покриття#

bash
vitest run --coverage

Вам знадобиться провайдер покриття. Vitest підтримує два:

bash
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
 
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbul

Налаштуйте його у вашій конфігурації Vitest:

typescript
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.test.{ts,tsx}",
        "src/**/*.spec.{ts,tsx}",
        "src/test/**",
        "src/**/*.d.ts",
        "src/**/types.ts",
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

Istanbul vs V8#

Покриття V8 використовує вбудовану інструментацію рушія V8. Це швидше, бо немає кроку трансформації коду. Але може бути менш точним для деяких крайніх випадків, особливо на межах ES-модулів.

Istanbul інструментує ваш вихідний код лічильниками перед запуском тестів. Він повільніший, але більш перевірений часом і дає точніше покриття гілок. Якщо ви застосовуєте пороги покриття в CI, точність Istanbul може мати значення.

Я використовую V8 для локальної розробки (швидший зворотний зв'язок) та Istanbul у CI (точніше застосування). Ви можете налаштувати різних провайдерів для різних середовищ, якщо потрібно.

Що насправді означає покриття#

Покриття показує, які рядки коду були виконані під час тестів. Ось і все. Воно не говорить вам, чи ці рядки були протестовані правильно. Розгляньте:

typescript
function divide(a: number, b: number): number {
  return a / b;
}
 
it("divides numbers", () => {
  divide(10, 2);
  // No assertion!
});

Цей тест дає 100% покриття функції divide. Він також не тестує абсолютно нічого. Тест би пройшов, якби divide повернула null, кинула помилку або запустила ракети.

Покриття — корисний негативний індикатор: низьке покриття означає, що точно є непротестовані шляхи. Але високе покриття не означає, що ваш код добре протестований. Це лише означає, що кожен рядок виконався під час якогось тесту.

Рядки vs гілки#

Покриття рядків — найпоширеніша метрика, але покриття гілок має більшу цінність:

typescript
function getDiscount(user: User): number {
  if (user.isPremium) {
    return user.yearsActive > 5 ? 0.2 : 0.1;
  }
  return 0;
}

Тест з getDiscount({ isPremium: true, yearsActive: 10 }) проходить кожен рядок (100% покриття рядків), але тестує лише дві з трьох гілок. Шляхи isPremium: false та yearsActive <= 5 не протестовані.

Покриття гілок це ловить. Воно відстежує кожен можливий шлях через умовну логіку. Якщо ви збираєтеся застосовувати поріг покриття, використовуйте покриття гілок.

Ігнорування згенерованого коду#

Деякий код не повинен враховуватися в покритті. Згенеровані файли, визначення типів, конфігурація — вони завищують ваші метрики без додавання цінності:

typescript
// vitest.config.ts
coverage: {
  exclude: [
    "src/**/*.d.ts",
    "src/**/types.ts",
    "src/**/*.stories.tsx",
    "src/generated/**",
    ".velite/**",
  ],
}

Ви також можете ігнорувати конкретні рядки або блоки у вашому вихідному коді:

typescript
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
  console.log("Debug info:", data);
}
/* v8 ignore stop */
 
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }

Використовуйте це обережно. Якщо ви виявите, що ігноруєте великі фрагменти коду, або ці фрагменти потребують тестів, або їх не повинно бути у звіті покриття з самого початку.

Тестування Next.js#

Мокування next/navigation#

Компоненти Next.js, що використовують useRouter, usePathname або useSearchParams, потребують моків:

typescript
import { vi } from "vitest";
 
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
    prefetch: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => "/en/blog",
  useSearchParams: () => new URLSearchParams("?page=1"),
  useParams: () => ({ locale: "en" }),
}));

Для тестів, що повинні перевірити виклик навігації:

typescript
import { useRouter } from "next/navigation";
 
vi.mock("next/navigation", () => ({
  useRouter: vi.fn(),
}));
 
describe("LogoutButton", () => {
  it("redirects to home after logout", async () => {
    const mockPush = vi.fn();
    vi.mocked(useRouter).mockReturnValue({
      push: mockPush,
      replace: vi.fn(),
      back: vi.fn(),
      prefetch: vi.fn(),
      refresh: vi.fn(),
      forward: vi.fn(),
    });
 
    const user = userEvent.setup();
    render(<LogoutButton />);
 
    await user.click(screen.getByRole("button", { name: /log out/i }));
 
    expect(mockPush).toHaveBeenCalledWith("/");
  });
});

Мокування next-intl#

Для інтернаціоналізованих компонентів, що використовують next-intl:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => (key: string) => key,
  useLocale: () => "en",
}));

Це найпростіший підхід — переклади повертають сам ключ, тому t("hero.title") повертає "hero.title". У твердженнях ви перевіряєте ключ перекладу замість фактично перекладеного рядка. Це робить тести незалежними від мови.

Якщо вам потрібні реальні переклади в конкретному тесті:

typescript
vi.mock("next-intl", () => ({
  useTranslations: () => {
    const translations: Record<string, string> = {
      "hero.title": "Welcome to My Site",
      "hero.subtitle": "Building things for the web",
    };
    return (key: string) => translations[key] ?? key;
  },
}));

Тестування Route Handlers#

Route Handlers Next.js — це звичайні функції, що приймають Request і повертають Response. Їх просто тестувати:

typescript
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("GET /api/users", () => {
  it("returns users list", async () => {
    const request = new NextRequest("http://localhost:3000/api/users");
    const response = await GET(request);
    const data = await response.json();
 
    expect(response.status).toBe(200);
    expect(data).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
      ])
    );
  });
 
  it("supports pagination via search params", async () => {
    const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
    const response = await GET(request);
    const data = await response.json();
 
    expect(data.page).toBe(2);
    expect(data.items).toHaveLength(10);
  });
});
 
describe("POST /api/users", () => {
  it("creates a new user", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
    });
 
    const response = await POST(request);
    const data = await response.json();
 
    expect(response.status).toBe(201);
    expect(data.name).toBe("Alice");
  });
 
  it("returns 400 for invalid body", async () => {
    const request = new NextRequest("http://localhost:3000/api/users", {
      method: "POST",
      body: JSON.stringify({ name: "" }),
    });
 
    const response = await POST(request);
    expect(response.status).toBe(400);
  });
});

Тестування Middleware#

Middleware Next.js працює на edge і обробляє кожен запит. Тестуйте його як функцію:

typescript
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
 
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
  const url = new URL(path, "http://localhost:3000");
  return new NextRequest(url, { headers });
}
 
describe("middleware", () => {
  it("redirects unauthenticated users from protected routes", async () => {
    const request = createRequest("/dashboard");
    const response = await middleware(request);
 
    expect(response.status).toBe(307);
    expect(response.headers.get("location")).toContain("/login");
  });
 
  it("allows authenticated users through", async () => {
    const request = createRequest("/dashboard", {
      cookie: "session=valid-token",
    });
    const response = await middleware(request);
 
    expect(response.status).toBe(200);
  });
 
  it("adds security headers", async () => {
    const request = createRequest("/");
    const response = await middleware(request);
 
    expect(response.headers.get("x-frame-options")).toBe("DENY");
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
  });
 
  it("handles locale detection", async () => {
    const request = createRequest("/", {
      "accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
    });
    const response = await middleware(request);
 
    expect(response.headers.get("location")).toContain("/tr");
  });
});

Тестування Server Actions#

Server Actions — це асинхронні функції, що виконуються на сервері. Оскільки це просто функції, ви можете тестувати їх безпосередньо — але вам може знадобитися мокувати серверні залежності:

typescript
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
      findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
    },
  },
}));
 
vi.mock("next/cache", () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));
 
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
 
describe("updateProfile", () => {
  it("updates user and revalidates profile page", async () => {
    const formData = new FormData();
    formData.set("name", "New Name");
    formData.set("bio", "New bio text");
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(true);
    expect(revalidatePath).toHaveBeenCalledWith("/profile");
  });
 
  it("returns error for invalid data", async () => {
    const formData = new FormData();
    // Missing required fields
 
    const result = await updateProfile(formData);
 
    expect(result.success).toBe(false);
    expect(result.error).toBeDefined();
  });
});

Практичні патерни#

Кастомна функція рендерингу#

Більшість проєктів потребують однакових провайдерів, обгорнутих навколо кожного компонента. Створіть кастомний рендер:

typescript
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
 
interface CustomRenderOptions extends RenderOptions {
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}
 
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
  children: React.ReactNode;
  theme?: "light" | "dark";
  initialCartItems?: CartItem[];
}) {
  return (
    <ThemeProvider defaultTheme={theme}>
      <CartProvider initialItems={initialCartItems}>
        {children}
      </CartProvider>
    </ThemeProvider>
  );
}
 
export function renderWithProviders(
  ui: ReactElement,
  options: CustomRenderOptions = {}
) {
  const { theme, initialCartItems, ...renderOptions } = options;
 
  return render(ui, {
    wrapper: ({ children }) => (
      <AllProviders theme={theme} initialCartItems={initialCartItems}>
        {children}
      </AllProviders>
    ),
    ...renderOptions,
  });
}
 
// Re-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };

Тепер кожен тестовий файл імпортує з ваших кастомних утиліт замість @testing-library/react:

typescript
import { render, screen } from "@/test/utils";
 
it("renders in dark mode", () => {
  render(<Header />, { theme: "dark" });
  // Header and all its children have access to ThemeProvider and CartProvider
});

Тестування кастомних хуків#

Vitest працює з renderHook з @testing-library/react:

typescript
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
 
describe("useCounter", () => {
  it("starts at initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });
 
  it("increments", () => {
    const { result } = renderHook(() => useCounter(0));
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  it("decrements with floor", () => {
    const { result } = renderHook(() => useCounter(0, { min: 0 }));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(0); // doesn't go below min
  });
});

Тестування Error Boundaries#

typescript
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
 
const ThrowingComponent = () => {
  throw new Error("Test explosion");
};
 
describe("ErrorBoundary", () => {
  // Suppress console.error for expected errors
  beforeEach(() => {
    vi.spyOn(console, "error").mockImplementation(() => {});
  });
 
  afterEach(() => {
    vi.restoreAllMocks();
  });
 
  it("displays fallback UI when child throws", () => {
    render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowingComponent />
      </ErrorBoundary>
    );
 
    expect(screen.getByText("Something went wrong")).toBeInTheDocument();
  });
 
  it("renders children when no error", () => {
    render(
      <ErrorBoundary fallback={<div>Error</div>}>
        <div>All good</div>
      </ErrorBoundary>
    );
 
    expect(screen.getByText("All good")).toBeInTheDocument();
    expect(screen.queryByText("Error")).not.toBeInTheDocument();
  });
});

Snapshot-тестування (обережно)#

Snapshot-тести мають погану репутацію, бо люди використовують їх як замінник реальних тверджень. Знімок усього HTML-виводу компонента — це тягар обслуговування: він ламається при кожній зміні CSS-класу, і ніхто не переглядає diff уважно.

Але цілеспрямовані знімки можуть бути корисними:

typescript
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
 
// Good — small, targeted snapshot of a pure function's output
it("formats various currency values consistently", () => {
  expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
  expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
  expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
  expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
 
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
  const { container } = render(<Dashboard />);
  expect(container).toMatchSnapshot(); // Don't do this
});

Inline-знімки (toMatchInlineSnapshot) кращі за файлові знімки, бо очікуване значення видно прямо в тесті. Ви можете з першого погляду побачити, що функція повертає, без відкриття окремого .snap файлу.

Філософія тестування#

Тестуйте поведінку, а не реалізацію#

Цей принцип настільки важливий, що заслуговує на окремий розділ. Розгляньте два тести одного й того ж функціоналу:

typescript
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
  const setStateSpy = vi.spyOn(React, "useState");
  render(<Counter />);
  fireEvent.click(screen.getByText("+"));
  expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
 
// Behavior test — resilient, tests what the user sees
it("increments the displayed count when plus button is clicked", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  expect(screen.getByText("Count: 0")).toBeInTheDocument();
 
  await user.click(screen.getByRole("button", { name: "+" }));
 
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

Перший тест ламається, якщо ви перейдете з useState на useReducer, навіть якщо компонент працює абсолютно так само. Другий тест ламається лише тоді, коли поведінка компонента дійсно змінюється. Йому байдуже, як рахунок управляється внутрішньо — лише те, що натискання "+" збільшує число.

Лакмусовий тест простий: чи можете ви рефакторити реалізацію, не змінюючи тест? Якщо так, ви тестуєте поведінку. Якщо ні, ви тестуєте реалізацію.

Тестовий трофей#

Кент С. Доддс запропонував "Тестовий трофей" як альтернативу традиційній піраміді тестування:

    ╭─────────╮
    │  E2E    │   Few — expensive, slow, high confidence
    ├─────────┤
    │         │
    │ Integr. │   Most — good confidence-to-cost ratio
    │         │
    ├─────────┤
    │  Unit   │   Some — fast, focused, low-cost
    ├─────────┤
    │ Static  │   Always — TypeScript, ESLint
    ╰─────────╯

Традиційна піраміда ставить unit-тести внизу (багато) та інтеграційні тести посередині (менше). Трофей перевертає це: інтеграційні тести — оптимальна точка. Ось чому:

  • Статичний аналіз (TypeScript, ESLint) ловить друкарські помилки, неправильні типи та прості логічні помилки безкоштовно. Вам навіть не потрібно нічого запускати.
  • Unit-тести чудові для складної чистої логіки, але не показують, чи працюють частини разом.
  • Інтеграційні тести перевіряють, що компоненти, хуки та контексти працюють разом. Вони дають найбільше впевненості на один написаний тест.
  • End-to-end тести перевіряють всю систему, але повільні, нестабільні та дорогі в обслуговуванні. Вам потрібно кілька для критичних шляхів, але не сотні.

Я дотримуюся цього розподілу на практиці: TypeScript ловить більшість моїх помилок типів, я пишу unit-тести для складних утиліт та алгоритмів, інтеграційні тести для фіч і користувацьких потоків, та кілька E2E-тестів для критичного шляху (реєстрація, покупка, основний робочий процес).

Що дає впевненість vs що марнує час#

Тести існують, щоб дати вам впевненість для випуску. Не впевненість, що кожен рядок коду виконується — впевненість, що додаток працює для користувачів. Це різні речі.

Висока впевненість, висока цінність:

  • Інтеграційний тест потоку оформлення замовлення — покриває валідацію форми, API-виклики, оновлення стану та UI успіху/помилки.
  • Unit-тест функції обчислення ціни з крайніми випадками — числа з плаваючою комою, округлення, знижки, нульові/від'ємні значення.
  • Тест, що захищені маршрути перенаправляють неавтентифікованих користувачів.

Низька впевненість, марнування часу:

  • Snapshot-тест статичної маркетингової сторінки — ламається щоразу при зміні тексту, не ловить нічого значущого.
  • Unit-тест, що компонент передає проп дочірньому елементу — тестує сам React, а не ваш код.
  • Тест, що useState викликається — тестує фреймворк, а не поведінку.
  • 100% покриття конфігураційного файлу — це статичні дані, TypeScript вже валідує їх структуру.

Запитання, яке потрібно задати перед написанням тесту: "Якщо цей тест не існував, яка помилка могла б потрапити в продакшен?" Якщо відповідь "ніяка, яку б TypeScript не зловив" або "ніяка, яку б хтось помітив," тест, ймовірно, не варто писати.

Тестування як зворотний зв'язок щодо дизайну#

Код, який важко тестувати, зазвичай погано спроєктований. Якщо вам потрібно мокувати п'ять речей, щоб протестувати одну функцію, ця функція має занадто багато залежностей. Якщо ви не можете відрендерити компонент без налаштування складних провайдерів контексту, компонент занадто прив'язаний до свого оточення.

Тести — це користувач вашого коду. Якщо ваші тести мають труднощі з використанням вашого API, інші розробники також матимуть. Коли ви боретеся з налаштуванням тесту, сприймайте це як сигнал для рефакторингу коду, що тестується, а не для додавання більше моків.

typescript
// Hard to test — function does too much
async function processOrder(orderId: string) {
  const order = await db.orders.findById(orderId);
  const user = await db.users.findById(order.userId);
  const inventory = await checkInventory(order.items);
  if (!inventory.available) {
    await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
    return { success: false, reason: "out-of-stock" };
  }
  const payment = await chargeCard(user.paymentMethod, order.total);
  if (!payment.success) {
    await sendEmail(user.email, "payment-failed", { error: payment.error });
    return { success: false, reason: "payment-failed" };
  }
  await db.orders.update(orderId, { status: "confirmed" });
  await sendEmail(user.email, "order-confirmed", { orderId });
  return { success: true };
}
 
// Easier to test — separated concerns
function determineOrderAction(
  inventory: InventoryResult,
  payment: PaymentResult
): OrderAction {
  if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
  if (!payment.success) return { type: "payment-failed", error: payment.error };
  return { type: "confirmed" };
}

Друга версія — чиста функція. Ви можете протестувати кожну комбінацію результатів інвентаризації та оплати без мокування бази даних, платіжного провайдера чи поштового сервісу. Логіка оркестрації (отримання даних, відправка листів) живе в тонкому шарі, що тестується на рівні інтеграції.

Ось справжня цінність тестування: не виловлювання помилок після їх написання, а запобігання поганим дизайнам до їх коміту. Дисципліна написання тестів штовхає вас до менших функцій, чистіших інтерфейсів та більш модульної архітектури. Тести — це побічний ефект. Покращення дизайну — головна подія.

Пишіть тести, яким довіряєте#

Найгірше, що може статися з тестовим набором, — це не те, що в ньому є прогалини. Це те, що люди перестають йому довіряти. Тестовий набір з кількома нестабільними тестами, що випадково падають на CI, навчає команду ігнорувати червоні білди. Коли це стається, тестовий набір стає гіршим за марний — він активно створює хибну безпеку.

Якщо тест падає періодично, виправте його або видаліть. Якщо тест повільний, прискоріть його або перемістіть в окремий набір повільних тестів. Якщо тест ламається при кожній незв'язаній зміні, перепишіть його, щоб тестувати поведінку замість реалізації.

Мета — тестовий набір, де кожне падіння означає, що щось реальне зламано. Коли розробники довіряють тестам, вони запускають їх перед кожним комітом. Коли не довіряють — обходять їх з --no-verify і деплоять із схрещеними пальцями.

Створіть тестовий набір, на який ви б поставили свої вихідні. Нічого менше не варто підтримувати.

Схожі записи