- Регистрация
- 23 Авг 2023
- Сообщения
- 3,969
- Реакции
- 0
- Баллы
- 36
Ofline
Когда я попадаю на проект, где принято покрывать тестами больше 80% кода, то я испытываю настоящую боль. Эту статью можно считать криком души в поисках здравого смысла.
И если покрытие бэкенда еще можно обосновать, то вот покрытие 100% React-кода — это настоящее безумие.
Как это обычно происходит в компаниях. Кто-то решает, что весь фронтовый код должен быть покрыт тестами. Обоснование часто довольно скупое — меньше багов, чтобы агенты не сломали код и вообще... Спорить на первый взгляд сложно с такими аргументами. Кажется, что лучше, когда есть тесты, чем когда их нет. Но не все так просто, как кажется на первый взгляд.
Сейчас самый популярный инструмент для тестирования фронтенд-кода — это Playwright. С его помощью можно рендерить отдельные куски React-кода в реальном браузере, ждать, когда элемент появится на странице, ждать, пока выполнится запрос, кликать по кнопкам, переходить по ссылкам. В общем, мощный инструмент, с помощью которого можно повторить реальные пользовательские сценарии в приложении.
И вот мы с помощью этого инструмента начинаем покрывать каждый отдельный компонент вдоль и поперек. И появляются тесты, которые проверяют, что проп
Допустим, у нас есть обычный React-компонент:
А теперь тест, который формально выглядит полезным:
И вот здесь надо задать главный вопрос: что именно мы проверили?
Что строка
Что строка
Что после клика вызвался
То есть мы не проверили продуктовый сценарий, не проверили бизнес-логику, не проверили интеграцию с API, не проверили переход к оплате, не проверили изменение состояния приложения. Мы проверили, что React умеет подставлять пропсы в JSX и что кнопка вызывает обработчик
В этом и проблема таких тестов. Они выглядят осмысленно, потому что в них есть рендер, клик и ожидания. Но по сути это просто дублирование контракта компонента. Если компонент написан как тупой презентейшен-компонент, тест почти полностью повторяет его код на человеческом языке.
По юнит-тестам функций тоже есть вопросы. Буквально есть функция-маппер, которая, например, в зависимости от ключа возвращает нужный объект.
И мы пишем такой тест:
Что дает такой тест?
Проверяет, что объект
Проблема в том, что такой тест почти не проверяет поведение системы. Он просто дублирует реализацию. Мы сначала руками создаём объект
Я понимаю, почему так происходит. Все начинается с прекрасной идеи, чтобы код не ломался. Потом появляется метрика покрытия. Метрику очень легко закрыть, ей легко отчитываться, легко накрутить это на пайплайны и контролировать.
Дальше включается простая логика — покрыть всё подряд. Компоненты, хуки, пропсы. Это самый лёгкий способ поднять процент: не нужно думать, где есть риск, достаточно просто написать тест.
Плюс такие тесты почти всегда зелёные. Ты сам задаёшь вход и сам проверяешь ожидаемый результат. Это создаёт ощущение контроля. Но это всего лишь иллюзия контроля.
И главный аргумент — “хуже же не будет, пусть будет больше тестов”. Но у тестов есть цена: их нужно писать, поддерживать и чинить. И когда их становится слишком много, эта цена начинает перевешивать пользу.
Главная ошибка — в том, что метрику принимают за цель. Покрытие не равно качеству. Оно не показывает, ловят ли тесты реальные баги. Оно просто показывает, что код выполнился.
И в итоге команда начинает оптимизироваться не под надёжность, а под процент. Появляются тесты, которые увеличивают покрытие, но не увеличивают уверенность в системе.
Потому что тестируют не то.
Возникает логичный вопрос — а как правильно?
Ответ на самом деле довольно простой, но почему-то редко соблюдается: тестировать нужно не код, а риски.
Если в компоненте нет логики, если он просто принимает пропсы и рендерит JSX — там почти нечему ломаться. В таких местах тест чаще всего просто дублирует реализацию. И чем подробнее он повторяет JSX, тем меньше в нём смысла.
А вот там, где начинается реальное поведение — там уже появляются причины для тестов. Например, когда у тебя есть условия, разные ветки отображения, зависимости от данных с бэка, обработка ошибок, асинхронщина, работа со стейтом. Вот это уже зона риска. Вот это уже можно сломать так, что пользователь это почувствует.
То же самое с функциями. Если функция — это по сути словарь с доступом по ключу, TypeScript уже даёт тебе больше гарантий, чем тест. Он не даст забыть ключ, не даст обратиться к несуществующему значению. Тест здесь просто повторяет код. Но если функция начинает трансформировать данные, обрабатывать edge cases, работать с нестабильным вводом — тогда тест уже начинает иметь смысл.
С Playwright история ещё проще. Это инструмент для проверки сценариев, а не JSX. Его сила в том, что он проверяет систему целиком: открыл страницу, загрузились данные, пользователь что-то сделал, система отреагировала. Там реально много точек отказа. И вот такие вещи действительно стоит проверять.
Использовать Playwright для проверки JSX — это как тестировать функцию сложения через браузер.
Я допускаю, что можно замокать бэкенд полностью, учитывая, что написаны контрактные тесты на бэкенде. Так часто делают в компаниях, чтобы ускорить фронтовые тесты. Но даже в таком виде, с замоканным бэкендом можно проверять самые критичные пользовательские сценарии.
А проверять через браузер, что строка из пропсов появилась на странице — это очень дорогой способ убедиться, что React всё ещё работает.
Хороший ориентир здесь очень простой: если тест падает, это должна быть проблема, которую заметит пользователь. Если тест падает потому, что ты поменял текст, структуру DOM или просто отрефакторил компонент — это не сигнал о баге, это шум.
И как только в проекте появляется много такого шума, тесты перестают выполнять свою главную функцию — быть сигналом. Они превращаются в фон, который все игнорируют.
Как мог бы выглядеть реально нужный тест. Просто для примера:
Если такой тест ломается, то на это, мягко говоря, стоит обратить внимание.
В итоге нормальная стратегия выглядит не как “покрыть всё”, а как “покрыть важное”. Не количество тестов, а их способность ловить реальные поломки.
И это просто приведет к тому, что в какой-то момент в проекте просто удалят 80% тестов, и все выдохнут. И это будет не потому, что команда стала хуже, а потому что она наконец начала ценить смысл, а не метрики.
Да, часто часть кода вообще не нужно тестировать. И это нормально.
Потому что цель — не 100% покрытие. Цель — чтобы приложение не ломалось там, где это действительно важно.
Мой тг-канал про инди-разработку своих продуктов
И если покрытие бэкенда еще можно обосновать, то вот покрытие 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% покрытие. Цель — чтобы приложение не ломалось там, где это действительно важно.
Мой тг-канал про инди-разработку своих продуктов