Навигация

Основы тестирования: пирамида, принципы, стратегии

Каждое изменение кода несёт риск сломать уже работающий функционал, особенно в команде и на долгосрочном проекте. Автотесты снижают этот риск, но только если их правильно выбирать, писать и распределять по уровням. В этой статье разберём пирамиду и Testing Trophy, уровни unit/интеграционных/E2E тестов, принципы FIRST, паттерн AAA, TDD, code coverage и антипаттерны, которые стоит избегать.

Содержание

  1. Что такое тестирование и зачем оно нужно
  2. Пирамида тестирования
  3. Testing Trophy — альтернативная модель
  4. FIRST-принципы
  5. Паттерн AAA (Arrange-Act-Assert)
  6. TDD: Red-Green-Refactor
  7. Что тестировать, что не тестировать
  8. Покрытие кода (Code Coverage)
  9. Классификация тестов по назначению
  10. Стоимость тестов
  11. Лучшие практики
  12. Антипаттерны
  13. Ключевые тезисы для интервью
  14. Заключение
  15. Полезные ссылки

Что такое тестирование и зачем оно нужно

Тестирование — это процесс проверки того, что код работает так, как ожидается. Автоматизированные тесты — это код, который проверяет другой код.

Зачем тестировать

Проблема без тестовКак решают тесты
Рефакторинг ломает существующий функционалТесты ловят регрессии до деплоя
Непонятно, что должна делать функцияТесты — живая документация ожидаемого поведения
Страх менять код («а вдруг сломается»)Тесты дают уверенность при изменениях
Ручная проверка каждого релизаАвтотесты выполняются за секунды в CI
Баги находят пользователиБаги обнаруживаются на этапе разработки

Когда тесты окупаются

Тесты окупаются не всегда одинаково. Вот факторы, влияющие на ROI:

Высокий ROI — тестировать обязательно:

  • Критичная бизнес-логика (платежи, авторизация, права доступа)
  • Код, который часто меняется
  • Код, от которого зависят другие модули (утилиты, API-клиенты)
  • Командная разработка (несколько человек работают с одним кодом)
  • Долгосрочные проекты

Низкий ROI — тестировать опционально:

  • Прототипы и одноразовые скрипты
  • UI-компоненты с чисто визуальной логикой (лучше — визуальное тестирование)
  • Код, который скоро будет удалён

💡 Правило: Если вы боитесь менять код — ему нужны тесты. Если вам всё равно — тесты не нужны.


Пирамида тестирования

Пирамида тестирования — модель, предложенная Майком Коном в 2009 году. Она описывает оптимальное соотношение тестов разных уровней.

Почему пирамида, а не всё наоборот

Представьте, что у вас есть 100 тестов. Вы можете написать их как:

  • 100 E2E-тестов — покрывают всё, но выполняются часами и ломаются при каждом изменении UI
  • 100 unit-тестов — выполняются за секунды, но не ловят ошибки интеграции
  • 70 unit + 20 интеграционных + 10 E2E — баланс между скоростью, стабильностью и уверенностью

Пирамида — это не догма, а экономическая модель. Каждый уровень тестов имеет свою стоимость написания, поддержки и выполнения. Пирамида минимизирует общую стоимость при максимальной уверенности.

            ╱╲
           ╱  ╲
          ╱ E2E╲              ← 10% — мало, дорого, медленно
         ╱──────╲
        ╱        ╲
       ╱Интеграц. ╲           ← 20% — средняя цена
      ╱────────────╲
     ╱              ╲
    ╱   Unit-тесты   ╲        ← 70% — много, дёшево, быстро
   ╱──────────────────╲

Эволюция пирамиды

Пирамида Кона была предложена в 2009 году для серверных приложений. С тех пор мир изменился:

2009 (Mike Cohn):

  • Серверные приложения с тонким клиентом
  • Unit-тесты — основа всего
  • Интеграционные тесты — редкость
  • E2E — только для критичных сценариев

2024 (React/Next.js):

  • Сложные клиентские приложения с бизнес-логикой
  • Компоненты — не просто UI, а мини-приложения
  • Интеграционные тесты компонентов дают больше уверенности, чем unit-тесты
  • E2E — для критичных пользовательских сценариев

Это привело к появлению Testing Trophy (см. ниже), которая лучше отражает реалии современных фронтенд-приложений.

Unit-тесты (модульные)

Что тестируют: Отдельные функции, утилиты, хуки — изолированно от остальных модулей.

Характеристики:

  • Самые быстрые (тысячи тестов за секунды)
  • Самые стабильные (не зависят от DOM, сети, браузера)
  • Самые дешёвые в написании и поддержке

Философия unit-тестов: Unit-тест проверяет, что одна единица кода работает правильно в изоляции. “Единица” — это функция, класс или модуль. Ключевое слово — изоляция: тест не должен зависеть от внешних зависимостей (API, БД, файловая система, другие модули).

Почему изоляция важна:

  • Скорость — моки выполняются мгновенно, реальные API — секунды
  • Стабильность — тест не упадёт из-за недоступности сервиса
  • Локализация — если тест упал, вы точно знаете, какой модуль сломался
  • Повторяемость — тест даёт одинаковый результат при любом запуске

Что подходит для unit-тестов:

  • Утилиты (форматирование, валидация, трансформация данных)
  • Чистые функции (без побочных эффектов)
  • Бизнес-логика (расчёты, правила, условия)
  • Хуки (в изоляции, с моками зависимостей)

Когда unit-тесты НЕ подходят:

  • Компоненты с сложной бизнес-логикой — лучше интеграционные тесты
  • Код, который сильно зависит от внешних зависимостей — мокирование сделает тест хрупким
  • UI-логика — пользователь не видит ваши функции, он видит поведение компонента

Пример:

import { calculateDiscount } from "./pricing";

describe("calculateDiscount", () => {
  it("returns 0 for non-premium users", () => {
    expect(calculateDiscount(100, false)).toBe(0);
  });

  it("returns 10% for premium users", () => {
    expect(calculateDiscount(100, true)).toBe(10);
  });

  it("returns 0 for negative prices", () => {
    expect(calculateDiscount(-50, true)).toBe(0);
  });

  it("handles zero price", () => {
    expect(calculateDiscount(0, true)).toBe(0);
  });
});

Интеграционные тесты

Что тестируют: Взаимодействие между модулями — несколько функций, компонент + хук, хук + API.

Характеристики:

  • Средние по скорости
  • Проверяют, что модули работают вместе корректно
  • Ловят ошибки контрактов между модулями

Философия интеграционных тестов: Интеграционный тест проверяет, что несколько модулей работают вместе правильно. В React это обычно означает тестирование компонента с его реальными зависимостями — хуками, контекстом, API-клиентами.

Почему интеграционные тесты критичны для React:

React-компоненты — это не просто функции, которые возвращают HTML. Это мини-приложения со своей логикой:

  • Состояние (useState, useReducer)
  • Побочные эффекты (useEffect)
  • Контекст (useContext)
  • API-запросы (через хуки или библиотеки)

Unit-тест компонента с моками всех зависимостей проверяет моки, а не реальное поведение. Интеграционный тест рендерит компонент с реальными провайдерами и проверяет, что видит пользователь.

Пример разницы:

// ❌ Unit-тест с моками — проверяет моки, не поведение
it("calls fetchUser and sets state", () => {
  const mockFetch = vi.fn().mockResolvedValue({ name: "Alice" });
  const mockSetState = vi.fn();
  
  // Тестируем внутреннюю реализацию
  expect(mockFetch).toHaveBeenCalledWith("/api/user/1");
  expect(mockSetState).toHaveBeenCalledWith({ name: "Alice" });
});

// ✅ Интеграционный тест — проверяет поведение пользователя
it("displays user name after loading", async () => {
  server.use(http.get("/api/user/1", () => HttpResponse.json({ name: "Alice" })));
  
  render(<UserProfile userId={1} />);
  
  // Проверяем то, что видит пользователь
  expect(await screen.findByText("Alice")).toBeInTheDocument();
});

Что подходит для интеграционных тестов:

  • Компонент + контекст / провайдер
  • Хук + API-запрос
  • Форма + валидация + отправка
  • Компонент + роутинг

Когда интеграционные тесты НЕ подходят:

  • Простые утилиты — unit-тесты быстрее и проще
  • Критичные бизнес-сценарии (регистрация → покупка) — E2E дают больше уверенности
  • Визуальное тестирование — используйте visual regression testing

Пример:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchPage } from "./SearchPage";

describe("SearchPage", () => {
  it("fetches and displays results on submit", async () => {
    render(<SearchPage />);

    const user = userEvent.setup();
    await user.type(screen.getByRole("textbox", { name: /search/i }), "react");
    await user.click(screen.getByRole("button", { name: /search/i }));

    expect(await screen.findByText("React Documentation")).toBeInTheDocument();
    expect(screen.queryByText("No results")).not.toBeInTheDocument();
  });

  it("shows error message on API failure", async () => {
    // Переопределяем mock API для конкретного теста
    server.use(
      http.get("/api/search", () => HttpResponse.json(
        { error: "Server error" },
        { status: 500 }
      ))
    );

    render(<SearchPage />);

    const user = userEvent.setup();
    await user.type(screen.getByRole("textbox", { name: /search/i }), "react");
    await user.click(screen.getByRole("button", { name: /search/i }));

    expect(await screen.findByText(/server error/i)).toBeInTheDocument();
  });
});

E2E-тесты (сквозные)

Что тестируют: Полные пользовательские сценарии в реальном браузере — от клика до результата.

Характеристики:

  • Самые медленные (минуты на выполнение)
  • Самые хрупкие (зависят от сети, UI, данных)
  • Самые дорогие в написании и поддержке
  • Дают наибольшую уверенность

Пример:

import { test, expect } from "@playwright/test";

test("user can complete checkout flow", async ({ page }) => {
  await page.goto("/products");
  await page.getByRole("link", { name: "React Handbook" }).click();
  await page.getByRole("button", { name: "Add to Cart" }).click();

  await page.goto("/cart");
  await expect(page.getByText("React Handbook")).toBeVisible();
  await expect(page.getByText("$49.99")).toBeVisible();

  await page.getByRole("button", { name: "Checkout" }).click();
  await page.fill('[name="email"]', "user@example.com");
  await page.fill('[name="card"]', "4242424242424242");
  await page.getByRole("button", { name: "Pay" }).click();

  await expect(page.getByText("Order confirmed")).toBeVisible();
});

Что подходит для E2E-тестов:

  • Критические бизнес-сценарии (регистрация → покупка → оплата)
  • Авторизация и защищённые маршруты
  • Мультистраничные сценарии
  • Smoke-тесты после деплоя

Соотношение тестов

ХарактеристикаUnitИнтеграционныеE2E
Количество~70%~20%~10%
СкоростьМиллисекундыСотни мсСекунды-минуты
СтабильностьВысокаяСредняяНизкая
Стоимость написанияНизкаяСредняяВысокая
Стоимость поддержкиНизкаяСредняяВысокая
УверенностьЛокальнаяСредняяМаксимальная
Обратная связьМгновеннаяБыстраяМедленная
ЗависимостиМокиРеальные модулиРеальный браузер + API

Почему не наоборот

Распространённая ошибка — «перевёрнутый торт» (ice cream cone):

         ╱──────────╲
        ╱   E2E-тесты ╲       ← ❌ Слишком много E2E
       ╱────────────────╲
      ╱                  ╲
     ╱  Мало unit-тестов   ╲    ← ❌ Мало unit
    ╱────────────────────────╲

Почему это плохо:

  • E2E-тесты медленные — CI выполняется часами
  • E2E-тесты хрупкие — ломаются при любом изменении UI
  • E2E-тесты дорогие — написание и поддержка требуют много ресурсов
  • E2E-тесты плохо локализуют баги — падение может быть в любом месте стека

Testing Trophy — альтернативная модель

Kent C. Dodds (автор React Testing Library) предложил альтернативную модель — Testing Trophy — в 2019 году как ответ на устаревание пирамиды для современных фронтенд-приложений.

        ╱────────────╲
       ╱  E2E-тесты   ╲        ← Критические сценарии
      ╱────────────────╲
     ╱                  ╲
    ╱  Интеграционные    ╲     ← Основная масса тестов!
   ╱                      ╲
  ╱──── Unit-тесты ─────────╲  ← Утилиты и сложная логика
  ╱──── Статические ─────────╲  ← TypeScript, ESLint
 ╱────────────────────────────╲

Почему Testing Trophy лучше для React

Исторический контекст: Пирамида Кона (2009) была создана для серверных приложений, где:

  • Бизнес-логика жила в функциях и классах
  • UI был тонким клиентом
  • Unit-тесты покрывали 70%+ кода

Современные реалии (2024): В React/Next.js приложениях:

  • Бизнес-логика живёт в компонентах и хуках
  • Компоненты — это не просто UI, а мини-приложения
  • Unit-тесты компонентов с моками часто проверяют моки, а не поведение

Ключевое отличие: Testing Trophy утверждает, что интеграционные тесты компонентов дают больше уверенности, чем unit-тесты, потому что:

  1. Тестируется реальное поведение, а не моки
  2. Ловятся ошибки интеграции между модулями
  3. Тесты не ломаются при рефакторинге внутренней реализации

Пример:

// ❌ Unit-тест с моками — хрупкий, проверяет реализацию
it("calls useEffect and fetches data", () => {
  const useEffect = vi.spyOn(React, "useEffect");
  const mockFetch = vi.fn().mockResolvedValue({ data: [] });
  
  render(<UserList />);
  
  expect(useEffect).toHaveBeenCalled();
  expect(mockFetch).toHaveBeenCalledWith("/api/users");
});

// ✅ Интеграционный тест — устойчивый, проверяет поведение
it("displays user list after loading", async () => {
  server.use(http.get("/api/users", () => HttpResponse.json([
    { id: 1, name: "Alice" }
  ])));
  
  render(<UserList />);
  
  expect(await screen.findByText("Alice")).toBeInTheDocument();
});

Второй тест:

  • Не зависит от реализации (можно переписать на другой хук)
  • Проверяет то, что видит пользователь
  • Ловит ошибки интеграции (компонент + API + контекст)

Когда использовать пирамиду, когда Testing Trophy

СитуацияМодельПочему
Серверное приложение (Node.js, Python)ПирамидаБизнес-логика в функциях, unit-тесты эффективны
React/Next.js приложениеTesting TrophyКомпоненты — мини-приложения, интеграционные тесты дают больше уверенности
МикросервисыПирамидаМного модулей, важна изоляция
SPA с сложной бизнес-логикойTesting TrophyБизнес-логика в компонентах, нужна интеграция
Библиотека утилитПирамидаЧистые функции, unit-тесты идеальны

Практическое соотношение для React-проекта

Статические (TypeScript + ESLint)  ──── автоматическая защита
Unit-тесты (утилиты, хуки)         ──── ~20%
Интеграционные (компоненты)        ──── ~60%
E2E (критические сценарии)         ──── ~20%

Важно: Это не догма. Если у вас сложная бизнес-логика в утилитах — увеличьте долю unit-тестов. Если у вас критичные многошаговые сценарии — увеличьте долю E2E.

Ключевое отличие от пирамиды

Пирамида (Mike Cohn)Testing Trophy (Kent C. Dodds)
Основной уровеньUnit-тестыИнтеграционные тесты
ФилософияТестировать изолированноТестировать взаимодействие
АргументUnit-тесты быстрые и стабильныеИнтеграционные тесты дают больше уверенности
Компонентные тестыОтносятся к unitВыделены в отдельный уровень

Почему Kent C. Dodds прав для React

React-компоненты — это не чистые функции. Они зависят от:

  • DOM (рендеринг)
  • Контекста (тема, аутентификация)
  • Хуков (состояние, эффекты)
  • API (данные с сервера)

Тестирование компонента в изоляции (unit) часто проверяет моки, а не реальное поведение. Интеграционный тест — рендерит компонент с реальными провайдерами и проверяет поведение пользователя.

💡 Вывод: Для React-приложений интеграционные тесты компонентов дают больше уверенности, чем unit-тесты. Unit-тесты остаются полезны для утилит и чистой бизнес-логики.


FIRST-принципы

FIRST — акроним, описывающий свойства хорошего теста. Эти принципы были сформулированы в 2004 году Тимом Оттингером и стали стандартом для написания тестируемого кода.

Почему FIRST важен: Тесты — это тоже код. Как и любой код, они имеют стоимость владения. Плохо написанные тесты:

  • Замедляют разработку (медленно выполняются)
  • Демотивируют команду (flaky-тесты, ложные срабатывания)
  • Усложняют поддержку (зависимости между тестами)
  • Дают ложную уверенность (тесты проходят, но баги есть)

FIRST-принципы помогают писать тесты, которые приносят пользу, а не создают проблемы.

F — Fast (Быстрый)

Тесты должны выполняться быстро. Медленные тесты — демотивируют разработчиков и замедляют CI.

Экономическое обоснование:

  • Разработчик запускает тесты 50-100 раз в день
  • Если тесты выполняются 10 секунд → 8-15 минут в день потеряно
  • Если тесты выполняются 1 секунду → меньше минуты в день
  • За месяц разница: 3-7 часов vs 20-30 минут

Как обеспечить:

  • Мокировать внешние зависимости (API, БД, файловая система)
  • Параллельный запуск тестов
  • Не создавать реальные файлы/БД для каждого теста
  • Использовать инкрементальные тесты (только изменённые модули)

Компромисс: Мокирование ускоряет тесты, но делает их менее реалистичными. Баланс:

  • Unit-тесты — мокируем всё (скорость важнее)
  • Интеграционные тесты — мокируем только внешние зависимости (реализм важнее)
  • E2E-тесты — реальные зависимости (уверенность важнее скорости)

I — Isolated (Изолированный)

Тесты не должны зависеть друг от друга. Каждый тест выполняется в независимом окружении.

// ❌ Тесты зависят от порядка выполнения
let counter = 0;

it("increments counter", () => {
  counter++;
  expect(counter).toBe(1);
});

it("counter is 2 after two increments", () => {
  counter++;
  expect(counter).toBe(2); // ❌ Падает, если тесты запускаются в другом порядке
});

// ✅ Каждый тест независим
it("increments counter", () => {
  const store = createCounterStore();
  store.increment();
  expect(store.count).toBe(1);
});

it("starts from zero", () => {
  const store = createCounterStore();
  expect(store.count).toBe(0);
});

Как обеспечить:

  • Каждый тест создаёт своё состояние
  • beforeEach / afterEach для очистки
  • vi.clearAllMocks() между тестами
  • Не использовать глобальное изменяемое состояние

R — Repeatable (Повторяемый)

Тест должен давать одинаковый результат при любом запуске — локально, в CI, на другом компьютере.

❌ Тест, который проходит локально, но падает в CI — flaky-тест
❌ Тест, который зависит от текущего времени — не повторяемый
✅ Тест с фиксированными данными и моками — повторяемый

Источники flaky-тестов:

  • Зависимость от реального времени (используйте vi.useFakeTimers())
  • Зависимость от сети (используйте MSW)
  • Зависимость от порядка тестов
  • Гонки состояний (race conditions)
  • Зависимость от состояния файловой системы

S — Self-Validating (Самопроверяемый)

Тест должен сам определять, прошёл он или упал. Никакой ручной проверки.

❌ Тест выводит данные в консоль, и разработчик смотрит — правильно ли
✅ Тест содержит expect() и автоматически проходит/падает
// ❌ Нет assertions — тест всегда проходит
it("fetches user", async () => {
  const user = await fetchUser(1);
  console.log(user); // Разработчик смотрит глазами
});

// ✅ Самопроверяемый
it("fetches user", async () => {
  const user = await fetchUser(1);
  expect(user).toEqual({ id: 1, name: "Alice" });
});

T — Timely (Своевременный)

Тесты должны писаться вовремя — в идеале до кода (TDD) или одновременно с ним.

❌ «Напишем тесты потом» → тесты не пишутся никогда
❌ Тесты пишутся через месяц после кода → тесты не покрывают edge cases
✅ Тесты пишутся вместе с кодом → покрытие растёт органично

Паттерн AAA (Arrange-Act-Assert)

AAA — структура организации теста. Каждый тест состоит из трёх фаз:

it("adds item to cart", () => {
  // Arrange — подготовка данных и окружения
  const cart = createCart();
  const item = createItem({ id: 1, name: "Book", price: 29.99 });

  // Act — выполнение тестируемого действия
  addToCart(cart, item);

  // Assert — проверка результата
  expect(cart.items).toHaveLength(1);
  expect(cart.items[0].name).toBe("Book");
  expect(cart.total).toBe(29.99);
});

Зачем разделять на AAA

  • Читаемость — сразу видно, что подготавливается, что выполняется, что проверяется
  • Отладка — если тест падает, понятно, на какой фазе проблема
  • Поддержка — легко добавить новый тест по образцу

Расширенный паттерн AAAA

Для тестов с очисткой добавляют четвёртую фазу:

it("subscribes to notifications", () => {
  // Arrange
  const user = createUser();
  const service = createNotificationService();

  // Act
  service.subscribe(user);

  // Assert
  expect(service.subscribers).toContain(user);

  // Cleanup (Arange-Act-Assert-Cleanup)
  service.unsubscribe(user);
});

💡 В Vitest/Jest очистку лучше делать в afterEach — это надёжнее, чем ручная очистка в каждом тесте.

Пример для React-компонента

it("disables submit button while saving", async () => {
  // Arrange
  const user = userEvent.setup();
  render(<ProfileForm />);

  const nameInput = screen.getByRole("textbox", { name: /name/i });
  const submitBtn = screen.getByRole("button", { name: /save/i });

  // Act
  await user.type(nameInput, "Alice");
  await user.click(submitBtn);

  // Assert — кнопка заблокирована во время сохранения
  expect(submitBtn).toBeDisabled();
  expect(screen.getByText(/saving/i)).toBeInTheDocument();

  // Assert — после завершения кнопка разблокирована
  expect(await screen.findByText(/saved/i)).toBeInTheDocument();
  expect(submitBtn).not.toBeDisabled();
});

TDD: Red-Green-Refactor

TDD (Test-Driven Development) — методология разработки, при которой тесты пишутся до кода. TDD был популяризирован Кентом Беком в 2003 году как часть экстремального программирования (XP).

Философия TDD

TDD — это не про тесты. TDD — про дизайн кода. Когда вы пишете тест до кода, вы:

  1. Думаете о контракте — что функция должна делать, а не как она это делает
  2. Пишете тестируемый код — код, который можно тестировать, обычно лучше спроектирован
  3. Получаете обратную связь мгновенно — тест падает → вы знаете, что нужно реализовать
  4. Документируете поведение — тесты становятся живой документацией

TDD vs “тесты после кода”:

ПодходПреимуществаНедостатки
TDD (тесты до кода)Код получается тестируемым, 100% покрытие новой функциональности, меньше баговЗамедляет начало, требует дисциплины
Тесты после кодаБыстрее начало, легче написать тесты для простого кодаТесты часто проверяют реализацию, а не поведение, покрытие неполное

Когда TDD окупается:

  • Сложная бизнес-логика (платежи, авторизация, права доступа)
  • Утилиты с чёткими правилами (форматирование, валидация)
  • Код, который часто меняется (тесты — страховка при рефакторинге)
  • Исправление багов (сначала тест, воспроизводящий баг, потом фикс)

Когда TDD избыточен:

  • Прототипы и одноразовые скрипты
  • UI-компоненты с неясным дизайном (дизайн может измениться)
  • Простой CRUD (создать, прочитать, обновить, удалить)
  • Исследовательский код (вы ещё не знаете, как это будет работать)

Цикл TDD

  ┌──────────────────────────────────────────┐
  │                                          │
  ▼                                          │
┌──────┐    ┌───────┐    ┌──────────┐       │
│ RED  │───►│ GREEN │───►│ REFACTOR │───────┘
│      │    │       │    │          │
└──────┘    └───────┘    └──────────┘

Red — написать падающий тест

Пишем тест, описывающий желаемое поведение. Тест падает, потому что код ещё не написан.

// Тест падает — функция formatPrice ещё не существует
it("formats price with currency", () => {
  expect(formatPrice(1234.5, "USD")).toBe("$1,234.50");
});

Green — написать минимальный код

Пишем минимальный код, чтобы тест прошёл. Не оптимальный, не красивый — просто рабочий.

function formatPrice(price: number, currency: string): string {
  return "$1,234.50"; // Хардкод — лишь бы тест прошёл
}

Refactor — улучшить код

Улучшаем код, сохраняя passing-тесты. Тесты — страховка при рефакторинге.

function formatPrice(price: number, currency: string): string {
  const symbols: Record<string, string> = { USD: "$", EUR: "€", RUB: "₽" };
  const symbol = symbols[currency] || currency;
  return `${symbol}${price.toLocaleString("en-US", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  })}`;
}

Когда использовать TDD

СитуацияTDD?
Новая утилита с чёткими правилами✅ Да
Сложная бизнес-логика✅ Да
Прототип / исследование❌ Нет
UI-компонент с неясным дизайном❌ Нет
Рефакторинг существующего кода⚠️ Сначала тесты на текущее поведение, потом рефакторинг
Исправление бага✅ Да (сначала тест, воспроизводящий баг, потом фикс)

Преимущества TDD

  • Тесты покрывают 100% новой функциональности
  • Код получается более тестируемым (вы пишете код, который можно тестировать)
  • Меньше багов — каждый edge case продумывается до реализации
  • Документация — тесты описывают контракт функции

Недостатки TDD

  • Замедляет начало разработки (но ускоряет в целом)
  • Требует дисциплины и практики
  • Не подходит для exploratory coding
  • Избыточен для простых CRUD-операций

Что тестировать, что не тестировать

Что тестировать

КатегорияПримерыУровень
Бизнес-логикаРасчёт скидки, валидация формы, правила доступаUnit
УтилитыФорматирование даты, парсинг, трансформация данныхUnit
ХукиuseAuth, useCart, useDebounceUnit / Интеграционный
КомпонентыРендеринг, взаимодействие, формы, ошибкиИнтеграционный
Критические сценарииРегистрация, оплата, авторизацияE2E
API-клиентыОбработка ответов, retry-логика, ошибкиИнтеграционный

Что НЕ тестировать

КатегорияПочему
ФреймворкReact, Next.js, Zustand — они уже протестированы
Третестные библиотекиНе тестируйте lodash, axios, zod
Тривиальный кодconst x = () => true — не нуждается в тесте
Приватные методыТестируйте публичный API, не внутреннюю реализацию
Конструкторы / конфигурациюnew Date(), { theme: "dark" }

Правило: тестируйте поведение, не реализацию

// ❌ Тестирует реализацию — хрупкий тест
it("calls setState and re-renders", () => {
  const setState = vi.spyOn(React, "useState");
  render(<Counter />);
  expect(setState).toHaveBeenCalled();
});

// ✅ Тестирует поведение — устойчивый тест
it("increments count on button click", async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole("button", { name: /increment/i }));
  expect(screen.getByText("Count: 1")).toBeInTheDocument();
});

Покрытие кода (Code Coverage)

Code Coverage — метрика, показывающая, какая доля исходного кода выполняется во время тестов. Это инструмент, а не цель.

Философия покрытия

Покрытие — это метрика здоровья, а не KPI:

  • 80% покрытие говорит: “большая часть кода тестируется”
  • 100% покрытие говорит: “весь код выполняется в тестах” (но не говорит, что тесты хорошие)
  • 50% покрытие говорит: “половина кода не тестируется” (риск багов)

Почему 100% покрытие — не цель:

  1. Покрытие не гарантирует отсутствие багов. Тест может выполняться, но не проверять ничего полезного:

    it("does something", () => {
      calculateDiscount(100, true); // Код выполнился, но ничего не проверяется
    });
  2. 100% покрытие требует тестирования тривиального кода:

    // Этот код не нуждается в тесте
    const getName = (user: User) => user.name;
  3. Погоня за 100% приводит к хрупким тестам:

    // ❌ Тест ради покрытия, а не ради уверенности
    it("calls console.log", () => {
      const spy = vi.spyOn(console, "log");
      doSomething();
      expect(spy).toHaveBeenCalled(); // И что это даёт?
    });

Как использовать покрытие правильно:

  1. Установите разумный порог (80% statements, 70% branches)
  2. Используйте покрытие для выявления непокрытых модулей, а не для гонки за процентами
  3. Пишите тесты для критичных модулей, даже если они уже покрыты
  4. Игнорируйте покрытие для тривиального кода (конструкторы, конфигурация)

Виды покрытия

МетрикаЧто измеряетПочему важна
Statements% исполненных выраженийБазовая метрика — код выполняется
Branches% исполненных веток (if/else, switch/case)Ветвление — частый источник багов
Functions% вызванных функцийКаждая функция должна вызываться в тестах
Lines% исполненных строкБлизко к statements, но менее точная

Пример разницы между statements и branches:

function getDiscount(isPremium: boolean): number {
  if (isPremium) {
    return 10;
  }
  return 0;
}

// Тест 1: вызов с true
getDiscount(true);
// Statements: 100% (все выражения выполнены)
// Branches: 50% (только if-ветка выполнена, else — нет)

// Тест 2: вызов с false
getDiscount(false);
// Statements: 100%
// Branches: 100% (обе ветки выполнены)

Вывод: Branches — более строгая метрика. Если вы тестируете только “happy path”, branches покажет непокрытые ветки.

Целевые значения

МетрикаЦельКомментарий
Statements> 80%Здоровый минимум для production-кода
Branches> 70%Ветвление — частый источник багов
Functions> 85%Каждая функция должна вызываться в тестах
Lines> 80%Близко к statements

⚠️ Не стремитесь к 100%. Покрытие — это метрика, а не цель. 100% покрытие не гарантирует отсутствие багов. Лучше 80% осмысленного покрытия, чем 100% покрытия ради галочки.

Как использовать покрытие

1. Настроить coverage в vitest.config.ts
2. Запускать тесты с флагом --coverage
3. Анализировать отчёт
4. Писать тесты для непокрытых критичных модулей

Покрытие как порог в CI

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: "v8",
      thresholds: {
        statements: 80,
        branches: 70,
        functions: 85,
        lines: 80,
      },
    },
  },
});

Если покрытие падает ниже порога — CI падает. Это гарантирует, что качество тестов не деградирует.


Классификация тестов по назначению

Smoke-тесты

Быстрая проверка того, что основные функции работают. Выполняются после каждого деплоя.

test("homepage loads", async ({ page }) => {
  await page.goto("/");
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});

test("login page loads", async ({ page }) => {
  await page.goto("/login");
  await expect(page.getByRole("textbox", { name: /email/i })).toBeVisible();
});

Regression-тесты

Тесты, написанные для конкретных исправленных багов. Гарантируют, что баг не вернётся.

// Баг: при пустой корзине итоговая сумма показывала NaN
test("shows zero total for empty cart", () => {
  const cart = createEmptyCart();
  expect(getTotal(cart)).toBe(0); // Не NaN
});

Sanity-тесты

Узкие тесты, проверяющие конкретную функцию после изменений. Быстрее полного прогона.

Acceptance-тесты

Проверяют, что функциональность соответствует требованиям заказчика. Обычно — E2E.


Стоимость тестов

Матрица стоимости

НаписаниеВыполнениеПоддержкаЛокализация бага
UnitДёшевоДёшевоДёшевоТочная
ИнтеграционныеСреднеСреднеСреднеХорошая
E2EДорогоДорогоДорогоПриблизительная

Закон стоимости

Стоимость исправления бага растёт экспоненциально с каждой фазой, на которой он обнаружен:

Написан    → Исправить за 1 минуту
Unit-тест  → Исправить за 5 минут
CI         → Исправить за 30 минут
QA         → Исправить за 2 часа
Production → Исправить за 1 день + репутационные потери

Лучшие практики

1. Тестируйте поведение, а не реализацию

Тест описывает что делает код, а не как он это делает. Если при рефакторинге внутренней реализации тест падает, значит, он проверяет не поведение, а детали.

// ❌ Зависит от реализации
it("calls useEffect and fetches data", () => {
  const useEffect = vi.spyOn(React, "useEffect");
  render(<UserList />);
  expect(useEffect).toHaveBeenCalled();
});

// ✅ Зависит от поведения
it("displays user list after loading", async () => {
  render(<UserList />);
  expect(await screen.findByText("Alice")).toBeInTheDocument();
  expect(await screen.findByText("Bob")).toBeInTheDocument();
});

2. Один тест — одна концепция

Каждый it проверяет одно конкретное поведение. Смешивание сценариев в одном тесте усложняет отладку: если тест упадёт, непонятно, какая именно проверка сломалась.

// ❌ Один тест проверяет слишком много
it("handles login", async () => {
  // Логин, переход в профиль, изменение настроек, выход
});

// ✅ Разбито на отдельные тесты
it("shows error for invalid credentials", async () => { ... });
it("redirects to dashboard after login", async () => { ... });
it("displays user name in header", async () => { ... });

3. Используйте factory-функции для тестовых данных

Не хардкодите данные в каждом тесте. Factory-функции централизуют создание объектов, делают тесты читаемыми и защищают от массовых правок при изменении типа.

const createUser = (overrides?: Partial<User>): User => ({
  id: crypto.randomUUID(),
  name: "Test User",
  email: "test@example.com",
  role: "user",
  ...overrides,
});

// Использование — видны только значимые поля
const admin = createUser({ role: "admin" });
const inactive = createUser({ isActive: false });

4. Называйте тесты по шаблону «должен … когда …»

Имя теста — это документация. Оно должно отвечать на вопрос: что должно произойти и при каких условиях?

it("returns discounted price when user is premium", () => { ... });
it("throws error when price is negative", () => { ... });
it("shows loading spinner when data is fetching", () => { ... });
it("disables button when form is invalid", () => { ... });

5. Группируйте тесты по describe

Вложенные describe помогают структурировать тесты по условиям: «для премиум-пользователей», «для пустого массива», «при сетевой ошибке».

describe("calculateDiscount", () => {
  describe("for premium users", () => {
    it("returns 10% discount", () => { ... });
    it("applies maximum discount cap", () => { ... });
  });

  describe("for regular users", () => {
    it("returns no discount", () => { ... });
  });

  describe("edge cases", () => {
    it("handles zero price", () => { ... });
    it("handles negative price", () => { ... });
    it("handles NaN input", () => { ... });
  });
});

6. Приоритет селекторов: роли, label, data-testid

Устойчивые селекторы привязаны к доступности, а не к структуре DOM. Они ломаются только при реальных изменениях UI.

Приоритет в React Testing Library и Playwright:

1. getByRole           ← лучше всего: доступность + стабильность
2. getByLabelText
3. getByPlaceholderText
4. getByText
5. getByDisplayValue
6. getByTestId          ← последний резерв
// ✅ Хорошо
await page.getByRole("button", { name: /submit/i }).click();
await page.getByLabel("Email").fill("user@example.com");

// ⚠️ Допустимо, когда нет семантики
await page.getByTestId("checkout-button").click();

// ❌ Плохо
await page.click(".btn-primary");
await page.click("#submit");

data-testid — это явный контракт между компонентом и тестом. Используйте его для сложных составных компонентов, иконок без текста, canvas и других случаев, где семантических селекторов нет.

7. Page Object Model для E2E

E2E-тесты быстро становятся длинными и хрупкими. Page Object Model (POM) выносит работу со страницей в отдельный класс: если селектор изменится, правка делается в одном месте, а тесты остаются неизменными.

// e2e/pages/CartPage.ts
export class CartPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto("/cart");
  }

  async getItems() {
    return this.page.getByTestId("cart-item").all();
  }

  async removeItem(name: string) {
    const item = this.page
      .getByTestId("cart-item")
      .filter({ hasText: name });
    await item.getByRole("button", { name: /remove/i }).click();
  }

  async getTotal() {
    return this.page.getByTestId("cart-total").textContent();
  }
}
test("removes item from cart", async ({ page }) => {
  const cart = new CartPage(page);
  await cart.goto();
  await cart.removeItem("React Handbook");

  expect(await cart.getItems()).toHaveLength(0);
  expect(await cart.getTotal()).toBe("$0.00");
});

POM делает тесты читаемыми как бизнес-сценарии и упрощает переиспользование шагов.

8. Мокируйте границы, не внутренности

Мокируйте только внешние зависимости: API, браузерные API, таймеры, сторонние библиотеки. Мокирование внутренних модулей делает тесты хрупкими и проверяет моки, а не реальное поведение.

// ✅ Мокируем API на границе
vi.mock("./api", () => ({
  fetchUser: vi.fn(),
}));

// ❌ Не мокируйте внутренние хуки и компоненты
vi.mock("./useUser", () => ({
  useUser: vi.fn(),
}));

Если приходится мокировать много внутренних модулей — это сигнал, что компонент слишком большой или слишком связанный.

9. Избегайте shared state

Shared mutable state между тестами — главный источник flakiness. Каждый тест должен создавать своё состояние.

// ❌ Плохо: shared mutable state
let counter = 0;

it("increments", () => {
  counter++;
  expect(counter).toBe(1);
});

it("increments again", () => {
  counter++;
  expect(counter).toBe(2); // упадёт при параллельном запуске
});

// ✅ Хорошо: каждый тест создаёт своё состояние
it("increments", () => {
  const counter = createCounter();
  counter.increment();
  expect(counter.value).toBe(1);
});

Что чистить между тестами:

Что делатьКак
Чистить localStorage/sessionStoragelocalStorage.clear() в beforeEach
Сбрасывать MSW handlersserver.resetHandlers()
Очищать мокиvi.clearAllMocks()
Очищать DOMcleanup() от Testing Library
Останавливать таймерыvi.useRealTimers()

10. Делайте тесты детерминированными

Тест должен давать одинаковый результат в любой среде. Избегайте зависимости от текущего времени, случайных данных, сети и файловой системы.

// ❌ Плохо: зависит от текущего времени
it("formats date", () => {
  expect(formatRelativeDate(new Date())).toBe("just now");
});

// ✅ Хорошо: фиксируем время
it("formats date", () => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date("2026-01-01T12:00:00Z"));

  expect(formatRelativeDate(new Date("2026-01-01T11:00:00Z"))).toBe("1 hour ago");

  vi.useRealTimers();
});

11. Очищайте состояние между тестами

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

12. Используйте beforeEach для повторяющейся настройки

describe("UserProfile", () => {
  let user: User;

  beforeEach(() => {
    user = createUser({ name: "Alice" });
    server.use(
      http.get("/api/user", () => HttpResponse.json(user))
    );
  });

  it("displays user name", async () => {
    render(<UserProfile />);
    expect(await screen.findByText("Alice")).toBeInTheDocument();
  });

  it("shows edit button for own profile", async () => {
    render(<UserProfile isOwnProfile />);
    expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
  });
});

13. Snapshot testing: осторожно

Snapshot-тесты легко создать, но сложно поддерживать. Они фиксируют разметку целиком и ломаются при любом изменении UI, даже intentional.

// ❌ Плохо: огромный snapshot всего компонента
expect(container).toMatchSnapshot();

// ✅ Хорошо: точечная проверка
expect(screen.getByRole("heading")).toHaveTextContent("Welcome");

Используйте snapshot только для стабильных структур данных: JSON-конфиги, ответы API, дизайн-система, где изменения UI осознанные. Всегда ревьюйте изменения snapshot.

14. Ревью тестов

Тесты должны проходить код-ревью наравне с продакшн-кодом. Проверяйте:

  • Тест проверяет поведение, а не реализацию.
  • Имя теста понятно без контекста.
  • Нет shared state.
  • Данные создаются через фабрики.
  • Селекторы устойчивы к рефакторингу.
  • Есть как минимум один негативный сценарий.
  • Тест не содержит waitForTimeout / sleep.

15. Чеклист качественного теста

  • Каждый тест следует структуре AAA.
  • Имя теста описывает поведение и условие.
  • Один тест проверяет одну концепцию.
  • Данные создаются через фабрики.
  • Селекторы используют роли/label, data-testid — как запасной вариант.
  • E2E используют Page Object Model.
  • Тесты изолированы и не зависят от порядка.
  • Мокируются только внешние зависимости.
  • Тесты детерминированы: нет случайности, времени, сети.
  • Тесты проходят код-ревью.

Антипаттерны

1. Тестирование деталей реализации

// ❌ Зависит от внутреннего state
expect(component.state.isOpen).toBe(true);

// ✅ Зависит от видимого поведения
expect(screen.getByRole("dialog")).toBeInTheDocument();

2. Зависимость тестов друг от друга

// ❌ Второй тест зависит от первого
it("creates user", () => {
  createUser("Alice");
  expect(getUsers()).toHaveLength(1);
});

it("finds user", () => {
  // ❌ Alice создана в предыдущем тесте!
  expect(findUser("Alice")).not.toBeNull();
});

// ✅ Каждый тест сам создаёт данные
it("finds user", () => {
  createUser("Alice");
  expect(findUser("Alice")).not.toBeNull();
});

3. Тестирование фреймворка

// ❌ Тестирует React, а не ваш код
it("calls useState", () => {
  const useState = vi.spyOn(React, "useState");
  render(<MyComponent />);
  expect(useState).toHaveBeenCalled();
});

4. Хрупкие селекторы

// ❌ Ломается при первом изменении разметки
expect(document.querySelector("div > div > span")).toHaveText("Hello");

// ✅ Устойчивый семантический селектор
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();

5. Snapshot-тестирование всего

// ❌ Snapshot ломается при каждом изменении разметки
expect(container).toMatchSnapshot();

// ✅ Snapshot только для стабильных компонентов
expect(iconElement).toMatchSnapshot();

6. Игнорирование async/await

// ❌ Синхронная проверка асинхронного результата
it("loads data", () => {
  render(<DataComponent />);
  expect(screen.getByText("Loaded")).toBeInTheDocument(); // FAIL — данные не загрузились
});

// ✅ Ожидание через findBy
it("loads data", async () => {
  render(<DataComponent />);
  expect(await screen.findByText("Loaded")).toBeInTheDocument();
});

7. Избыточное мокирование

// ❌ Мокирует всё — тест проверяет моки, а не код
vi.mock("./utils", () => ({ format: vi.fn().mockReturnValue("formatted") }));
vi.mock("./api", () => ({ fetch: vi.fn().mockReturnValue({ data: [] }) }));
vi.mock("./hooks", () => ({ useAuth: vi.fn().mockReturnValue({ user: {} }) }));

// ✅ Мокирует только внешние зависимости
// utils, hooks — реальные; API — мок (внешняя зависимость)
server.use(http.get("/api/data", () => HttpResponse.json({ data: [] })));

8. Тесты без assertions

// ❌ Нет expect — тест всегда проходит
it("renders component", () => {
  render(<MyComponent />);
  // Ничего не проверяется!
});

// ✅ Есть проверка
it("renders component", () => {
  render(<MyComponent />);
  expect(screen.getByText("Hello")).toBeInTheDocument();
});

Ключевые тезисы для интервью

  • Тесты — это код, проверяющий код; их главная цель — уверенность при изменениях, а не покрытие ради процента. Тестируйте поведение, а не реализацию.
  • Пирамида Кона (70% unit, 20% интеграционных, 10% E2E) и Testing Trophy (акцент на интеграционные для React/Next.js) определяют баланс: unit-тесты быстры и дёшевы, интеграционные проверяют реальное взаимодействие модулей, E2E имитирует пользователя, но дороги.
  • FIRST-принципы (Fast, Isolated, Repeatable, Self-validating, Timely) и паттерн AAA (Arrange-Act-Assert) делают тесты читаемыми, стабильными и поддерживаемыми.
  • TDD — цикл Red-Green-Refactor: сначала тест, потом код, потом рефакторинг; форсирует дизайн и защищает от регрессий, но избыточен для прототипов и исследовательских задач.
  • Покрытие кода — метрика, а не цель; важнее качество тестов и покрытие критичных веток бизнес-логики, а не 100% всех строк.
  • Приоритет селекторов (getByRole → getByLabelText → getByTestId) делает тесты устойчивыми к рефакторингу и одновременно проверяет доступность; data-testid — последний резерв, когда семантических селекторов нет.
  • Page Object Model централизует локаторы и действия страницы, снижая хрупкость E2E-тестов и делая их читаемыми как бизнес-сценарии.
  • Мокируйте только внешние границы (API, сеть, браузерные API, таймеры), а не внутренние модули; избыточное мокирование превращает тесты в проверку моков.
  • Smoke, regression, sanity и acceptance-тесты решают разные задачи; избегайте антипаттернов — хрупких селекторов, избыточных моков, snapshot всего и зависимости тестов друг от друга.

Заключение

  1. Эффективная стратегия тестирования начинается с понимания уровней тестов и их стоимости.
  2. Для серверного кода и библиотек хорошо работает пирамида Кона, а для React/Next.js — Testing Trophy с акцентом на интеграционные тесты.
  3. Хороший тест следует FIRST, использует AAA и проверяет поведение, а не внутреннюю реализацию.
  4. TDD помогает проектировать код и защищает от регрессий, но не обязателен для прототипов и исследовательских задач.
  5. Покрытие кода полезно для поиска непротестированных участков, но не заменяет качество проверок.
  6. Избегайте антипаттернов — хрупких селекторов, избыточных моков и snapshot-тестирования всего подряд.
  7. Начните с покрытия критичной бизнес-логики unit-тестами, добавьте интеграционные тесты компонентов и завершите критичными E2E-сценариями.

Полезные ссылки