О разделе

KB_6 даёт основу: как запустить PHPUnit, что такое unit и feature тесты, как мокать. Этого достаточно, чтобы написать осмысленный тест и пройти первое ревью. Однако реальные кодобазы быстро упираются в задачи, не решаемые поверхностным знанием: тесты становятся медленными, флакают, ложно зелёные, дорогими в поддержке. Этот раздел — о том, что отделяет middle-инженера от senior'а в части тестирования.

Что разбирается в разделе:
  • Точная терминология test doubles — и почему путать mock со stub'ом дорого;
  • Стратегии управления состоянием БД: транзакции, миграции, ленивые миграции, in-memory SQLite vs реальный движок;
  • Параллельное исполнение тестов и изоляция данных между процессами;
  • Детерминизм: фиксация времени, контролируемые очереди, изолированные внешние вызовы;
  • Mutation testing — как измерять качество тестов, а не их количество;
  • Какие тесты ломаться при рефакторинге не должны и почему так часто ломаются.
Пререквизиты
  • KB_6 — основы PHPUnit/Pest, базовые fakes, TDD;
  • KB_3 — Service Container и DI (без понимания DI инжектирование моков превращается в магию);
  • KB_12 — Eloquent (для DB-стратегий полезно понимать observers, transactions, factories).
Карта раздела
БлокЧто узнаешь
КонцепцииTest doubles по Мезаросу, AAA/GWT, пирамида/трофей/соты, контракт-тесты
LaravelDB-стратегии, параллельность, время, очереди, Http
КачествоMutation testing, честный coverage, snapshot, антипаттерны
ПрактикаПеревод legacy-модуля на тестируемый код шаг за шагом
Test Doubles — таксономия и применение
Назначение

«Test double» — общий зонтичный термин Жерара Мезароса для любых объектов, заменяющих реальные коллабораторы в тесте. Различие между подтипами — не педантизм: путаница mock'а со stub'ом приводит к тестам, которые ломаются при безобидном рефакторинге и не ловят настоящие баги. В отличие от популярного «всё это моки», точная таксономия даёт язык для проектирования тестов и обсуждения их в ревью.

Пять типов дублей

1. Dummy

Объект, который передаётся ради соблюдения сигнатуры, но не используется. Часто — new \stdClass() или $this->createMock(SomeInterface::class) без настройки. Если тест случайно начнёт вызывать его методы — PHPUnit выкинет "Trying to perform method call on null", что укажет на несоответствие предположению.

2. Stub

Возвращает фиксированные данные на вызовы методов. Не проверяет, что вызовы были — только отвечает на них. Использует method(...)->willReturn(...). Stub полезен, когда тестируемый код запрашивает информацию у коллаборатора, и нам важно что придёт обратно, а не что было запрошено.

3. Spy

Stub, который дополнительно записывает все вызовы. Проверка вызовов происходит после исполнения тестируемого кода (post-condition), а не как ожидание (Mockery: shouldHaveReceived). Удобно, когда нужно проверить факт вызова, но не хочется блокировать произвольные внутренние вызовы.

4. Mock

Объект с заранее заданными ожиданиями: какие методы будут вызваны, с какими аргументами, сколько раз. Проверка происходит в момент вызова или при разрушении объекта. PHPUnit: $mock->expects($this->once())->method('foo'). Mockery: $mock->shouldReceive('foo')->once(). Превращает тест в спецификацию протокола взаимодействия.

5. Fake

Рабочая (но упрощённая) реализация контракта. Хранит данные в памяти вместо БД, держит in-memory массив вместо очереди. Не пишет на диск, не делает сетевых вызовов. Laravel'овские Storage::fake(), Queue::fake(), Event::fake() — типичные fakes. Принципиально отличается от stub'а тем, что у него есть поведение: если сохранить и прочитать — вернётся то же значение.

Практика на примере: сервис рассылки

Дано: NotificationDispatcher с зависимостями — UserRepository (читает получателей), RateLimiter (ограничивает частоту), MailGateway (отправляет почту), AuditLogger (пишет аудит). Каждую зависимость подменим уместным типом дубля.

final class NotificationDispatcher
{
    public function __construct(
        private UserRepository $users,
        private RateLimiter     $limiter,
        private MailGateway     $mail,
        private AuditLogger     $audit,
    ) {}

    public function send(string $campaignId): int
    {
        $users = $this->users->subscribed();
        $sent = 0;
        foreach ($users as $user) {
            if (!$this->limiter->allow($user->id)) continue;
            $this->mail->send($user->email, $campaignId);
            $this->audit->record('sent', $user->id);
            $sent++;
        }
        return $sent;
    }
}
// Тест: «при двух подписчиках, оба прошли лимит — обе почты ушли»
public function test_sends_email_to_each_allowed_user(): void
{
    // Stub — отвечает на запрос подписчиков:
    $users = $this->createStub(UserRepository::class);
    $users->method('subscribed')->willReturn([
        new User(1, 'a@example.com'),
        new User(2, 'b@example.com'),
    ]);

    // Stub с фиксированным «да» от лимитера:
    $limiter = $this->createStub(RateLimiter::class);
    $limiter->method('allow')->willReturn(true);

    // Mock — фиксируем ожидание именно двух вызовов с конкретными аргументами:
    $mail = $this->createMock(MailGateway::class);
    $mail->expects($this->exactly(2))
         ->method('send')
         ->willReturnCallback(function (string $to, string $campaign) {
             $this->assertContains($to, ['a@example.com', 'b@example.com']);
             $this->assertSame('spring-sale', $campaign);
         });

    // Dummy — нам безразлично, что аудит-логгер делает в этом тесте:
    $audit = $this->createStub(AuditLogger::class);

    $dispatcher = new NotificationDispatcher($users, $limiter, $mail, $audit);
    $sent = $dispatcher->send('spring-sale');

    $this->assertSame(2, $sent);
}

Когда уместен fake вместо mock? Если бы AuditLogger хранил факт записи и далее использовался в проверках, fake (например, in-memory имплементация) был бы предпочтительнее: тест проверял бы итоговое состояние (state-based), а не цепочку вызовов (interaction-based). Это устойчивее к рефакторингу.

Когда какой тип
ТипПрименяется когдаЦена
DummyПараметр нужен по сигнатуре, не используетсяНулевая
StubТест проверяет реакцию на входные данныеНизкая: устойчив к рефакторингу
SpyНужно зафиксировать факт вызова без жёстких ожиданийСредняя: post-check, гибче mock'а
MockТестируется протокол взаимодействия (последовательность, количество)Высокая: ломается при безопасном рефакторинге внутренностей
FakeНужно реалистичное поведение коллаборатора без побочекСредняя: требует поддержки fake-имплементации
Особые случаи
1. Mock на каждый коллаборатор. Тест с пятью моками описывает не поведение, а внутреннее устройство класса. При любой реструктуризации (перенос вызова из метода A в метод B, объединение двух вызовов в один) тест краснеет, хотя поведение не изменилось. Это называется «over-mocking» и приводит к тому, что разработчики начинают избегать рефакторинга.
2. Mock на собственные value-objects. Если Money или Email — иммутабельный value object без зависимостей, мокать его бессмысленно: используйте реальный конструктор. Мок переусложняет тест и теряет проверку, что value-объект правильно собран.
3. Stub без strict-режима. По умолчанию stub возвращает null на любой неконфигурированный метод. Если код потребует ->getId() а stub его не настроен, получим null, который тихо просочится дальше и сломает тест в случайном месте. В PHPUnit 10+ stub'ы по умолчанию строгие — неконфигурированные методы бросают исключение. Включите аналог в более старых версиях через willThrowException.
4. Mock'ание final-класса. PHPUnit не может расширить final класс. Решения: (а) ввести интерфейс и мокать его; (б) использовать bovigo/assert или Mockery с shouldAllowMockingFinalClasses (медленнее); (в) переосмыслить, нужен ли тут double вообще — возможно, нужен fake.
5. willReturnOnConsecutiveCalls для итерации. Этот метод возвращает значения по порядку для каждого следующего вызова. Если вызов сделан N+1 раз, последний вернёт null молча. Используйте willReturnCallback с явной логикой или принципиально другой дизайн, если порядок вызовов критичен.
6. Мокаем то, чего не владеем. Мокать внешние SDK (Stripe, AWS) напрямую — путь к хрупким тестам: их API меняется, моки нет. Оборачивайте чужие SDK в собственный интерфейс (PaymentGateway) и мокайте свой контракт. Это правило «don't mock what you don't own» (Steve Freeman / Nat Pryce).
7. Подсчёт вызовов вместо проверки результата. Тест вида «метод save был вызван ровно один раз» — слабый: он не подтверждает, что данные сохранены корректно. Предпочтительнее использовать fake-репозиторий и проверять реальное состояние: $this->assertNotNull($repository->find($id)).
8. partialMock как костыль. Частичный mock (когда часть методов реальные, часть подменены) почти всегда сигнал, что класс делает слишком много. Разделите его на два — и каждый тестируйте отдельно с настоящими (или мокированными) зависимостями.
Дизайн отдельного теста
Назначение

Тест — код, который читают чаще, чем пишут: при поломке его открывают в спешке, чтобы понять, что именно сломалось. Структура теста определяет, насколько быстро это понимание достигается. Хороший тест читается как параграф: задана ситуация → выполнено действие → проверен результат. Плохой — читается как клубок настроек.

Структурные подходы

AAA — Arrange / Act / Assert

Три блока с пустой строкой между ними. Arrange создаёт окружение и входные данные. Act — одно действие, чьё поведение тестируется. Assert — проверки результата и состояния. Если act-блок занимает больше одной строки или содержит вспомогательную логику — его пора извлекать в helper-метод.

GWT — Given / When / Then

Вариант AAA из BDD: Given — контекст, When — событие, Then — ожидаемая реакция. Pest и Behat используют это естественно (it('orders should ship when paid', ...)). На уровне unit-тестов GWT и AAA взаимозаменяемы — разница в словаре, не в структуре.

Naming: methodUnderTest_state_expected

Имя теста — это первая строка сообщения о падении. Хорошее имя описывает три вещи: что тестируется, в каком состоянии, чего ожидаем. Примеры: charge_whenCardDeclined_throwsPaymentException, store_withDuplicateEmail_returns422. В Pest то же достигается через it: it('throws PaymentException when card is declined').

Сравнение: одно действие vs один assert на тест

Популярный принцип «один assert на тест» нередко уродует тест: вместо одного логичного действия — десять методов, повторяющих setup. Точнее формулировать: одно действие и столько assert'ов, сколько нужно для полной проверки исхода этого действия. Если действий два — разделите на два теста.

// Anti-pattern: act-блок выполняет два независимых действия
public function test_user_lifecycle(): void
{
    $user = User::factory()->create();
    $user->activate();             // действие 1
    $this->assertTrue($user->isActive());

    $user->block('spam');          // действие 2
    $this->assertSame('spam', $user->block_reason);
}
// Два теста, по одному действию каждый
public function test_activate_marks_user_active(): void
{
    $user = User::factory()->create();

    $user->activate();

    $this->assertTrue($user->isActive());
    $this->assertNotNull($user->activated_at);
}

public function test_block_stores_reason(): void
{
    $user = User::factory()->active()->create();

    $user->block('spam');

    $this->assertFalse($user->isActive());
    $this->assertSame('spam', $user->block_reason);
}
Особые случаи
1. setUp вместо явного Arrange. Большой setUp, готовящий весь мир, заставляет читателя теста скакать между методами. Предпочтительнее явный Arrange в теле каждого теста с helper-методами вида $user = $this->activeUser() — единственная зависимость остаётся явной.
2. Логика в assert-блоке. if ($order->total > 100) $this->assertTrue(...) — ветвление в assert означает, что тест проверяет одно поведение в одном случае и другое в другом. Это два теста, замаскированные под один. Разделите.
3. Магические данные. Использование 42, 'foo', '2020-01-01' без объяснения смысла затрудняет понимание. Дайте константам имена: const VALID_AGE = 21; или const PAID_AMOUNT_USD = 100; — даже на уровне теста.
4. Условные expects. Конструкция $mock->expects($this->atLeastOnce()) неявно говорит «не важно, сколько раз вызывали». Это нечеткое ожидание скрывает баги — разработчик может случайно увеличить количество вызовов в N раз и не заметить. Используйте точное exactly($n) или вовсе откажитесь от подсчёта.
5. Тест зависит от другого теста. PHPUnit/Pest исполняют тесты в фиксированном порядке (но запретить опираться на это нужно жёстко). Тест, требующий состояния от предыдущего, рассыпется при параллельном запуске или фильтрации. Каждый тест должен быть самостоятельным.
6. Имя test_works. Бесполезное имя — ничего не говорит о падении. Хорошее имя теста заменяет половину комментариев и описывает контракт. Если придумать имя сложно — вероятно, тест проверяет слишком много или слишком мало.
7. Перебор data provider'ов. Запихнуть 50 кейсов в data provider, не назвав их — превратить отчёт о падении в загадку. Используйте именованные ключи: 'expired card' => [...] — имя кейса попадёт в отчёт о падении.
8. Зависимость от текущего времени. assertEquals(Carbon::now(), $order->created_at) сравнивает значения по микросекундам — в реальности время чуть-чуть разное. Зафиксируйте время через Carbon::setTestNow() или сравнивайте по дате.
Архитектура тест-сюты
Назначение

Тест-сюта — это система: соотношение её слоёв определяет общую скорость, надёжность и стоимость поддержки. Слишком много медленных интеграционных тестов — CI занимает 40 минут. Слишком мало — зелёная сюта не ловит баги интеграции. Архитектурные модели (пирамида, трофей, соты) — способ обсудить эти соотношения с командой.

Три модели

Пирамида (Mike Cohn)

Классика. Основание — много быстрых unit-тестов; середина — меньше integration; вершина — единицы E2E. Работает, когда логика сосредоточена внутри сервисов и слабо зависит от рамок (фреймворк, БД). На сильно зависимых от фреймворка проектах (типичные Laravel-CRUD'ы) пирамида деформируется — integration-тестов нужно больше, потому что бизнес-логика «размазана» по модели + контроллеру + middleware.

Трофей (Kent C. Dodds)

Сверху узко (E2E), внизу узко (статический анализ + типы), посередине — широкий integration-блок. Идея: интеграционные тесты дают наилучшее соотношение «уверенность / стоимость», поскольку тестируют поведение через настоящие границы (БД, очередь). Адекватная модель для веб-проектов с богатой инфраструктурой.

Соты (Spotify)

Для микросервисов: интеграционные тесты — основная масса, unit'ов меньше (микросервис как «тонкий слой» поверх библиотек), E2E — редко и кратко. Контракт-тесты заменяют большую часть E2E. Если ваш сервис — адаптер между двумя API, эта модель уместнее пирамиды.

Контракт-тесты

Между двумя сервисами договорённость о формате запросов и ответов называется контрактом. Consumer-driven contract testing (Pact) переворачивает подход: потребитель пишет ожидаемые взаимодействия, эти ожидания передаются провайдеру, провайдер при сборке проверяет, что его реализация удовлетворяет контракту. Преимущество перед E2E: не нужно поднимать всю инфраструктуру; падение чётко локализовано в одну сторону взаимодействия.

// Псевдокод consumer-side test (Pact PHP):
$builder = new InteractionBuilder($config);
$builder
    ->given('user 42 exists')
    ->uponReceiving('a request for user 42')
    ->with(['method' => 'GET', 'path' => '/users/42'])
    ->willRespondWith([
        'status' => 200,
        'body'   => ['id' => 42, 'email' => 'a@example.com'],
    ]);

// Pact записывает контракт в pact.json и публикует в Pact Broker.
// На стороне провайдера CI поднимает API и проигрывает все контракты.
Особые случаи
1. Пирамида с гнилым основанием. 5000 unit-тестов с моками всего вокруг — формально пирамида, фактически она ничего не проверяет. Каждый тест тестирует ровно один мок-сценарий. Замените часть на integration-тесты с реальной БД и реальным контейнером — ловится больше реальных багов.
2. E2E как единственный уровень. Стартапная классика: «у нас есть Cypress, нам unit не нужны». E2E ловит только баги, проявляющиеся через UI; разваливается на каждом редизайне; диагностика падения занимает часы. E2E дополняет, а не заменяет нижние уровни.
3. Integration-тесты вместо unit'ов из-за инфраструктурной лени. Если бизнес-логику легче тестировать через HTTP-запрос (потому что лень настраивать unit-окружение), значит у вас сильная связанность с фреймворком. Это симптом, не решение. Извлекайте логику в чистые сервисы.
4. Контракт-тесты без Pact Broker. Без брокера контракты живут отдельно у каждой стороны и расходятся. Pact Broker (или Pactflow) хранит контракты централизованно и сигнализирует, когда провайдер собирается ломающим изменением. Без брокера контракт-тесты быстро превращаются в спецификацию-фантом.
5. Соты для монолита. Модель сот предполагает много мелких сервисов, у каждого — узкая ответственность. Применять её к монолиту бессмысленно — в монолите нет границ, на которых интеграция чем-то отличается от unit'а.
6. Игнорирование времени исполнения. Сюита из 3000 тестов, бегающая 25 минут на CI, делает feedback-петлю болезненной. Целевая отметка: вся сюта на pull request'е — 5 минут. Если выше — разделяйте на быстрый блок (unit) для каждого PR и полный (integration + E2E) для main.
7. Дублирование между уровнями. Если бизнес-правило тестируется и в unit, и в feature, и в E2E — вы платите за поддержку трижды, а ловите одну и ту же ошибку трижды. Каждое правило тестируется на самом дешёвом уровне, где оно полностью проверяемо.
8. Ice-cream cone (перевёрнутая пирамида). Много E2E, мало unit — самый дорогой и медленный профиль сюты. Часто возникает в legacy: «unit'ы написать невозможно, потому что всё связано». Признак фундаментальной проблемы архитектуры, а не плохой стратегии тестирования.
Базы данных в тестах: стратегии и компромиссы
Назначение

База данных — почти всегда самый медленный коллаборатор. Правильно выбранная стратегия изоляции состояния между тестами определяет, сколько секунд (или минут) занимает прогон всей сюты. Laravel предлагает несколько готовых трейтов; каждый имеет своё применение, и неправильный выбор бьёт по производительности или корректности.

Стратегии очистки БД

RefreshDatabase

Стандартный выбор. При первом тесте — migrate:fresh, далее каждый тест оборачивается в транзакцию, откатываемую в tearDown. Быстрый, корректный, требует, чтобы тестируемый код не открывал собственных транзакций верхнего уровня и не выполнял DDL (Laravel автоматически использует savepoints для вложенных транзакций, поэтому вложенные операции работают).

DatabaseTransactions

То же оборачивание в транзакцию, но без migrate:fresh перед сюитой. Подходит, если миграции запускаются отдельно (например, в CI отдельным шагом), и БД уже подготовлена. Чуть быстрее RefreshDatabase на первом тесте, идентичен далее.

DatabaseMigrations

Полный migrate:fresh перед каждым тестом. Медленный (драматически на больших схемах), но единственный корректный вариант, если тестируемый код выполняет собственный DDL (например, шардирующий код, создающий таблицы динамически), или если тестируется код, у которого BEGIN в собственной логике конфликтует с обёрткой.

LazilyRefreshDatabase (Laravel 9+)

Откладывает migrate:fresh до первого реального обращения к БД из теста. Если в тесте БД не используется (например, тест чистой логики), миграция не происходит вовсе. Особенно полезно в сюитах со смесью unit и feature тестов: чистые unit-тесты не платят за инициализацию БД.

In-memory SQLite

DB_CONNECTION=sqlite, DB_DATABASE=:memory:. БД живёт в памяти процесса, миграции мгновенные, тесты летают. Цена: SQLite не поддерживает многих фич реального MySQL/PostgreSQL (CHECK-ограничения работают по-другому, нет FULLTEXT, разные правила NULL в UNIQUE, нет array/jsonb типов). Тесты могут проходить на SQLite и падать на проде. Применимо, если код заведомо не использует специфичных фич, либо как быстрый смоук-уровень рядом с медленной но «настоящей» интеграционной сюитой.

Практика: матрица выбора
СлучайСтратегияПочему
Обычная CRUD-сюта на MySQL/PostgresRefreshDatabase + реальный движок в контейнереПоведение совпадает с продом, время приемлемое
Большая сюта с смесью unit/featureLazilyRefreshDatabaseUnit-тесты не платят за БД
Код управляет схемой динамическиDatabaseMigrationsТранзакции конфликтуют с DDL
CI с медленным Postgres, нужна максимальная скорость PRSQLite in-memory для PR, Postgres для mainДвухуровневая защита: быстрая для PR, точная для main
Параллельные тесты с paratestRefreshDatabase + per-process DBКаждый процесс работает в своей БД, нет конфликтов
Особые случаи
1. RefreshDatabase и собственные BEGIN/COMMIT в коде. Если код вызывает DB::beginTransaction() и DB::commit(), при работе под RefreshDatabase commit на верхнем уровне не делает реальный commit (мы внутри оборачивающей транзакции), а ошибки внутри откатывают только savepoint. Тесты проходят, в проде же поведение отличается. Решение — DatabaseMigrations для такого кода.
2. truncate вместо транзакций. Некоторые проекты используют ручной DB::table('users')->truncate() в tearDown. На таблицах с FK — падает. На больших таблицах — медленно. На MySQL TRUNCATE вне транзакции и не может быть откачен. Транзакционная стратегия Laravel почти всегда быстрее и корректнее.
3. Кэширование внутри теста. Если тестируемый код кеширует значения (Redis, file cache), кеш переживает tearDown и протекает между тестами. Добавьте Cache::flush() в setUp/тестовый boot, либо используйте array драйвер кеша в phpunit.xml.
4. Автоматические observed хуки моделей. Observer::created может писать в другие таблицы, отправлять уведомления, генерировать события. В тесте это создаёт неявные побочные эффекты. Либо отключайте observer'ы в тестах (User::flushEventListeners()), либо принимайте, что тест — integration.
5. seed в RefreshDatabase. Если включить protected bool $seed = true;, seed выполняется на каждом тесте. На крупных seed'ах — десятки секунд накладных расходов. Лучше выносить общий setup в фабрики или явные helper'ы и сидить только нужное для теста.
6. SQLite строгий vs нестрогий. До PHP 8.4 SQLite-драйвер Laravel автоматически нестрогий: вставка INSERT в колонку с лишним полем не падает, а тихо игнорирует поле. На проде с MySQL/Postgres — ошибка. Включите foreign_key_constraints => true и периодически прогоняйте сюту на «настоящем» движке.
7. Зависимость от порядка autoincrement. Тест проверяет, что у первого созданного пользователя id = 1. На SQLite (с reset) проходит; на Postgres c gap'ами (после неудачных вставок) — падает. Не опирайтесь на конкретные значения ID, используйте ссылки на объекты: $this->assertSame($user->id, $order->user_id).
8. Миграции в тестах vs миграции в проде. Если в проде применяете zero-downtime миграции (через pt-osc, gh-ost), а в тестах — обычные Laravel-миграции, окно расхождения возможно. Прогоняйте боевые миграции на staging БД с реальными данными как отдельный CI-шаг.
Параллельные тесты
Назначение

Тестов становится больше каждый месяц; время «зелёного PR» растёт линейно от их числа, если ничего не предпринимать. Параллельное исполнение — первое и самое дешёвое средство удержать feedback-петлю короткой. Laravel 8+ поставляет встроенную интеграцию с ParaTest через php artisan test --parallel; Pest имеет собственный параллельный режим. Главное в обоих — изоляция: процессы не должны мешать друг другу через общие БД, файлы, кеш и порты.

Как Laravel изолирует параллельные тесты

Отдельная БД на процесс

При --parallel Laravel создаёт N тестовых БД по шаблону {db_name}_test_{token}, где {token} — идентификатор процесса (1..N). Каждый процесс получает ParallelTesting::resolveDatabaseTokenUsing, который подменяет имя БД на свою копию. Миграции прогоняются для каждой БД отдельно при первом тесте процесса.

Хуки ParallelTesting::setUp*

Регистрируются в AppServiceProvider::boot или в tests/CreatesApplication.php. Доступны: setUpProcess, setUpTestCase, setUpTestDatabase, tearDownProcess. Здесь готовится shared-инфраструктура (например, отдельный Redis DB index на процесс) и убирается на выходе.

Per-process Redis / Cache / Queue

Без явной настройки два процесса будут писать в один Redis DB и затирать друг друга. В тестовой конфигурации задайте config(['cache.stores.redis.database' => $token]) в setUp процесса или используйте драйвер array для кеша/очереди в тестах — он живёт в памяти процесса и автоматически изолирован.

Практика: настройка параллельного запуска
// tests/CreatesApplication.php — настройка изоляции
use Illuminate\Support\Facades\ParallelTesting;
use Illuminate\Support\Facades\Redis;

trait CreatesApplication
{
    public function createApplication(): \Illuminate\Foundation\Application
    {
        $app = require __DIR__ . '/../bootstrap/app.php';
        $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();

        ParallelTesting::setUpProcess(function (int $token) {
            // Каждый процесс получает собственный Redis DB index.
            config(['database.redis.cache.database' => $token]);
            config(['database.redis.default.database' => $token]);
        });

        ParallelTesting::setUpTestDatabase(function (string $database, int $token) {
            // Сюда можно положить общие seed'ы, нужные каждому процессу.
        });

        ParallelTesting::tearDownProcess(function (int $token) {
            Redis::connection()->flushdb();
        });

        return $app;
    }
}

Запуск: php artisan test --parallel --processes=8 --recreate-databases. Флаг --recreate-databases пересоздаёт все шарды; на CI — обязателен (предыдущая сюита могла оставить БД в неконсистентном состоянии).

Особые случаи
1. Файловая система не изолирована. Тесты, пишущие в storage/app/exports/, конфликтуют между процессами. Используйте Storage::fake('exports') — драйвер fake создаёт уникальный временный каталог для каждого процесса.
2. Общий внешний сервис (ClickHouse, Elastic). Параллельные тесты могут гасить друг другу индексы. Решение: per-process index name (orders_test_{$token}) или один процесс на такой класс тестов через group/филтрацию.
3. Глобальные php-настройки. Изменение ini_set в тесте утекает между тестами одного процесса. При параллельном запуске это удваивается. Используйте ini_restore или явный setUp/tearDown, восстанавливающий значения.
4. Порты. Если тест поднимает HTTP-стуб на фиксированном порту, параллельные процессы будут драться за порт. Используйте 0 (любой свободный) и читайте назначенный порт обратно.
5. Параллельность ради параллельности. На крошечной сюите (50 тестов) параллельный запуск может оказаться медленнее последовательного из-за оверхеда на forks и копирование БД. Замеряйте до и после.
6. Random seed. Тест, использующий fake()->randomElement(...) без фиксированного seed, может проходить на одной машине и падать на другой. Зафиксируйте seed в setUp: fake()->seed(1234).
7. Расход памяти. Каждый параллельный процесс — полноценный PHP-процесс с приложением в памяти. 8 процессов = 8 × ~150 MB. На небольших CI-раннерах это превышает доступную RAM и приводит к swap'у, делая тесты медленнее, чем при последовательном запуске.
8. Стресс на БД. 8 процессов = 8 одновременных миграций на старте. На небольшой dev-БД это вызывает блокировки. Прогрейте БД заранее или используйте схему «один процесс инициализирует, остальные клонируют».
Время и очереди: детерминизм
Назначение

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

Инструменты контроля

Фиксация времени: Carbon::setTestNow() / travel*

В Laravel: $this->travelTo($specific), $this->travel(5)->hours(), $this->freezeTime(). Под капотом — Carbon::setTestNow(). Все вызовы now(), Carbon::now(), Date::now(), CURRENT_TIMESTAMP на стороне ORM (через $timestamps) и в Eloquent касты возвращают зафиксированное время.

Очереди: Queue::fake()

Подменяет драйвер очереди на in-memory массив. Задания не исполняются, но фиксируются: Queue::assertPushed(SendInvoice::class), Queue::assertPushedOn('mail', SendInvoice::class), Queue::assertNotPushed(...). Это идеально подходит для феичр-тестов, где важно проверить факт постановки задачи, но не её исполнение.

Синхронное исполнение: драйвер sync

Альтернатива — QUEUE_CONNECTION=sync в phpunit.xml. Задание исполняется сразу в текущем процессе. Удобно, когда нужно протестировать побочные эффекты исполнения (job записал в БД, отправил письмо), а не сам факт постановки.

События: Event::fake() и партиал-фейк

Event::fake() — все слушатели отключены, события только записываются. Event::fake([OrderPaid::class]) — фейк только для конкретного события (остальные доходят до слушателей). Полезно, когда нужно изолировать одно событие, не отключая всю шину.

HTTP: Http::fake и Http::preventStrayRequests

Http::fake([...]) подменяет ответы для указанных URL. Http::preventStrayRequests() делает тест строгим: любой реальный HTTP-вызов в тесте бросит исключение. Включите в базовом TestCase — это поймает «забытые» внешние вызовы, которые иначе ходят в реальный API на CI.

Практика: тест задачи с задержкой
// Тестируем, что подписка истечёт через 30 дней и тогда отправится напоминание.
public function test_renewal_reminder_sent_one_day_before_expiry(): void
{
    Queue::fake();

    // Замораживаем время на «сегодня в 10:00 UTC».
    $this->travelTo(now()->setTime(10, 0));

    $subscription = Subscription::factory()->create([
        'expires_at' => now()->addDays(30),
    ]);

    // Перемещаемся в момент за день до истечения.
    $this->travel(29)->days();

    (new ScheduleRenewalReminders())->__invoke();

    Queue::assertPushed(SendRenewalReminder::class,
        fn (SendRenewalReminder $job) => $job->subscriptionId === $subscription->id);
}

Тест полностью детерминирован: now() зафиксирован, очередь — in-memory, никакой реальной отправки. То же поведение можно проверить и через Queue::sync + Mail::fake(), если важна проверка финального письма, а не задачи в очереди.

Особые случаи
1. travel и БД CURRENT_TIMESTAMP. Laravel перехватывает только PHP-сторону. Если в SQL стоит DEFAULT CURRENT_TIMESTAMP, БД возьмёт реальное время. Это разъезжается с замороженным PHP-временем. Заполняйте поля явно в фабриках через now().
2. Queue::fake() и встроенные асинхронные нотификации. Notification::fake() и Mail::fake() могут конфликтовать с Queue::fake(), поскольку нотификация ставится в очередь и потом fake-очередь рапортует «pushed», а тесты пишутся через assertSent. Используйте либо один уровень fake, либо понимайте, на каком уровне будете проверять.
3. RefreshDatabase + QUEUE_CONNECTION=sync. Если job в синхронной очереди делает запись в БД и далее тест проверяет состояние через другую транзакцию — запись не видна (она внутри обёрточной транзакции). Либо используйте fake, либо настраивайте отдельную «фоновую» транзакционную стратегию.
4. Случайные числа в тесте. random_int, fake()->name(), Str::random() — источник флакающих тестов. Зафиксируйте seed (mt_srand(1234), fake()->seed(1234)) либо избегайте случайностей в тестах вовсе.
5. Carbon::setTestNow(null) в setUp следующего теста. Если setTestNow вызван и не сброшен, оно утечёт в следующий тест. Laravel'овский travelBack вызывается автоматически в tearDown — используйте его, а не голый Carbon, чтобы не забывать.
6. Таймзоны. Тест может проходить в UTC и падать на машине разработчика в Asia/Almaty: даты, рассчитываемые через локальную TZ, отличаются. Зафиксируйте date.timezone = UTC в phpunit.xml и в app.timezone.
7. Http::fake для не-await вызовов. Если код использует Http::async, а fake настроен синхронно, поведение разъезжается. Используйте Http::fakeSequence и явные ожидания.
8. Использование sleep() в тесте. Любая физическая пауза — запах. Если тест ждёт условия, выражайте это явно (retry(5, fn() => ...)) и фиксируйте время через travel.
Тестирование внешних API
Назначение

Любое взаимодействие с внешним сервисом — источник недетерминизма, нестабильности и затрат: API может быть медленным, недоступным, лимитированным по запросам или платным. Laravel'овский Http-клиент предоставляет встроенный fake, который покрывает 90% случаев; для оставшихся применяются recorded-сценарии и WireMock-подобные сервисы.

Способы изоляции

Http::fake([URL => Response])

Самый прямой способ: декларируем мок-ответ для конкретного URL или шаблона. Поддерживает wildcard'ы (api.example.com/*), последовательности ответов (Http::sequence()), функциональные обработчики (fn ($request) => ...). Все настоящие HTTP-вызовы блокируются.

Http::preventStrayRequests()

Включается в базовом TestCase. Любой запрос, не покрытый Http::fake, бросает исключение с описанием URL. Это страхует от ситуации «разработчик добавил новый внешний вызов и забыл его замокать», которая иначе бьёт по CI.

Recorded fixtures

Для сложных ответов (большой JSON, бинарный contentent) удобно сохранить настоящий ответ один раз и проигрывать его в тестах. В Laravel есть пакеты типа spatie/test-time и spatie/laravel-snapshot-tests; рукотворный вариант — Storage::disk('fixtures')->get('stripe-charge.json').

WireMock / Mockoon как отдельный процесс

Когда тестируется поведение, зависящее от тонкостей HTTP (заголовки, retry, redirect), полезен внешний mock-сервер. WireMock крутится в Docker, отвечает по своим правилам, и приложение общается с ним по обычному HTTP. Дороже в setup'е, но даёт максимальную точность.

Практика: retry, timeout, idempotency
public function test_charge_retries_on_502_and_succeeds_on_third_attempt(): void
{
    Http::preventStrayRequests();
    Http::fake([
        'api.stripe.com/*' => Http::sequence()
            ->push(['error' => 'bad_gateway'], 502)
            ->push(['error' => 'bad_gateway'], 502)
            ->push(['id' => 'ch_123', 'amount' => 1000], 200),
    ]);

    $gateway = app(StripeGateway::class);
    $result  = $gateway->charge(1000, 'USD', 'idem_42');

    $this->assertSame('ch_123', $result->id);

    Http::assertSentCount(3);
    Http::assertSent(fn (Request $req) =>
        $req->header('Idempotency-Key')[0] === 'idem_42');
}

Тест декларирует ожидаемое поведение (две неудачи и успех на третьей попытке), проверяет результат и подтверждает, что idempotency-key передан во всех попытках — это критично для финансовых операций.

Особые случаи
1. Fake без preventStrayRequests. Http::fake([]) молча пропускает реальные запросы для URL, не указанных в массиве. Один забытый URL — и тест ходит на боевой API. Всегда включайте preventStrayRequests в базовом TestCase.
2. Http::sequence исчерпался. Если код сделал больше запросов, чем настроено в sequence, fake вернёт пустой 200 OK молча (без явной настройки). Используйте whenEmpty для явного поведения по исчерпанию.
3. Мок отвечает медленнее реального API. Fake возвращает мгновенно — тест не проверяет timeout-сценарии. Если retry/timeout важны (а в платежах — почти всегда), отдельно тестируйте код таймаута через подмену клиента на тот, что выбрасывает ConnectionException.
4. Wildcard слишком широкий. Http::fake(['*' => ...]) ловит всё, включая случайные домены. Указывайте конкретный хост: 'api.stripe.com/*'.
5. Тест не проверяет тело запроса. assertSent с одним только URL не подтверждает, что мы послали правильный payload. В платежах это критично: тест может проходить, отправляя $0 вместо $1000. Проверяйте $req->data().
6. Recorded fixture не обновлён. Записанный когда-то ответ со временем устаревает (изменилась структура API). Без процесса периодической перезаписи fixture'ов тесты дают ложную уверенность. Лучше: contract tests + Pact.
7. Утечка реального API-ключа в fake-режим. Если Http::fake не закрывает все вызовы, реальный запрос уйдёт с боевым ключом. Обнулите STRIPE_SECRET в phpunit.xml и убедитесь, что код корректно реагирует на отсутствие ключа.
8. Зависимость от порядка assert'ов. Http::assertSent проходит по всем запросам в порядке отправки. Если меняется порядок (например, параллельные HTTP-вызовы через Http::pool), хрупкие проверки порядка ломаются. Используйте assertSent с предикатом, не зависящим от порядка.
Mutation testing — измеряем качество тестов
Назначение

Coverage отвечает на вопрос «исполнялась ли строка кода во время тестов» — это слабое утверждение. Mutation testing отвечает на сильнее: «обнаружит ли сюта, если этот код изменится?». Инструмент (для PHP — Infection) делает множество мелких изменений в исходниках (мутаций) и прогоняет тесты на каждой. Если все тесты проходят на мутации — значит, сюта эту строку не реально проверяет; такая мутация называется survived. Если хотя бы один тест падает — мутация killed. Отношение убитых к общему числу — mutation score.

Что Infection меняет в коде
  • Арифметика: + ↔ -, * ↔ /;
  • Сравнения: > ↔ >=, == ↔ !=, < ↔ <=;
  • Возвращаемые значения: return $x → return null, return true → return false;
  • Условия циклов: foreach ($items) → foreach ([]);
  • Логические операторы: && ↔ ||, ! убирается;
  • Удаление вызовов методов с типом возврата void.
Практика: настройка Infection
// composer require --dev infection/infection

// infection.json5
{
    "$schema": "vendor/infection/infection/resources/schema.json",
    "source": {
        "directories": ["app"],
        "excludes":    ["app/Http/Controllers", "app/Console"]
    },
    "timeout": 10,
    "logs": {
        "text":    "build/infection.log",
        "summary": "build/infection-summary.log"
    },
    "mutators": {
        "@default": true,
        "@function_signature": false
    }
}
# Прогон. --threads берёт N процессов; --min-msi — минимальный mutation score
vendor/bin/infection --threads=8 --min-msi=80 --min-covered-msi=90

Параметры --min-msi и --min-covered-msi ставят пороги: первый — общий mutation score, второй — mutation score только по покрытым строкам. На CI Infection валит сборку, если пороги не достигнуты — это превращает качество тестов в измеряемую величину, как и обычное coverage.

Типичный профиль: Infection прогоняет в 30-100 раз дольше обычной сюты (на каждой мутации — полный тест-ран). Не запускайте на каждом PR — запускайте ночью или вручную перед релизом.

Особые случаи
1. Equivalent mutations. Некоторые мутации семантически эквивалентны оригиналу (например, x < 10 и x <= 9 для целых). Они помечены как survived, хотя тест корректен. Доля equivalent — шум, не свидетельство плохих тестов; научитесь распознавать.
2. Mutation score 100%. Звучит как идеал, на практике — красный флаг over-testing. Каждый сценарий проверен с избытком; малейший рефакторинг ломает десятки тестов. Реалистичный таргет — 70-85% для важной логики, ниже для glue-кода.
3. Мутация контроллеров. Контроллер — тонкий слой; полезной логики для мутации мало. Исключайте app/Http/Controllers и аналогичные thin layers из source — иначе Infection тратит время на мутации, которые ничего не доказывают.
4. Долгий тайм-аут. На медленных integration-тестах Infection может зависать. Установите "timeout": 10 и снимите тяжёлые тесты с участка кода, тестируемого мутациями.
5. Параллельные тесты в Infection. --threads=N — параллельные мутации, не параллельные тесты. Это N процессов Infection, каждый запускает обычную последовательную сюту. Параллельные тесты внутри (--parallel) могут конфликтовать; чаще проще использовать только Infection threads.
6. Mutation score падает после рефакторинга. Это нормально и информативно: рефакторинг ввёл новую логику, которую старые тесты не покрывают. Добавьте тесты, score восстановится.
7. Бегать на каждой ветке. На большом проекте Infection занимает часы. Бегайте только на main/develop по расписанию, а на PR проверяйте только --git-diff-base — мутации только изменённых файлов.
8. Игнорирование @infection-ignore. Можно пометить участок кода как игнорируемый для Infection (комментарий @infection-ignore-all). Использование оправдано (примеры: вспомогательная статистика, нечеткая логика), но обильное применение — способ скрыть низкое качество тестов.
Coverage честно: что измеряют метрики
Назначение

Coverage — популярная, но опасно неточная метрика. Менеджмент часто требует «80% coverage», команда подгоняет цифру тестами, которые ничего не проверяют. Чтобы метрика была полезной, важно понимать: что именно она измеряет, чем отличаются её разновидности и почему 100% line coverage не равно «всё протестировано».

Виды coverage

Line coverage

Доля строк кода, исполненных хотя бы раз во время тестов. Самая распространённая и самая слабая метрика. Тест, который вызывает функцию, но не проверяет результат, даёт 100% line coverage функции.

Branch coverage

Доля ветвей условий, пройденных тестами. Для if ($a && $b) требует, чтобы и true, и false ветви были покрыты. Сильнее line, но не покрывает все комбинации short-circuit.

Path coverage

Доля уникальных путей через граф управления функции. Самая сильная из «обычных» метрик; экспоненциально дорогая в достижении. На практике замеряется редко.

MC/DC (Modified Condition / Decision Coverage)

Каждое условие в составном выражении должно независимо менять итоговое решение. Стандарт для авиационного и медицинского ПО; для веба избыточен, но концептуально полезен для критичных модулей (платежи, ауф).

Практика: настройка coverage
// phpunit.xml — включаем coverage, добавляем фильтр
<coverage>
    <include>
        <directory suffix=".php">app</directory>
    </include>
    <exclude>
        <directory>app/Console</directory>
        <file>app/Providers/RouteServiceProvider.php</file>
    </exclude>
</coverage>
# XDebug медленный, для скорости используйте PCOV:
pecl install pcov
php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html=build/coverage --coverage-clover=build/clover.xml

Coverage пригоден как индикатор «вот эта папка ни разу не тронута», не как мера качества. Сочетайте с mutation testing для адекватной картины.

Особые случаи
1. Coverage как KPI. Если разработчик премируется за процент coverage — он напишет много пустых тестов. KPI на coverage всегда дегенерирует в KPI на «количество вызовов в исходниках». Метрика должна быть индикатором, не целью.
2. Coverage Eloquent-моделей. Модели — в основном декларация (атрибуты, relations). Coverage 100% достигается тривиально, не доказывая ничего. Считайте coverage только для слоя с логикой (Services, Actions, Domain).
3. Coverage и dead code. Если есть код, не вызванный никаким тестом и при этом не используемый в проде — это dead code, а не «не покрытая ветка». Используйте инструменты типа icanhazstring/composer-unused и phpstan для поиска dead code; coverage только покажет последствия.
4. Coverage exception-веток. Тестировать обработку catch часто непропорционально дорого: нужно подменить зависимость так, чтобы она выкинула исключение. Если ошибка корректно прокидывается выше — иногда достаточно интеграционного теста на пограничном уровне.
5. Coverage абстрактных классов. Abstract-методы не имеют тела и не учитываются в line coverage. Сам абстрактный класс — неисполняемый. Это не проблема, но может вводить в заблуждение при первом анализе отчёта.
6. Xdebug vs PCOV. Xdebug снимает coverage в 10-20 раз медленнее, чем PCOV. Для CI на больших сюитах разница в полчаса каждый PR. PCOV не работает в обычных debug-сценариях — ставьте его как отдельное расширение для CI.
7. Coverage по строкам vs по выражениям. Несколько выражений на одной строке ($a = foo(); $b = bar();) считаются как одна строка. Coverage 100% не означает, что оба выражения исполнены.
8. Кеширование coverage между тестами. Если в CI кешируется отчёт coverage, изменения тестов могут не отразиться в финальном проценте. Сбрасывайте кеш coverage при каждом запуске.
Snapshot-тесты
Назначение

Snapshot-тестирование — запись «снимка» вывода (JSON, HTML, текст) при первом прогоне и сравнение с ним на последующих. Цель — быстро ловить непредвиденные изменения в выводе сложной структуры (рендеринг шаблона, сериализация модели в JSON, ответ API). Используется парами: snapshot фиксирует факт, code review подтверждает легитимность обновлённого snapshot'а.

Когда snapshot подходит и когда нет

Подходит

  • API-ответы с большой стабильной структурой (JSON Resource);
  • Рендеринг HTML-писем;
  • Сериализация сложных value-объектов в текст;
  • Конфигурационные дампы.

Не подходит

  • Бизнес-логика с конкретными правилами (snapshot не объясняет, что именно правильно);
  • Выход с временными метками или случайными ID без нормализации;
  • Очень нестабильный вывод (UI с динамической компоновкой);
  • Когда «обновить snapshot» становится автоматическим жестом — теряется смысл фиксации.
Практика: snapshot API-ответа
// composer require --dev spatie/phpunit-snapshot-assertions

use Spatie\Snapshots\MatchesSnapshots;

final class ProductApiTest extends TestCase
{
    use MatchesSnapshots;

    public function test_product_show_response_shape(): void
    {
        $product = Product::factory()->create([
            'id'         => 42,
            'name'       => 'Test SKU',
            'created_at' => '2026-01-01 00:00:00',
        ]);

        $response = $this->getJson("/api/products/{$product->id}");

        $this->assertMatchesJsonSnapshot($response->json());
    }
}

Первый прогон создаёт файл __snapshots__/ProductApiTest__test_product_show_response_shape.json. Последующие сравнивают — любое отклонение валит тест. Если изменение легитимно (новое поле в ответе) — обновить snapshot командой: vendor/bin/phpunit -d --update-snapshots и зафиксировать в коммите.

Особые случаи
1. Snapshot с временными метками. Если ответ содержит created_at от now() — snapshot будет валиться при каждом прогоне. Нормализуйте через $this->travelTo(specific) или замените временные значения placeholder'ом перед assert.
2. Бездумное «update». Когда «обновить snapshot» становится привычкой при каждом красном тесте, snapshot перестаёт что-либо защищать. Обновление snapshot'а — обязательная позиция для code review.
3. Размер snapshot'а. Snapshot на 800 строк — нечитаемый. Дробите на несколько меньших, проверяющих отдельные части ответа.
4. Snapshot не на JSON. Текстовые snapshot'ы (например, HTML письма) ловят пробелы, переносы строк. Изменение шаблона из-за форматирования рушит snapshot, хотя суть не изменилась. Нормализуйте whitespace перед сравнением.
5. Snapshot для бизнес-правила. Snapshot {discount: 15} не объясняет, почему скидка 15. Бизнес-правила тестируются явными assert'ами: $this->assertSame(15, $price->discount(), 'New customer 15% rule').
6. Snapshot в локали. Если ответ зависит от App::setLocale, snapshot, снятый в en, не пройдёт под ru. Фиксируйте локаль в setUp.
7. Snapshot и factories с случайными значениями. Если Product::factory() создаёт случайное имя, snapshot будет уникальным каждый прогон. Указывайте детерминированные значения для каждого тестируемого поля.
8. Snapshot вместо contract test. Snapshot для публичного API проверяет совместимость с самим собой. Если внешний потребитель ожидает определённую структуру, snapshot не защитит от изменений в его сторону. Используйте OpenAPI-валидацию или contract tests.
Практика: миграция legacy-модуля на тестируемый код
Постановка

Дано: модуль InvoiceGenerator, написанный одним статическим методом, который читает заказ из БД, считает налоги по сложной таблице, генерирует PDF, отправляет письмо, пишет аудит-лог. Покрыто двумя feature-тестами, дающими 22% line coverage. Любая правка ломает один из них непредсказуемо. Задача — пошагово привести к тестируемому виду.

// Исходник: всё в одном методе, ничего не тестируется отдельно.
final class InvoiceGenerator
{
    public static function generate(int $orderId): string
    {
        $order = DB::table('orders')->find($orderId);
        $tax = 0;
        if ($order->country === 'KZ') $tax = $order->total * 0.12;
        elseif ($order->country === 'DE') $tax = $order->total * 0.19;
        // ... ещё 18 стран ...

        $pdf = (new Dompdf())->loadHtml(view('invoices.show', compact('order', 'tax'))->render());
        $pdf->render();
        $path = storage_path("invoices/{$orderId}.pdf");
        file_put_contents($path, $pdf->output());

        Mail::to($order->email)->send(new InvoiceMail($path));
        DB::table('audit')->insert(['action' => 'invoice', 'order_id' => $orderId]);

        return $path;
    }
}
Шаги рефакторинга

Шаг 1: characterization test

До любого рефакторинга — покрываем существующее поведение «как есть». Это characterization test: цель — не доказать правильность, а зафиксировать текущее поведение, чтобы рефакторинг ничего не сломал. Пишем feature-тест на InvoiceGenerator::generate(...) для 3-4 типичных стран и проверяем выходной PDF (через хеш) и факт письма.

Шаг 2: извлечь расчёт налога

Налоговая таблица — чистая функция: страна и сумма → налог. Извлекаем в отдельный класс TaxCalculator с одним публичным методом calculate(string $country, int $totalMinor): int. Пишем unit-тесты на каждую страну. Этот блок тестируется без БД, моков, файлов — быстро и точно.

Шаг 3: ввести контракты на инфраструктуру

PDF-генерация, отправка письма, аудит-лог — каждый получает контракт: PdfRenderer, InvoiceMailer, AuditLogger. InvoiceGenerator принимает их в конструктор. Static-метод превращается в обычный, регистрируется в контейнере. Существующие feature-тесты остаются зелёными (DI прозрачен для них).

Шаг 4: написать unit-тесты на оркестрацию

Теперь InvoiceGenerator тестируется с моками своих коллабораторов: TaxCalculator вернёт фиксированное значение (stub), PdfRenderer подменён fake'ом, который пишет в memory, InvoiceMailer — mock с ожиданием send. Каждый тест проверяет конкретное взаимодействие.

Шаг 5: удалить «лишние» feature-тесты

Часть characterization-тестов из шага 1 теперь дублирует unit-тесты с шага 2-4. Удаляем дублирующие, оставляем 1-2 широких feature-теста как «smoke»: проверка, что вся цепочка собирается и работает на реальных коллабораторах. Сюта становится быстрее, тесты точечнее.

Шаг 6: mutation testing

Запускаем Infection на app/Invoices. Survived мутации указывают на оставшиеся «дыры»: например, $totalMinor * 0.12 ↔ $totalMinor + 0.12 — если survived, значит наш тест проверяет «вернуть какое-то число», а не «правильный налог». Добавляем точечные assert'ы.

Результат
  • До: 2 медленных feature-теста, 22% line coverage, любая правка — рулетка;
  • После: 24 unit-теста (мгновенные), 3 feature-теста (smoke), 86% line coverage, 78% mutation score;
  • Время рефакторинга — ~4-6 часов; экономия времени поддержки — десятки часов в год.
Сводные подводные камни тестирования
Топ ошибок в реальных сюитах
1. Тестирование реализации, а не поведения. Тест, опирающийся на конкретные приватные методы или последовательность вызовов внутри класса, ломается при безопасном рефакторинге. Тестируйте через публичный API и результат.
2. Флакающие тесты в режиме «retry до зелёного». CI с настройкой «прогнать упавший тест 3 раза» прячет реальные баги. Флак — это всегда сигнал недетерминизма (время, порядок, race), который проявится в проде. Чините, не маскируйте.
3. Тесты, повторяющие логику. Если в тесте вычисляется ожидаемое значение той же формулой, что в продукте — тест проверяет, что формула вернёт сама себя. Жёстко прописывайте ожидаемые значения, не вычисляйте.
4. assertTrue(condition) вместо специфичных assert'ов. Сообщение «expected true, got false» бесполезно. Используйте assertSame, assertEquals, assertCount, assertDatabaseHas — они дают понятное диагностическое сообщение при падении.
5. Скрытые зависимости через глобальное состояние. Тест случайно опирается на конфиг, переменную окружения, кэш, оставленные предыдущим тестом. На CI с другим порядком — падает. Делайте тесты гермитичными.
6. Огромные фабрики «на все случаи». Order::factory() создаёт заказ с 20 связанными моделями «на всякий случай». 80% тестов используют только заказ, но получают целое дерево. Делайте минимальные фабрики и наращивайте через states (->withPayments(), ->withItems()).
7. Тест зависит от значения по умолчанию из .env. Тест проходит локально (где APP_DEBUG=true) и падает в CI (где false). Зафиксируйте критичные настройки в phpunit.xml — они переопределят окружение.
8. Параллельный запуск без проверки. Сюита проходит последовательно и падает параллельно — есть скрытые зависимости через FS, Redis, БД. Лечится не отключением --parallel, а изоляцией состояний.
9. Логика в setUp, отличающаяся между тестами. Большой setUp с ветвлениями по типу теста — запах. Каждый тест должен явно объявлять свой Arrange; общее — через помощники, не через ветвящийся setUp.
10. Тесты только на happy path. Сюта проверяет 5 «правильных» сценариев и игнорирует ошибки, граничные значения, конкуренцию. На код вида if ($x > 0) должно быть как минимум 3 теста: меньше, ровно, больше.
11. Coverage без mutation testing. 90% line coverage и 30% mutation score — типичная картина: тесты «трогают» код, но не проверяют его. Сочетайте метрики.
12. E2E как способ покрыть всё. Selenium-тест на «оформление заказа» проходит, и команда считает оформление покрытым. На самом деле сценарий проверен один; остальные 19 граничных случаев не покрыты. E2E дополняет, не заменяет.
Вопросы для самопроверки (middle / senior)
Test doubles и дизайн

1. Чем mock отличается от stub'а?

Stub возвращает заранее настроенные значения на вызовы — используется, чтобы подать тестируемому коду нужные входные данные. Mock заранее объявляет ожидания (какие методы будут вызваны, с какими аргументами, сколько раз) — используется, чтобы проверить протокол взаимодействия. Тесты со stub'ами устойчивее к рефакторингу; тесты с моками строже, но хрупче.

2. Когда уместен fake вместо mock'а?

Когда нужно реалистичное поведение коллаборатора (in-memory репозиторий, который сохраняет и читает данные), но без побочек реальной реализации. Fake позволяет писать state-based тесты: проверять итоговое состояние, а не цепочку вызовов. Это устойчивее к изменениям внутренней реализации — пока контракт fake'а соответствует продакшен-реализации.

3. Что такое over-mocking и чем он опасен?

Laravel: БД и параллельность

4. Когда выбрать DatabaseMigrations вместо RefreshDatabase?

Когда тестируемый код выполняет собственный DDL (например, динамически создаёт таблицы), либо когда код управляет верхнеуровневыми транзакциями (DB::beginTransaction/commit), которые конфликтуют с обёртывающей транзакцией от RefreshDatabase. В остальных случаях RefreshDatabase предпочтителен из-за скорости.

5. Как Laravel изолирует БД при параллельных тестах?

При --parallel Laravel создаёт N копий тестовой БД с суффиксом _test_{token}, где {token} — идентификатор процесса. Каждый процесс получает свою копию через ParallelTesting::resolveDatabaseTokenUsing. Миграции прогоняются для каждой копии независимо. Изоляция Redis, кеша, файловой системы делается через хуки ParallelTesting::setUpProcess.

6. Почему SQLite in-memory может давать ложно зелёную сюту?

SQLite поддерживает не все фичи реального MySQL/Postgres: иначе работают CHECK-ограничения, UNIQUE с NULL, FULLTEXT отсутствует, нет array/jsonb типов, autoincrement начинается с нуля. Тест, проходящий на SQLite, может падать на проде. Поэтому SQLite уместен как «быстрый смоук», но реальная сюта должна периодически прогоняться на боевом движке.

Детерминизм

7. Как зафиксировать время в тесте и что это не покроет?

$this->travelTo($specific) или Carbon::setTestNow() подменяют PHP-уровень: now(), Carbon::now(), Eloquent timestamps через updated_at. Не покрывает: SQL DEFAULT CURRENT_TIMESTAMP — БД берёт реальное время. Решение — заполнять временные поля явно через now() в фабриках или коде.

8. В чём разница между Queue::fake() и QUEUE_CONNECTION=sync?

Queue::fake() — задания регистрируются, но не исполняются; тест проверяет факт постановки. sync — задание исполняется немедленно в текущем процессе; тест проверяет побочные эффекты исполнения. Выбор зависит от уровня изоляции: fake для тестов на бизнес-логику с очередью, sync — для интеграционного тестирования всей цепочки.

9. Что делает Http::preventStrayRequests() и зачем включать в базовом TestCase?

Любой HTTP-запрос в тесте, не покрытый Http::fake, бросит исключение. Без этого «забытый» внешний вызов уйдёт в реальный API на CI — медленно, ненадёжно, иногда дорого. Включение в базовом TestCase делает строгий режим по умолчанию для всей сюты.

Качество тестов

10. Чем mutation testing полезнее coverage?

Coverage показывает, исполнялся ли код во время тестов — это слабая метрика, легко обманывается тестами без assert'ов. Mutation testing меняет код мелкими шагами и проверяет, ловит ли это сюта — даёт прямую метрику «насколько тесты реально проверяют поведение». 100% coverage и 30% mutation score — типичная картина «есть тесты, проверяющие мало».

11. Когда snapshot-тестирование уместно, а когда нет?

Уместно: API-ответы с большой стабильной структурой, HTML-письма, сериализация сложных value-объектов — всё, что не нужно проверять побитово, а важна стабильность вывода. Не уместно: бизнес-правила, где важно почему результат именно такой; нестабильный вывод (UI, временные метки без нормализации); ситуации, где «обновить snapshot» превращается в рефлекс.

12. Объясните разницу между line coverage и branch coverage.

13. Что такое characterization test и когда он применяется?

Тест, фиксирующий текущее поведение системы «как есть», без суждения о правильности. Применяется перед рефакторингом legacy-кода: покрываем существующее поведение, потом меняем структуру; если тесты падают — значит поведение изменилось, и нужно либо вернуть, либо подтвердить осознанное изменение. Термин из книги Michael Feathers «Working Effectively with Legacy Code».

14. Что такое ice-cream cone в тестировании?

Перевёрнутая пирамида тестов: много медленных E2E, мало быстрых unit'ов. Симптом архитектуры с сильной связанностью — «unit-тесты невозможно написать, потому что всё связано со всем». Бьёт по скорости feedback'а и стоимости поддержки. Лечится не дополнительными E2E, а рефакторингом архитектуры в сторону извлечения тестируемых модулей.

15. Как тестировать код с retry и timeout?

Retry: Http::fake с sequence — первая попытка возвращает 5xx, вторая успех. Тест проверяет финальный результат и количество попыток через Http::assertSentCount(2). Timeout: подменить HTTP-клиента на тот, который выбрасывает ConnectionException, и проверить, что код корректно реагирует (логирует, возвращает fallback). Тестирование реальных timeout'ов через ожидание времени — антипаттерн; всегда подменяйте источник ошибки явно.