Навигация

Подводные камни и антипаттерны в тестировании

Тесты могут быть хуже, чем их отсутствие. Хрупкие, медленные и ложно успокаивающие тесты отнимают время и снижают доверие к CI.

Эта статья — про типичные ошибки, которые допускают даже опытные разработчики, и как их избежать.


Содержание

  1. Flaky-тесты
  2. Хрупкие селекторы
  3. Shared state между тестами
  4. Моки вместо реальности
  5. Тесты проверяют реализацию
  6. Погоня за coverage
  7. Слишком много E2E
  8. Race conditions
  9. Неактуальные тесты
  10. False confidence
  11. Sleep и waitForTimeout
  12. Отсутствие негативных сценариев
  13. Неиспользуемые моки
  14. Отладка: что делать, когда тест падает
  15. Чеклист
  16. Ключевые тезисы для интервью
  17. Заключение
  18. Полезные ссылки

Flaky-тесты

Flaky-тест — тест, который то проходит, то падает без изменений кода.

Причины

ПричинаПример решения
Зависимость от времениvi.useFakeTimers()
Гонки состоянийawait findBy..., waitForResponse
Нестабильные селекторыgetByRole, getByTestId
Shared statebeforeEach + очистка
Анимацииprefers-reduced-motion, отключение анимаций в тестах
СетьMSW, моки внешних сервисов

Что делать

  1. Не повышай retries без анализа.
  2. Собери trace, screenshot, логи.
  3. Воспроизведи локально многократно.
  4. Исправь причину, а не симптом.

💡 Правило: flaky-тест хуже, чем отсутствие теста. Он разрушает доверие к CI.


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

Селекторы по CSS-классам и DOM-структуре ломаются при любом рефакторинге вёрстки.

// ❌ Плохо
await page.click(".btn-primary");
await page.click("div > button:nth-child(2)");

// ✅ Хорошо
await page.getByRole("button", { name: /submit/i }).click();
await page.getByTestId("submit-button").click();

Решение

  • Используй семантические селекторы.
  • Добавляй data-testid там, где нет текста/ролей.
  • Согласуй с командой политику использования data-testid.

Shared state между тестами

Тесты, которые зависят от порядка или делят данные, нестабильны при параллельном запуске.

// ❌ Плохо
let user: User;

it("creates user", async () => {
  user = await createUser();
});

it("deletes user", async () => {
  await deleteUser(user.id); // упадёт, если первый тест не выполнится
});

// ✅ Хорошо
it("deletes created user", async () => {
  const user = await createUser();
  await deleteUser(user.id);
  await expect(findUser(user.id)).rejects.toThrow();
});

Решение

  • Каждый тест создаёт и удаляет свои данные.
  • Используй beforeEach / afterEach.
  • Сбрасывай моки и handlers между тестами.

Моки вместо реальности

Если всё замокано, ты тестируешь моки, а не приложение.

// ❌ Плохо: мокнуты все слои
vi.mock("./api");
vi.mock("./store");
vi.mock("./hooks");

render(<UserProfile />);
expect(screen.getByText("Alice")).toBeInTheDocument();

Решение

  • Мокируй только внешние зависимости.
  • Для API используй MSW вместо моков хуков.
  • Проверяй, что тест без моков тоже работает на staging.

Тесты проверяют реализацию

Тест, привязанный к внутренней структуре, ломается при рефакторинге.

// ❌ Плохо
it("calls useEffect once", () => {
  const spy = vi.spyOn(React, "useEffect");
  render(<Component />);
  expect(spy).toHaveBeenCalledTimes(1);
});

// ✅ Хорошо
it("loads user on mount", async () => {
  render(<Component />);
  expect(await screen.findByText("Alice")).toBeInTheDocument();
});

Решение

  • Тестируй поведение пользователя.
  • Не проверяй вызовы внутренних хуков, если это не критично.
  • Избегай тестов приватных методов.

Погоня за coverage

100% покрытие не гарантирует отсутствие багов.

// ❌ Бесполезный тест ради coverage
it("calls calculateDiscount", () => {
  calculateDiscount(100, true); // нет assert
});

Решение

  • Coverage — метрика, не цель.
  • Устанавливай разумные пороги.
  • Пиши asserts, которые проверяют поведение.

Слишком много E2E

E2E — дорогие тесты. Если их слишком много, CI превращается в марафон.

Признаки

  • CI проходит 30+ минут.
  • Каждый рефакторинг UI ломает десятки тестов.
  • Разработчики игнорируют красный CI.

Решение

  • Переноси логику на интеграционные и unit-тесты.
  • E2E оставь только для критичных пользовательских сценариев.
  • Используй теги (@smoke, @regression) для выборочного запуска.

Race conditions

Тест может выполнить assert до того, как приложение обновит состояние.

// ❌ Плохо
await user.click(submitButton);
expect(screen.getByText("Success")).toBeInTheDocument(); // может не успеть

// ✅ Хорошо
await user.click(submitButton);
expect(await screen.findByText("Success")).toBeInTheDocument();

Решение

  • Используй findBy* вместо getBy* для асинхронных операций.
  • В Playwright используй auto-retrying assertions.
  • Жди сетевых ответов через waitForResponse.

Неактуальные тесты

Тесты, которые давно не обновлялись, проверяют уже не то поведение.

Признаки

  • Тест проходит, но функционал сломан.
  • В тесте закомментированы участки.
  • Название теста не соответствует коду.

Решение

  • Регулярно ревьюй тесты.
  • Удаляй бесполезные тесты.
  • При изменении требований обновляй тесты первым делом.

False confidence

Тесты проходят, но приложение не работает.

Причины

  • Тесты проверяют моки.
  • asserts слишком слабые.
  • Тесты не покрывают негативные сценарии.
  • E2E проходят против мокированного бэкенда, который не соответствует реальности.

Решение

  • Периодически запускай E2E против staging.
  • Проверяй реальные API-контракты.
  • Пиши asserts, которые реально валидируют результат.

Sleep и waitForTimeout

Фиксированные задержки — признак нестабильного теста.

// ❌ Плохо
await page.waitForTimeout(2000);

// ✅ Хорошо
await expect(page.getByText("Loaded")).toBeVisible();

Решение

  • Жди конкретного состояния.
  • Используй fake timers для unit-тестов.
  • В E2E используй auto-waiting Playwright.

Отсутствие негативных сценариев

Счастливый путь — это только половина дела. Ошибки случаются чаще, чем кажется.

// ✅ Тестируем и успех, и ошибку
it("shows user list on success", async () => { ... });
it("shows error message on API failure", async () => { ... });
it("shows empty state when list is empty", async () => { ... });

Неиспользуемые моки

Мок, который не влияет на тест, сбивает с толку.

// ❌ Плохо
vi.mock("./api");

it("displays heading", () => {
  render(<HomePage />);
  expect(screen.getByRole("heading")).toBeInTheDocument();
});

Решение

  • Убирай моки, которые не нужны для конкретного теста.
  • Используй vi.mock в файле, где он действительно используется.

Отладка: что делать, когда тест падает

Unit / интеграционные

  1. Запусти только упавший тест: npx vitest run MyTest.
  2. Добавь screen.debug() или console.log.
  3. Проверь, что моки настроены правильно.
  4. Убедись, что нет shared state.

E2E

  1. Включи trace: trace: "on-first-retry".
  2. Открой HTML-отчёт и воспроизведи шаги.
  3. Проверь screenshot и video (если включены).
  4. Запусти headed-режим: npx playwright test --headed.
  5. Используй codegen для сравнения с рабочим сценарием.

Чеклист

  • Flaky-тесты анализируются и исправляются, а не маскируются retries.
  • Селекторы устойчивы к рефакторингу.
  • Тесты не делят mutable state.
  • Моки используются умеренно, мокируются только границы.
  • Тесты проверяют поведение, не реализацию.
  • Coverage не является целью сам по себе.
  • E2E покрывают только критичные сценарии.
  • Нет waitForTimeout и sleep.
  • Для каждого happy path есть негативный сценарий.
  • Тесты регулярно пересматриваются и удаляются, если устарели.

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

  • Flaky-тесты разрушают доверие к CI; их нужно анализировать и чинить, а не маскировать retries. Асинхронные операции проверяют через findBy* и auto-retrying assertions, а не через sleep.
  • Устойчивые селекторы опираются на семантику и data-testid, а не на CSS-классы и DOM-структуру.
  • Тесты не должны делить mutable state и зависеть от порядка выполнения; каждый тест создаёт свои данные и очищает окружение.
  • Мокировать следует только внешние границы; избыточное мокирование и неиспользуемые моки делают тесты хрупкими и замедляют рефакторинг.
  • Проверяй поведение пользователя, а не внутренние детали реализации компонентов; это делает тесты устойчивыми к рефакторингу.
  • Coverage и количество E2E — метрики, а не цели; false confidence возникает, когда asserts слабые, моки заменяют реальность или негативные сценарии отсутствуют.
  • Для каждого happy path нужен негативный сценарий и проверка пограничных состояний; тесты устаревают, их нужно регулярно пересматривать и удалять, если они теряют смысл.

Заключение

Хорошие тесты дают уверенность при изменениях, а плохие — ложное спокойствие и лишнюю работу. Главный принцип: тест должен проверять поведение, а не реализацию, быть стабильным, понятным и поддерживаемым. Избегайте фиксированных задержек, хрупких селекторов, избыточных моков и общего состояния между тестами. Coverage и количество E2E — вспомогательные метрики, а не цели. Регулярно пересматривайте тестовый набор: удаляйте устаревшие, добавляйте негативные сценарии и запускайте E2E против staging. Тогда тесты станут надёжной частью CI, а не источником рутины.

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