Я этот ритуал знаю слишком хорошо. Пятница, до релиза двадцать минут. Авторизация работает. Форма сохраняется. Кнопка нажимается. На этом очень хочется остановиться: три клика дали достаточно доказательств, production наверняка согласится.
Зачем тратить время на unit-тесты, integration-тесты и тем более какой-то E2E, если можно открыть браузер и всё проверить самому?
А если через неделю выяснится, что последняя правка сломала старый импорт, значит, перед следующим релизом нужно кликать внимательнее.
Главное — хорошо помнить проект
Автоматические тесты особенно любят разработчики, которые не доверяют собственной памяти. Настоящий разработчик примерно помнит, что нужно проверить.
Допустим, мы изменили расчёт скидки в заказе. Очевидный маршрут выглядит безупречно:
открыть заказ
↓
добавить товар
↓
применить скидку
↓
проверить сумму
Работает. Задача закрыта.
Необязательно смотреть заказ без скидки, несколько товаров, процентную и фиксированную скидки, максимальный лимит, округление, повторное применение, изменение количества, API, импорт, административную панель и старые заказы.
Мы ведь меняли только скидку. Откуда там вообще могут появиться проблемы?
Особенно удобно через пару лет
Первые месяцы ручная регрессия действительно работает неплохо. Разработчик знает проект и помнит: если меняем A, надо проверить B, C и иногда D.
Потом связи начинают размножаться:
A
├── B → F → K
├── C → G
│ └── H → L
└── D → старый импорт
├── API v1
└── админка
Но и это не проблема. Нужно просто держать схему в голове. Желательно всей командой. Желательно одинаковую.
Новичку её можно передать устно: сорок минут у доски, семь фраз «сюда лучше не лезть» и одна ссылка на переписку трёхлетней давности. Надёжнее любой документации.
Регрессионная ошибка? Проверяй старый функционал
Есть особенно неприятный класс ошибок. Новая функция работает, но перестаёт работать то, что жило без приключений последние три года. Это и есть регрессионная ошибка.
Мы слегка меняем сервис расчёта стоимости:
final class PriceCalculator
{
public function calculate(Order $order): Money
{
// новая логика расчёта
}
}
Задача из тикета проходит. Но где-то существовал старый сценарий: корпоративный клиент, специальный тариф, товар из категории X и доставка в регион Y. О нём никто не вспомнил до релиза.
После релиза вспомнит пользователь. У production вообще удивительная привычка: там постоянно запускают сценарии, которые разработчик не проверил вручную.
Поэтому перед релизом нужно проверить всё
Решение очевидно. Авторизация, регистрация, профиль, каталог, корзина, заказы, оплата, админка, API, импорт, экспорт, уведомления и права доступа. Потом ещё раз — под разными ролями.
30 минут разработки
↓
2 часа ручной проверки
↓
релиз
Зато тесты поддерживать не приходится.
Правда, довольно скоро появляется оптимизация: разработчик перестаёт проверять всё, потому что это слишком долго. Он смотрит только то, что кажется связанным, выкатывает и надеется.
Получается, тестовая система у нас всё-таки есть. Просто её набор хранится в голове, запускается руками и не умеет точно повторить вчерашний прогон.
Unit-тесты вообще бесполезны
Особенно странно тестировать маленькие классы. Есть, например, бизнес-правило:
final class DiscountCalculator
{
public function calculate(
Customer $customer,
Money $amount,
): Money {
// правила скидки
}
}
Можно за миллисекунды проверить: обычному клиенту скидка не положена, VIP получает 10%, после 100 000 — 15%, предел равен 20%, деньги округляются по согласованному правилу.
Но зачем? Каждый раз создавай пользователя, товары и заказ через интерфейс. Это займёт значительно больше времени, зато тест писать не придётся.
Чем дешевле вызвать код и чем больше вариантов поведения у него есть, тем выгоднее unit-тест. Расчёты, лимиты, переходы состояний, права и тарифы — как раз такие места: короткий прогон, точная причина падения, никакого браузера.
Integration-тесты заменим уверенностью
Unit-тест проверяет изолированное поведение. Реальная система задаёт вопрос похуже: «А эти пять частей вообще умеют разговаривать друг с другом?»
HTTP-запрос
↓
Laravel
↓
Application Service
↓
Eloquent
↓
PostgreSQL
Каждый компонент отдельно может быть прекрасен. Вместе всплывают неправильный mapping, constraint базы, забытая транзакция, listener, запрос репозитория, миграция или конфигурация контейнера.
Integration-тест нужен не затем, чтобы ещё раз доказать, что два плюс два равно четыре. Он проверяет шов: настоящая база принимает запись, событие отправляется после коммита, HTTP-ответ содержит то, что ожидает клиент.
Но браузером-то мы проверим наверняка
Для этого есть E2E: открыть сайт, авторизоваться, добавить товар, оформить заказ, оплатить и увидеть результат. Почти тот же путь проходит пользователь.
Кажется логичным покрыть браузером всё. А потом получить 300 сценариев, CI на 48 минут и семь периодически красных тестов. Pipeline перезапускают, он зеленеет, а команда уже не знает, можно ли ему верить.
E2E дорог: ему нужны приложение, база, браузер, данные, иногда внешние сервисы. Он медленнее и хуже объясняет, где именно случилась поломка. Поэтому один хороший smoke-тест на оплату может стоить десяти тестов декоративной страницы, а сотня хрупких браузерных сценариев — меньше пяти стабильных.
Задача не в том, чтобы заменить ручные клики автоматическими кликами в максимальном количестве. Нужно поймать дорогую поломку на самом дешёвом подходящем уровне.
Не поклоняйся пирамиде тестирования
Классическая пирамида предлагает много быстрых unit-тестов, меньше integration и совсем немного дорогих E2E. Как модель мышления она полезна. Как религия — уже нет.
Если приложение в основном представляет собой HTTP → CRUD → Database, десятки мокированных тестов сервисов могут давать меньше уверенности, чем несколько проверок с настоящей базой. В расчётном движке, наоборот, большая часть ценности окажется в быстрых unit-тестах.
Вопрос «какой процент тестов должен быть unit?» мало помогает. Лучше спросить: «Какую поломку я пытаюсь поймать и где это сделать дешевле?»
Coverage обязательно должен быть 100%
Coverage: 97%. Красиво. Осталось добить последние три процента тестом геттера:
public function testGetter(): void
{
$user = new User('Alex');
self::assertSame('Alex', $user->getName());
}
Число выросло. Качество системы, конечно, тоже.
Line coverage отвечает только на вопрос, выполнялась ли строка во время тестов. Есть и другие метрики — например, покрытие ветвей и функций, — но ни одна из них не знает, проверили ли мы правильное поведение. Можно иметь 95% и пропустить критический платёжный сценарий. Можно иметь 60%, но надёжно закрыть деньги, доступы и самые хрупкие контракты.
Это хороший фонарик для поиска дыр и плохой KPI. Как только команда начинает тестировать строки ради числа, метрика перестаёт измерять уверенность.
Где тест действительно окупается
Я бы сначала шёл туда, где ошибка дороже всего или где ручной повтор особенно утомителен.
Деньги и лимиты
Цены, скидки, налоги, комиссии, зарплаты, тарифы и платежи. Ошибка на рубль в одном заказе может выглядеть мелочью, пока таких заказов не станет сто тысяч.
Права доступа
Матрица «кто что может делать» быстро перестаёт помещаться в голове. Проверять каждую роль вручную после изменения policy — отличный способ однажды открыть чужие данные не тому человеку.
Сложные правила и частые поломки
Если описание начинается с «для группы A, при условии B, кроме C, но только до D», рядом просится тест. Production тоже подсказывает приоритеты: место, которое ломалось дважды, не обязано получать третий шанс.
Полезное правило для регрессионной ошибки простое:
воспроизвели ошибку тестом
↓
увидели красный результат
↓
исправили код
↓
оставили зелёный тест
Критические маршруты и страшный legacy
Регистрация, вход, оформление заказа, оплата, создание заявки — несколько стабильных smoke-сценариев защищают то, ради чего существует продукт. А перед рефакторингом старого сервиса полезны characterization tests: сначала зафиксировать текущее поведение, даже странное, и только потом наводить красоту.
Где тест может не окупиться
Тест тоже является кодом. Его придётся читать, менять и чинить. Для тривиального, временного, некритичного и редко меняющегося кода цена поддержки иногда выше риска. Если метод лишь склеивает имя и фамилию, пять тестов на него вряд ли спасут релиз:
public function format(User $user): string
{
return $user->getFirstName()
.' '.
$user->getLastName();
}
Особенно плохо окупается тест, который знает каждую внутреннюю строчку реализации, поднимает десятки моков и падает после безопасного рефакторинга. Он не защищает контракт — он запрещает двигать мебель.
Писать тест автоматически после каждого нового метода так же бессмысленно, как принципиально не писать их никогда.
Хороший тест покупает уверенность
Не coverage. Не красивый отчёт CI. И не отсутствие багов — такой гарантии никто не даст.
Тест покупает возможность изменить код и быстро узнать, сохранились ли старые договорённости. Без него после правки остаётся вопрос: «А что ещё я сломал?» С ним часть ответа приходит за минуты, одинаково сегодня и через год.
Это особенно заметно в рефакторинге. Без страховки команда защищает плохой код просто потому, что боится его трогать. С проверками большой сервис не становится хорошим автоматически, но перестаёт быть чёрным ящиком с табличкой «не будить».
Но руками всё равно придётся кликать
Автоматические проверки не заметят всё. Можно иметь идеальные unit, integration и E2E, а потом выкатить съехавшую вёрстку, неудобный UX, странный текст или редкую комбинацию действий, которой не было в сценариях.
Перед важным релизом открыть приложение и посмотреть на него глазами человека всё ещё полезно. Разница только в масштабе:
«Я вручную смотрю новую функцию» — нормальная инженерная работа.
«Я обязан прокликать всю систему, иначе понятия не имею, что мог сломать» — уже дорогой процесс, замаскированный под экономию.
Поэтому не пиши тесты
Просто перед каждым релизом вспомни все сценарии. Проверь их руками на всех ролях, со всеми типами данных и во всех состояниях. Не забудь старые API, фоновые задачи, импорт, экспорт, уведомления и пограничные значения. Потом повтори всё после следующей правки.
А когда поймаешь себя на мысли «я проверял это вчера, почему снова делаю то же самое?», остановись. Ты только что нашёл место, где автоматизация начинает окупаться.
Хороший тест — не доказательство любви разработчика к качеству. Это способ не проверять одну и ту же вещь руками тысячу раз.