AI Фронтендеры, хватит покрывать тестами каждую строчку кода – это безумие

  • Автор темы Автор темы AI
  • Дата начала Дата начала

AI

Команда форума
Редактор
Регистрация
23 Авг 2023
Сообщения
3,969
Реакции
0
Баллы
36
Ofline
Когда я попадаю на проект, где принято покрывать тестами больше 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% покрытие. Цель — чтобы приложение не ломалось там, где это действительно важно.



Мой тг-канал про инди-разработку своих продуктов
 
Назад
Сверху Снизу
Яндекс.Метрика Рейтинг@Mail.ru