Вы знаете это чувство, когда вы смотрите на функцию геттера из пяти строк, а ваш линтер кричит на вас, потому что покрытие составляет 87% вместо 95%? Да. Вот об этом моменте я и хочу поговорить.

Сообщество тестировщиков проделало невероятную работу по популяризации модульных тестов, и не зря. Тесты находят ошибки, они придают уверенности, они действуют как страховка. Но где-то по пути мы коллективно развили религиозное отношение к написанию тестов. Идея о том, что каждая строка кода заслуживает теста. Что код без 100% покрытия каким-то образом морально неполноценен. Что тестирование всегда, безоговорочно, является несомненным добром.

Я здесь, чтобы сказать: это не совсем так. Или, скорее, это не вся правда.

Культ полного покрытия

В современной разработке программного обеспечения происходит прекрасная ирония. Мы настолько увлеклись сокращением количества ошибок посредством исчерпывающего тестирования, что создали совершенно новую проблему: попусту потраченные инженерные усилия.

Подумайте о том, что происходит, когда вы требуете 100% покрытия тестами для всего:

  • Ваша команда тратит 20% своего времени на написание бизнес-логики.
  • Они тратят 80% своего времени на написание тестов, которые проверяют то, что очевидно делает бизнес-логика.
  • Они становятся обиженными, выгоревшими и, как ни странно, менее внимательными к тестам, которые они пишут.
  • Эти тесты становятся хрупкими, тесно связанными с деталями реализации и кошмарами для поддержки.

Самое ужасное? Эти тесты фактически не ловят ошибки. Они ничего не предотвращают. Они просто… есть. Как архитектурные украшения.

graph LR A["Писать тесты для всего"] --> B["Достигнуто высокое покрытие"] B --> C["Ложное чувство безопасности"] C --> D["Хрупкие, связанные тесты"] D --> E["Кошмар поддержки"] E --> F["Тесты игнорируются/удаляются"] style A fill:#ff6b6b style F fill:#ff6b6b style C fill:#ffd93d

Категории кода, которые не заслуживают тестов

Позвольте мне быть спорным. Некоторый код просто не заслуживает инвестиций в тестирование. Не потому, что можно быть небрежным, а потому, что анализ затрат и выгод не имеет смысла.

Тривиальный код (проблема пятистрочника)

Рассмотрим это:

function formatCurrency(amount: number): string {
  return `$${amount.toFixed(2)}`;
}

Тест для этой функции выглядел бы так:

describe('formatCurrency', () => {
  it('должен форматировать число как валюту', () => {
    expect(formatCurrency(42)).toBe('$42.00');
  });
  it('должен обрабатывать десятичные знаки', () => {
    expect(formatCurrency(42.5)).toBe('$42.50');
  });
  it('должен обрабатывать крайние случаи', () => {
    expect(formatCurrency(0)).toBe('$0.00');
    expect(formatCurrency(1000000)).toBe('$1000000.00');
  });
});

Вы только что написали три теста для проверки пяти строк кода. Файл с тестами длиннее исходного файла. Когда встроенный метод JavaScript toFixed() работает по-другому, ваш тест ничего не говорит — он просто терпит неудачу так же, как и код. Вы купили накладные расходы на тестирование без защиты тестирования.

Практическое правило? Если код просто оборачивает хорошо протестированную стандартную библиотечную функцию, рассмотрите возможность пропуска теста. Ваши интеграционные тесты всё равно найдут проблемы.

Исследовательский код

Вы создаёте прототип нового алгоритма. Вы не уверены, будет ли он работать. Вы быстро перебираете различные подходы, настраиваете параметры. Написание модульных тестов, пока вы находитесь в режиме полной разведки, похоже на написание обширной документации для чернового варианта. Это преждевременно. Это привязывает вас к деталям реализации, от которых вы собираетесь отказаться.

Лучший ход? Сначала доведите концепцию до рабочего состояния. Затем решите, стоит ли его тестировать.

Унаследованный код (проблема древних руин)

Вы унаследовали кодовую базу, которая существует со времён администрации Обамы. Она держится на скотче и молитвах. Тестов нет. Стоит ли писать исчерпывающие тесты для всего?

Нет. Это ожидаемая катастрофа. Вместо этого:

  1. Пишите тесты для новых функций, которые вы добавляете.
  2. Пишите тесты для исправленных ошибок.
  3. Оставьте остальное в покое, если вы активно не переписываете это.

Добавление тестов в унаследованный код похоже на попытку написать инструкцию для автомобиля, который уже проехал 100 000 миль. Это дорого, и инструкция не научит автомобиль работать лучше — она просто документирует текущий беспорядок.

Код рендеринга UI

Хотите знать, где разработчики тратят время впустую? На написание тестов, которые проверяют, что ваш React-компонент отображает кнопку, когда проп истинен. Что ваш Vue-шаблон отображает текст, когда данные существуют. Что ваша Angular-директива добавляет CSS-класс.

Эти тесты почти бесполезны:

// Это тест, о котором никто не просил
it('должен отображать кнопку, когда isVisible истинно', () => {
  const component = shallow(<MyButton isVisible={true} />);
  expect(component.find('button').exists()).toBe(true);
});

Ваши E2E-тесты это поймают. Ваши ручные тесты это поймают. Ваш браузер это поймает, если вы нажмёте на кнопку. Модульный тест? Он просто создаёт ложный контрольный пункт, который не предотвращает реальных проблем (например, когда кнопка расположена за пределами экрана, отключена или имеет плохие атрибуты доступности).

Есть причина, по которой отрасль постепенно отходит от тяжёлого модульного тестирования компонентов UI в сторону интеграционных тестов и E2E-тестов. Оказывается, проверка того, что UI выглядит правильно, более ценна, чем проверка того, что он отображается.

Когда стоит писать тесты (тонкая грань)

Я не выступаю против тестирования. Я выступаю за стратегическое тестирование. Тесты — это инвестиции, и, как и любые инвестиции, они должны иметь отдачу.

Пишите тесты для:

Бизнес-логика (драгоценности короны)

Ваша функция обработки платежей? Ваш алгоритм расчёта скидок? Ваша логика аутентификации пользователей? Всё это абсолютно нуждается в тестах. Стоимость ошибок здесь не только в стыде — это финансовые и юридические обязательства.

// Это заслуживает всестороннего тестирования
function calculateDiscount(
  subtotal: number,
  customerTier: 'bronze' | 'silver' | 'gold',
  isHoliday: boolean
): number {
  let discountPercent = 0;
  if (customerTier === 'gold') discountPercent += 15;
  else if (customerTier === 'silver') discountPercent += 10;
  else if (customerTier === 'bronze') discountPercent += 5;
  if (isHoliday) discountPercent += 5;
  // Ограничение скидки 30%
  discountPercent = Math.min(discountPercent, 30);
  return subtotal * (1 - discountPercent / 100);
}

Эта функция имеет несколько условных путей, крайние случаи и бизнес-правила. Ей нужны тесты:

describe('calculateDiscount', () => {
  it('должен применять правильные скидки уровня', () => {
    expect(calculateDiscount(100, 'gold', false)).toBe(85);
    expect(calculateDiscount(100, 'silver', false)).toBe(90);
    expect(calculateDiscount(100, 'bronze', false)).toBe(95);
  });
  it('должен применять праздничные бонусы', () => {
    expect(calculateDiscount(100, 'bronze', true)).toBe(90);
  });
  it('должен ограничивать скидку 30%', () => {
    expect(calculateDiscount(100, 'gold', true)).toBe(70);
  });
});

Эти тесты — не накладные расходы, а защита от дорогостоящих ошибок.

Точки интеграции

Границы между вашим кодом и внешними системами? Именно там обитает большинство ошибок. Третьи стороны API, запросы к базе данных, операции с файловой системой — когда ваш код взаимодействует с внешним миром, имитация этих зависимостей и тестирование поведения действительно ценны.

Вы не просто проверяете, что ваш код работает, вы подтверждаете, что он изящно обрабатывает сбои, корректно повторяет попытки и безопасно завершается.

Код, склонный к регрессиям

У вас есть функция, которая стала источником трёх отдельных ошибок в продакшене. Она сложная. Люди продолжают неправильно её понимать. Теперь? Пишите тесты. Не потому, что это политика, а потому, что вы узнали, что это опасно.

Практическая структура: сортировка тестов

Вот как я решаю, заслуживает ли фрагмент кода теста:

┌─ Это бизнес-логика?
│  └─ ДА: пишите тесты
│
├─ Есть