Когда я попадаю на проект, где принято покрывать тестами больше 80% кода, то я испытываю настоящую боль. Эту статью можно считать криком души в поисках здравого смысла.
И если покрытие бэкенда еще можно обосновать, то вот покрытие 100% React-кода — это настоящее безумие.
Как это обычно происходит в компаниях. Кто-то решает, что весь фронтовый код должен быть покрыт тестами. Обоснование часто довольно скупое — меньше багов, чтобы агенты не сломали код и вообще... Спорить на первый взгляд сложно с такими аргументами. Кажется, что лучше, когда есть тесты, чем когда их нет. Но не все так просто, как кажется на первый взгляд.
Сейчас самый популярный инструмент для тестирования фронтенд-кода — это Playwright. С его помощью можно рендерить отдельные куски React-кода в реальном браузере, ждать, когда элемент появится на странице, ждать, пока выполнится запрос, кликать по кнопкам, переходить по ссылкам. В общем, мощный инструмент, с помощью которого можно повторить реальные пользовательские сценарии в приложении.
И вот мы с помощью этого инструмента начинаем покрывать каждый отдельный компонент вдоль и поперек. И появляются тесты, которые проверяют, что проп title="Hello" реально отрисовался как “Hello”. Тесты, которые мокают API и проверяют, что описание из мока появилось на странице. Кликают по кнопке и проверяют, что вызывается функция с нужными параметрами, которую мы же туда и передали.
Допустим, у нас есть обычный React-компонент:
type ProductCardProps = { title: string; description: string; onBuy: () => void; }; export function ProductCard({ title, description, onBuy }: ProductCardProps) { return ( <div> <h2>{title}</h2> <p>{description}</p> <button onClick={onBuy}>Buy</button> </div> ); }
А теперь тест, который формально выглядит полезным:
import { test, expect } from '@playwright/experimental-ct-react'; import { ProductCard } from './ProductCard'; test('renders props and calls handler on click', async ({ mount }) => { let clicked = false; const component = await mount( <ProductCard title="iPhone 16" description="Best phone ever" onBuy={() => { clicked = true; }} /> ); await expect(component.getByText('iPhone 16')).toBeVisible(); await expect(component.getByText('Best phone ever')).toBeVisible(); await component.getByRole('button', { name: 'Buy' }).click(); expect(clicked).toBe(true); });
И вот здесь надо задать главный вопрос: что именно мы проверили?
Что строка title="iPhone 16" отрисовалась как iPhone 16.
Что строка description="Best phone ever" отрисовалась как Best phone ever.
Что после клика вызвался onBuy, который мы сами только что и передали.
То есть мы не проверили продуктовый сценарий, не проверили бизнес-логику, не проверили интеграцию с API, не проверили переход к оплате, не проверили изменение состояния приложения. Мы проверили, что React умеет подставлять пропсы в JSX и что кнопка вызывает обработчик onClick. Но это не та часть системы, которая обычно и ломается сама по себе.
В этом и проблема таких тестов. Они выглядят осмысленно, потому что в них есть рендер, клик и ожидания. Но по сути это просто дублирование контракта компонента. Если компонент написан как тупой презентейшен-компонент, тест почти полностью повторяет его код на человеческом языке.
По юнит-тестам функций тоже есть вопросы. Буквально есть функция-маппер, которая, например, в зависимости от ключа возвращает нужный объект.
type Status = 'new' | 'in_progress' | 'done'; export function mapStatusToLabel(status: Status): string { const map = { new: 'New', in_progress: 'In progress', done: 'Done', }; return map[status]; }
И мы пишем такой тест:
import { mapStatusToLabel } from './mapStatusToLabel'; describe('mapStatusToLabel', () => { it('returns correct label for each status', () => { expect(mapStatusToLabel('new')).toBe('New'); expect(mapStatusToLabel('in_progress')).toBe('In progress'); expect(mapStatusToLabel('done')).toBe('Done'); }); });
Что дает такой тест?
Проверяет, что объект map содержит такие же значения, какие мы туда сами и записали. Что JS корректно делает доступ по ключу. Что строка 'New' равна 'New'.
Проблема в том, что такой тест почти не проверяет поведение системы. Он просто дублирует реализацию. Мы сначала руками создаём объект map, потом в тесте руками перечисляем те же самые ключи и те же самые значения, а потом радуемся, что всё совпало. Но если разработчик поменяет 'Done' на 'Completed', он с такой же лёгкостью поменяет это и в тесте. То есть тест не ловит ошибку, а просто заставляет поддерживать два одинаковых куска кода.
Я понимаю, почему так происходит. Все начинается с прекрасной идеи, чтобы код не ломался. Потом появляется метрика покрытия. Метрику очень легко закрыть, ей легко отчитываться, легко накрутить это на пайплайны и контролировать.
Дальше включается простая логика — покрыть всё подряд. Компоненты, хуки, пропсы. Это самый лёгкий способ поднять процент: не нужно думать, где есть риск, достаточно просто написать тест.
Плюс такие тесты почти всегда зелёные. Ты сам задаёшь вход и сам проверяешь ожидаемый результат. Это создаёт ощущение контроля. Но это всего лишь иллюзия контроля.
И главный аргумент — “хуже же не будет, пусть будет больше тестов”. Но у тестов есть цена: их нужно писать, поддерживать и чинить. И когда их становится слишком много, эта цена начинает перевешивать пользу.
Главная ошибка — в том, что метрику принимают за цель. Покрытие не равно качеству. Оно не показывает, ловят ли тесты реальные баги. Оно просто показывает, что код выполнился.
И в итоге команда начинает оптимизироваться не под надёжность, а под процент. Появляются тесты, которые увеличивают покрытие, но не увеличивают уверенность в системе.
Потому что тестируют не то.
Возникает логичный вопрос — а как правильно?
Ответ на самом деле довольно простой, но почему-то редко соблюдается: тестировать нужно не код, а риски.
Если в компоненте нет логики, если он просто принимает пропсы и рендерит JSX — там почти нечему ломаться. В таких местах тест чаще всего просто дублирует реализацию. И чем подробнее он повторяет JSX, тем меньше в нём смысла.
А вот там, где начинается реальное поведение — там уже появляются причины для тестов. Например, когда у тебя есть условия, разные ветки отображения, зависимости от данных с бэка, обработка ошибок, асинхронщина, работа со стейтом. Вот это уже зона риска. Вот это уже можно сломать так, что пользователь это почувствует.
То же самое с функциями. Если функция — это по сути словарь с доступом по ключу, TypeScript уже даёт тебе больше гарантий, чем тест. Он не даст забыть ключ, не даст обратиться к несуществующему значению. Тест здесь просто повторяет код. Но если функция начинает трансформировать данные, обрабатывать edge cases, работать с нестабильным вводом — тогда тест уже начинает иметь смысл.
С Playwright история ещё проще. Это инструмент для проверки сценариев, а не JSX. Его сила в том, что он проверяет систему целиком: открыл страницу, загрузились данные, пользователь что-то сделал, система отреагировала. Там реально много точек отказа. И вот такие вещи действительно стоит проверять.
Использовать Playwright для проверки JSX — это как тестировать функцию сложения через браузер.
Я допускаю, что можно замокать бэкенд полностью, учитывая, что написаны контрактные тесты на бэкенде. Так часто делают в компаниях, чтобы ускорить фронтовые тесты. Но даже в таком виде, с замоканным бэкендом можно проверять самые критичные пользовательские сценарии.
А проверять через браузер, что строка из пропсов появилась на странице — это очень дорогой способ убедиться, что React всё ещё работает.
Хороший ориентир здесь очень простой: если тест падает, это должна быть проблема, которую заметит пользователь. Если тест падает потому, что ты поменял текст, структуру DOM или просто отрефакторил компонент — это не сигнал о баге, это шум.
И как только в проекте появляется много такого шума, тесты перестают выполнять свою главную функцию — быть сигналом. Они превращаются в фон, который все игнорируют.
Как мог бы выглядеть реально нужный тест. Просто для примера:
test('user can complete checkout', async ({ page }) => { await page.goto('/product/iphone-16'); await page.getByRole('button', { name: 'Buy' }).click(); await expect(page).toHaveURL(/checkout/); await expect(page.getByText('Order summary')).toBeVisible(); });
Если такой тест ломается, то на это, мягко говоря, стоит обратить внимание.
В итоге нормальная стратегия выглядит не как “покрыть всё”, а как “покрыть важное”. Не количество тестов, а их способность ловить реальные поломки.
И это просто приведет к тому, что в какой-то момент в проекте просто удалят 80% тестов, и все выдохнут. И это будет не потому, что команда стала хуже, а потому что она наконец начала ценить смысл, а не метрики.
Да, часто часть кода вообще не нужно тестировать. И это нормально.
Потому что цель — не 100% покрытие. Цель — чтобы приложение не ломалось там, где это действительно важно.
