Когда я попадаю на проект, где принято покрывать тестами больше 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% покрытие. Цель — чтобы приложение не ломалось там, где это действительно важно.

Комментарии (8)


  1. nihil-pro
    25.03.2026 05:13

    Подержите мое пиво!

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


  1. winkyBrain
    25.03.2026 05:13

    Это вы не видели ещё самое бестолковое) на пару недель загремел в другую команду, помочь с релизом MVP. Так у них в проекте(при том, что это MVP и надо вроде как побыстрее) буквально на каждый, даже самый мизерный компонент, написан бесполезный тест на jest, который проверяет, что компонент рендерится. Без различных комбинаций пропсов, без пограничных случаев или исключений, и все такие написанные ранее тесты перед коммитом должны пройти.

    Казалось бы, ну тесты и тесты, ну запускаются там себе перед коммитом. Проблемы начались тогда, когда компонент-заглушка(на который, разумеется, написан свой тест) перестал быть таковым - в нём появился хук из RTK, а именно useAppSelector: это сломало тесты, ведь jest не знает, что это за метод, какие значения он отдаст в компонент. Теперь необходимо идти в поломанный бесполезный тест и мокать в нём хук, а так же значения, которые он вернёт для теста.

    И так в каждом бесполезном тесте компонента, где ранее не использовался useAppSelector, но вдруг стал. По итогу получается какая-то обезьянья работа: тратить время на починку того, что не несёт в себе никакой пользы. Причём в будущем проект будет покрываться интеграционными тестами, что лишает написание обсуждаемых выше юнит-тестов на jest какого-либо смысла. Но тем не менее, единственным фронтенд-разработчиком на этом проекте(если не считать тех, кого время от времени присылают на помощь, как меня) тесты упорно пишутся


  1. David_Osipov
    25.03.2026 05:13

    Тесты можно писать и редачить через ИИ. Тесты полезны тем, что показывают, что что-то где-то отвалилось или отвалится, когда настанет какой-то edge case. Писать чего-то посложнее статичного одностраничника без тестов - это приговаривать компанию тратить х часов или дней на неуловимого бага. Уж нет, спасибо, проходили через такой гемор. Писать сложное без тестов, это как заниматься в постеле без защиты - по идее можно, но даже натренированная реакция не защитит от появления детей.


    1. nazarovigor
      25.03.2026 05:13

      Зато если избегать тестов, то это станет причиной появления новых задач по проектам, что может кормить команду потом ещё долгое время


  1. dimonbavly
    25.03.2026 05:13

    Если продолжить мысль в таком же духе - можно не писать длинные осмысленные имена переменных, а обойтись лишь буквами, везде заложить any вместо соответствующего типа. И когда проект разрастётся уже думать как все это мигрировать на новые версии или вообще на другой фреймворк. Но я согласен с тем, что избыточное, излишнее тестирование это плохо и не нужно.