Основы тестирования: пирамида, принципы, стратегии
Каждое изменение кода несёт риск сломать уже работающий функционал, особенно в команде и на долгосрочном проекте. Автотесты снижают этот риск, но только если их правильно выбирать, писать и распределять по уровням. В этой статье разберём пирамиду и Testing Trophy, уровни unit/интеграционных/E2E тестов, принципы FIRST, паттерн AAA, TDD, code coverage и антипаттерны, которые стоит избегать.
Содержание
- Что такое тестирование и зачем оно нужно
- Пирамида тестирования
- Testing Trophy — альтернативная модель
- FIRST-принципы
- Паттерн AAA (Arrange-Act-Assert)
- TDD: Red-Green-Refactor
- Что тестировать, что не тестировать
- Покрытие кода (Code Coverage)
- Классификация тестов по назначению
- Стоимость тестов
- Лучшие практики
- Антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Что такое тестирование и зачем оно нужно
Тестирование — это процесс проверки того, что код работает так, как ожидается. Автоматизированные тесты — это код, который проверяет другой код.
Зачем тестировать
| Проблема без тестов | Как решают тесты |
|---|---|
| Рефакторинг ломает существующий функционал | Тесты ловят регрессии до деплоя |
| Непонятно, что должна делать функция | Тесты — живая документация ожидаемого поведения |
| Страх менять код («а вдруг сломается») | Тесты дают уверенность при изменениях |
| Ручная проверка каждого релиза | Автотесты выполняются за секунды в 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-тесты, потому что:
- Тестируется реальное поведение, а не моки
- Ловятся ошибки интеграции между модулями
- Тесты не ломаются при рефакторинге внутренней реализации
Пример:
// ❌ 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 — про дизайн кода. Когда вы пишете тест до кода, вы:
- Думаете о контракте — что функция должна делать, а не как она это делает
- Пишете тестируемый код — код, который можно тестировать, обычно лучше спроектирован
- Получаете обратную связь мгновенно — тест падает → вы знаете, что нужно реализовать
- Документируете поведение — тесты становятся живой документацией
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, useDebounce | Unit / Интеграционный |
| Компоненты | Рендеринг, взаимодействие, формы, ошибки | Интеграционный |
| Критические сценарии | Регистрация, оплата, авторизация | 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% покрытие — не цель:
-
Покрытие не гарантирует отсутствие багов. Тест может выполняться, но не проверять ничего полезного:
it("does something", () => { calculateDiscount(100, true); // Код выполнился, но ничего не проверяется }); -
100% покрытие требует тестирования тривиального кода:
// Этот код не нуждается в тесте const getName = (user: User) => user.name; -
Погоня за 100% приводит к хрупким тестам:
// ❌ Тест ради покрытия, а не ради уверенности it("calls console.log", () => { const spy = vi.spyOn(console, "log"); doSomething(); expect(spy).toHaveBeenCalled(); // И что это даёт? });
Как использовать покрытие правильно:
- Установите разумный порог (80% statements, 70% branches)
- Используйте покрытие для выявления непокрытых модулей, а не для гонки за процентами
- Пишите тесты для критичных модулей, даже если они уже покрыты
- Игнорируйте покрытие для тривиального кода (конструкторы, конфигурация)
Виды покрытия
| Метрика | Что измеряет | Почему важна |
|---|---|---|
| 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/sessionStorage | localStorage.clear() в beforeEach |
| Сбрасывать MSW handlers | server.resetHandlers() |
| Очищать моки | vi.clearAllMocks() |
| Очищать DOM | cleanup() от 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 всего и зависимости тестов друг от друга.
Заключение
- Эффективная стратегия тестирования начинается с понимания уровней тестов и их стоимости.
- Для серверного кода и библиотек хорошо работает пирамида Кона, а для React/Next.js — Testing Trophy с акцентом на интеграционные тесты.
- Хороший тест следует FIRST, использует AAA и проверяет поведение, а не внутреннюю реализацию.
- TDD помогает проектировать код и защищает от регрессий, но не обязателен для прототипов и исследовательских задач.
- Покрытие кода полезно для поиска непротестированных участков, но не заменяет качество проверок.
- Избегайте антипаттернов — хрупких селекторов, избыточных моков и snapshot-тестирования всего подряд.
- Начните с покрытия критичной бизнес-логики unit-тестами, добавьте интеграционные тесты компонентов и завершите критичными E2E-сценариями.
Полезные ссылки
- Kent C. Dodds — Static vs Unit vs Integration vs E2E Tests
- Kent C. Dodds — The Testing Trophy and Testing Classifications
- Mike Cohn — The Forgotten Layer of the Test Automation Pyramid
- Testing Library — Guiding Principles
- Vitest Docs — Coverage
- Playwright Docs — Best Practices
- Jest Docs — Getting Started
- MDN — Tools and testing