История, повторяющаяся в тысячах команд: проект закрыт стопроцентным юнит-покрытием, каждый метод обложен тестом с подменами, зелёный прогон висит в конвейере, а релиз всё равно падает на первом же обращении к базе. Тесты проверяли всё, только не то, что реально происходит в приложении. Разберём, как устроены PHPUnit и Pest, где моки начинают лгать, почему интеграционные тесты с настоящей базой дают больше доверия за те же деньги, и как выстроить баланс, при котором набор остаётся быстрым, а покрытие - честным.

Как устроен PHPUnit и почему он остаётся стандартом PHP

PHPUnit живёт с 2004 года и давно стал инфраструктурой по умолчанию: от него наследуют тестовые среды фреймворков, его понимают все IDE и конвейеры, его ассерты знают наизусть даже те, кто пишет на других языках. Тест - класс, унаследованный от TestCase, сценарий - метод, проверка - цепочка ассертов, окружение - пара хуков до и после каждого теста.

final class OrderTest extends TestCase
{
    public function testTotalWithDiscount(): void
    {
        $order = new Order(1000.0);

        self::assertSame(900.0, $order->totalWithDiscount(0.1));
    }
}

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

Поставщики данных размножают один сценарий на таблицу примеров, и с десятой версии описываются статическими методами:

#[DataProvider('provideTotals')]
public function testTotals(float $price, float $expected): void
{
    self::assertSame($expected, (new Order($price))->totalWithDiscount(0.1));
}

public static function provideTotals(): array
{
    return [
        'full price' => [1000.0, 900.0],
        'minimum' => [200.0, 180.0],
    ];
}

Конструктор подмен при этом остаётся честным инструментом для внешних границ, пока ожидания читаются сверху вниз как сценарий: один вызов, с такой суммой, с таким ответом.

$gateway = $this->createMock(PaymentGateway::class);
$gateway->expects($this->once())
    ->method('charge')
    ->with(1990)
    ->willReturn(new Receipt('ok'))
;

Проблема начинается там, где ожидания описывают внутренности собственных классов.

Pest как надстройка над PHPUnit и что она меняет в коде тестов

Pest не заменяет движок, а надевает на него другой синтаксис: тесты пишутся функциями без классов, проверки собираются в цепочки ожиданий, а наборы данных заменяют поставщиков одной строкой. Под капотом тот же PHPUnit, поэтому конвейер, покрытие и IDE работают без переделок.

test('total with discount', function () {
    $order = new Order(1000.0);

    expect($order->totalWithDiscount(0.1))->toBe(900.0);
});
test('discount math', function (float $price, float $expected) {
    expect((new Order($price))->totalWithDiscount(0.1))->toBe($expected);
})->with([
    [1000.0, 900.0],
    [200.0, 180.0],
]);

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

Архитектурные проверки выглядят как тесты над картой классов: контроллерам запрещено тянуть модели напрямую, сущностям запрещено знать про контейнер, и нарушение падает как обычный провал набора. Для команд с выраженными слоями это дешевле споров на ревью. Разница в отказоустойчивости при этом минимальна: отчёты, фильтры по группам и подсчёт покрытия порождаются одним движком, миграция между синтаксисами механическая, и ни одна практика из этой статьи не зависит от выбора.

Почему погоня за стопроцентным покрытием юнит тестами с моками лжёт

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

$repo = $this->createMock(OrderRepository::class);
$repo->method('findPaid')->willReturn([new Order(100.0)]);

self::assertSame([100.0], $service->paidTotals($repo));

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

Погоня за сотней процентов покрытие усугубляет: последние десять процентов кода добиваются тестами на геттеры, сеттеры и тривиальные конструкции, которые ловят только опечатки, зато дают красивую цифру на дашборде. Цифра растёт, доверие падает.

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

Интеграционные тесты с настоящей базой как основа доверия

Интеграционный тест честнее по построению: он запускает настоящий код против настоящей базы и проверяет результат, видимый пользователю. Ключ к скорости - транзакция на тест: перед сценарием открывается транзакция, данные вставляются, ассерты проверяются, в финале транзакция откатывается, и следующий тест видит чистую базу без пересоздания схемы.

final class CheckoutIntegrationTest extends TestCase
{
    use RefreshDatabase;

    public function testPaidOrdersAppearInReport(): void
    {
        Order::factory()->create(['status' => 'paid', 'total' => 500]);
        Order::factory()->create(['status' => 'new']);

        self::assertSame(500.0, (new ReportBuilder())->paidTotal());
    }
}

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

Отдельная вилка - тестовая база: встроенная в память SQLite быстра, но отличается диалектом от продакшена, и часть SQL просто не воспроизводит. Практика зрелых команд сходится: тестовая база той же версии, что продакшен, пусть и медленнее, потому что тест на другом диалекте проверяет другой продукт.

Данные для сценариев собираются фабриками: тест описывает только важное для сценария, остальное достраивается значениями по умолчанию, и правка схемы не ломает сотни тестов сразу. Хозяйственная выплата считается минутами конвейера: тысяча тестов с транзакционным откатом на четырёх ядрах укладывается в минуты, с пересозданием схемы то же количество считается десятками минут.

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

Что честно мокать и что мокать запрещено

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

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

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

Время подменяется интерфейсом часов, а не функцией даты внутри кода:

final class FrozenClock implements Clock
{
    public function __construct(private DateTimeImmutable $now) {}

    public function now(): DateTimeImmutable
    {
        return $this->now;
    }
}

$clock = new FrozenClock(new DateTimeImmutable('2026-01-01 12:00'));

self::assertTrue((new Subscription($clock))->expiredBy('2026-02-01'));

Тест перестаёт зависеть от момента запуска, а сценарии вроде истечения подписки перестают быть лотереей.

Скорость набора и организация конвейера тестов

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

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

Отдельная гигиена - неустойчивые тесты: упавший без причины раз в неделю прогон подтачивает доверие сильнее, чем отсутствие теста. Политика проста: неустойчивый тест либо фиксируется немедленно, либо помечается и выпадает из прогона, но никогда не живёт в статусе упал-перезапусти.

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

Правила баланса юнит и интеграционных тестов в PHP проекте

Свод правил, за который не стыдно на ревью:

  1. тесты покрывают поведение, заметное пользователю, а не строки кода;
  2. интеграционные тесты закрывают критичные сценарии: оплата, регистрация, отчёты;
  3. юнит-тесты остаются чистой математике: расчёты, валидаторы, нормализация;
  4. подмена живёт только на внешних границах: шлюзы, время, почта;
  5. база для тестов той же версии, что в продакшене, диалект важнее секунды;
  6. откат транзакции вместо пересоздания схемы в каждом интеграционном тесте;
  7. покрытие - метрика дыма, а не цель спринта.

Баланс не сводится к пирамиде из учебников: в приложениях, где логика тонкая, а связей с базой много, интеграционных тестов законно больше юнитных. Инструмент при этом безразличен: PHPUnit честен и скучен, Pest быстр и разговорчив, и оба питаются одной дисциплиной.

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