Подводные камни и антипаттерны в тестировании
Тесты могут быть хуже, чем их отсутствие. Хрупкие, медленные и ложно успокаивающие тесты отнимают время и снижают доверие к CI.
Эта статья — про типичные ошибки, которые допускают даже опытные разработчики, и как их избежать.
Содержание
- Flaky-тесты
- Хрупкие селекторы
- Shared state между тестами
- Моки вместо реальности
- Тесты проверяют реализацию
- Погоня за coverage
- Слишком много E2E
- Race conditions
- Неактуальные тесты
- False confidence
- Sleep и waitForTimeout
- Отсутствие негативных сценариев
- Неиспользуемые моки
- Отладка: что делать, когда тест падает
- Чеклист
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Flaky-тесты
Flaky-тест — тест, который то проходит, то падает без изменений кода.
Причины
| Причина | Пример решения |
|---|---|
| Зависимость от времени | vi.useFakeTimers() |
| Гонки состояний | await findBy..., waitForResponse |
| Нестабильные селекторы | getByRole, getByTestId |
| Shared state | beforeEach + очистка |
| Анимации | prefers-reduced-motion, отключение анимаций в тестах |
| Сеть | MSW, моки внешних сервисов |
Что делать
- Не повышай
retriesбез анализа. - Собери trace, screenshot, логи.
- Воспроизведи локально многократно.
- Исправь причину, а не симптом.
💡 Правило: 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 / интеграционные
- Запусти только упавший тест:
npx vitest run MyTest. - Добавь
screen.debug()илиconsole.log. - Проверь, что моки настроены правильно.
- Убедись, что нет shared state.
E2E
- Включи trace:
trace: "on-first-retry". - Открой HTML-отчёт и воспроизведи шаги.
- Проверь screenshot и video (если включены).
- Запусти headed-режим:
npx playwright test --headed. - Используй 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, а не источником рутины.