Тестирование — продвинутый разбор
Что отличает зрелую тест-сюту от набора зелёных галочек: таксономия дублей, базовые стратегии для БД, параллельное исполнение, детерминизм времени и очередей, mutation testing, контракт vs реализация. Раздел дополняет KB_6 (основы PHPUnit/Pest), не повторяет его.
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, пирамида/трофей/соты, контракт-тесты |
| Laravel | DB-стратегии, параллельность, время, очереди, Http |
| Качество | Mutation testing, честный coverage, snapshot, антипаттерны |
| Практика | Перевод legacy-модуля на тестируемый код шаг за шагом |
«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-имплементации |
Money или Email — иммутабельный value object без зависимостей, мокать его бессмысленно: используйте реальный конструктор. Мок переусложняет тест и теряет проверку, что value-объект правильно собран.
null на любой неконфигурированный метод. Если код потребует ->getId() а stub его не настроен, получим null, который тихо просочится дальше и сломает тест в случайном месте. В PHPUnit 10+ stub'ы по умолчанию строгие — неконфигурированные методы бросают исключение. Включите аналог в более старых версиях через willThrowException.
final класс. Решения: (а) ввести интерфейс и мокать его; (б) использовать bovigo/assert или Mockery с shouldAllowMockingFinalClasses (медленнее); (в) переосмыслить, нужен ли тут double вообще — возможно, нужен fake.
willReturnOnConsecutiveCalls для итерации. Этот метод возвращает значения по порядку для каждого следующего вызова. Если вызов сделан N+1 раз, последний вернёт null молча. Используйте willReturnCallback с явной логикой или принципиально другой дизайн, если порядок вызовов критичен.
PaymentGateway) и мокайте свой контракт. Это правило «don't mock what you don't own» (Steve Freeman / Nat Pryce).
save был вызван ровно один раз» — слабый: он не подтверждает, что данные сохранены корректно. Предпочтительнее использовать fake-репозиторий и проверять реальное состояние: $this->assertNotNull($repository->find($id)).
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').
Популярный принцип «один 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);
}
setUp вместо явного Arrange. Большой setUp, готовящий весь мир, заставляет читателя теста скакать между методами. Предпочтительнее явный Arrange в теле каждого теста с helper-методами вида $user = $this->activeUser() — единственная зависимость остаётся явной.
if ($order->total > 100) $this->assertTrue(...) — ветвление в assert означает, что тест проверяет одно поведение в одном случае и другое в другом. Это два теста, замаскированные под один. Разделите.
42, 'foo', '2020-01-01' без объяснения смысла затрудняет понимание. Дайте константам имена: const VALID_AGE = 21; или const PAID_AMOUNT_USD = 100; — даже на уровне теста.
$mock->expects($this->atLeastOnce()) неявно говорит «не важно, сколько раз вызывали». Это нечеткое ожидание скрывает баги — разработчик может случайно увеличить количество вызовов в N раз и не заметить. Используйте точное exactly($n) или вовсе откажитесь от подсчёта.
test_works. Бесполезное имя — ничего не говорит о падении. Хорошее имя теста заменяет половину комментариев и описывает контракт. Если придумать имя сложно — вероятно, тест проверяет слишком много или слишком мало.
'expired card' => [...] — имя кейса попадёт в отчёт о падении.
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 и проигрывает все контракты.
База данных — почти всегда самый медленный коллаборатор. Правильно выбранная стратегия изоляции состояния между тестами определяет, сколько секунд (или минут) занимает прогон всей сюты. 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/Postgres | RefreshDatabase + реальный движок в контейнере | Поведение совпадает с продом, время приемлемое |
| Большая сюта с смесью unit/feature | LazilyRefreshDatabase | Unit-тесты не платят за БД |
| Код управляет схемой динамически | DatabaseMigrations | Транзакции конфликтуют с DDL |
| CI с медленным Postgres, нужна максимальная скорость PR | SQLite in-memory для PR, Postgres для main | Двухуровневая защита: быстрая для PR, точная для main |
Параллельные тесты с paratest | RefreshDatabase + per-process DB | Каждый процесс работает в своей БД, нет конфликтов |
RefreshDatabase и собственные BEGIN/COMMIT в коде. Если код вызывает DB::beginTransaction() и DB::commit(), при работе под RefreshDatabase commit на верхнем уровне не делает реальный commit (мы внутри оборачивающей транзакции), а ошибки внутри откатывают только savepoint. Тесты проходят, в проде же поведение отличается. Решение — DatabaseMigrations для такого кода.
truncate вместо транзакций. Некоторые проекты используют ручной DB::table('users')->truncate() в tearDown. На таблицах с FK — падает. На больших таблицах — медленно. На MySQL TRUNCATE вне транзакции и не может быть откачен. Транзакционная стратегия Laravel почти всегда быстрее и корректнее.
tearDown и протекает между тестами. Добавьте Cache::flush() в setUp/тестовый boot, либо используйте array драйвер кеша в phpunit.xml.
observed хуки моделей. Observer::created может писать в другие таблицы, отправлять уведомления, генерировать события. В тесте это создаёт неявные побочные эффекты. Либо отключайте observer'ы в тестах (User::flushEventListeners()), либо принимайте, что тест — integration.
seed в RefreshDatabase. Если включить protected bool $seed = true;, seed выполняется на каждом тесте. На крупных seed'ах — десятки секунд накладных расходов. Лучше выносить общий setup в фабрики или явные helper'ы и сидить только нужное для теста.
INSERT в колонку с лишним полем не падает, а тихо игнорирует поле. На проде с MySQL/Postgres — ошибка. Включите foreign_key_constraints => true и периодически прогоняйте сюту на «настоящем» движке.
id = 1. На SQLite (с reset) проходит; на Postgres c gap'ами (после неудачных вставок) — падает. Не опирайтесь на конкретные значения ID, используйте ссылки на объекты: $this->assertSame($user->id, $order->user_id).
pt-osc, gh-ost), а в тестах — обычные Laravel-миграции, окно расхождения возможно. Прогоняйте боевые миграции на staging БД с реальными данными как отдельный CI-шаг.
Тестов становится больше каждый месяц; время «зелёного PR» растёт линейно от их числа, если ничего не предпринимать. Параллельное исполнение — первое и самое дешёвое средство удержать feedback-петлю короткой. Laravel 8+ поставляет встроенную интеграцию с ParaTest через php artisan test --parallel; Pest имеет собственный параллельный режим. Главное в обоих — изоляция: процессы не должны мешать друг другу через общие БД, файлы, кеш и порты.
Отдельная БД на процесс
При --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 — обязателен (предыдущая сюита могла оставить БД в неконсистентном состоянии).
storage/app/exports/, конфликтуют между процессами. Используйте Storage::fake('exports') — драйвер fake создаёт уникальный временный каталог для каждого процесса.
orders_test_{$token}) или один процесс на такой класс тестов через group/филтрацию.
ini_set в тесте утекает между тестами одного процесса. При параллельном запуске это удваивается. Используйте ini_restore или явный setUp/tearDown, восстанавливающий значения.
0 (любой свободный) и читайте назначенный порт обратно.
fake()->randomElement(...) без фиксированного seed, может проходить на одной машине и падать на другой. Зафиксируйте seed в setUp: fake()->seed(1234).
Источники недетерминизма ломают тесты предсказуемо — не сегодня, так через неделю. Главные виновники: текущее время, случайные числа, асинхронные задачи. Каждый из них — контролируемая сущность в 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(), если важна проверка финального письма, а не задачи в очереди.
travel и БД CURRENT_TIMESTAMP. Laravel перехватывает только PHP-сторону. Если в SQL стоит DEFAULT CURRENT_TIMESTAMP, БД возьмёт реальное время. Это разъезжается с замороженным PHP-временем. Заполняйте поля явно в фабриках через now().
Queue::fake() и встроенные асинхронные нотификации. Notification::fake() и Mail::fake() могут конфликтовать с Queue::fake(), поскольку нотификация ставится в очередь и потом fake-очередь рапортует «pushed», а тесты пишутся через assertSent. Используйте либо один уровень fake, либо понимайте, на каком уровне будете проверять.
RefreshDatabase + QUEUE_CONNECTION=sync. Если job в синхронной очереди делает запись в БД и далее тест проверяет состояние через другую транзакцию — запись не видна (она внутри обёрточной транзакции). Либо используйте fake, либо настраивайте отдельную «фоновую» транзакционную стратегию.
random_int, fake()->name(), Str::random() — источник флакающих тестов. Зафиксируйте seed (mt_srand(1234), fake()->seed(1234)) либо избегайте случайностей в тестах вовсе.
Carbon::setTestNow(null) в setUp следующего теста. Если setTestNow вызван и не сброшен, оно утечёт в следующий тест. Laravel'овский travelBack вызывается автоматически в tearDown — используйте его, а не голый Carbon, чтобы не забывать.
date.timezone = UTC в phpunit.xml и в app.timezone.
Http::fake для не-await вызовов. Если код использует Http::async, а fake настроен синхронно, поведение разъезжается. Используйте Http::fakeSequence и явные ожидания.
sleep() в тесте. Любая физическая пауза — запах. Если тест ждёт условия, выражайте это явно (retry(5, fn() => ...)) и фиксируйте время через travel.
Любое взаимодействие с внешним сервисом — источник недетерминизма, нестабильности и затрат: 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'е, но даёт максимальную точность.
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 передан во всех попытках — это критично для финансовых операций.
preventStrayRequests. Http::fake([]) молча пропускает реальные запросы для URL, не указанных в массиве. Один забытый URL — и тест ходит на боевой API. Всегда включайте preventStrayRequests в базовом TestCase.
Http::sequence исчерпался. Если код сделал больше запросов, чем настроено в sequence, fake вернёт пустой 200 OK молча (без явной настройки). Используйте whenEmpty для явного поведения по исчерпанию.
ConnectionException.
Http::fake(['*' => ...]) ловит всё, включая случайные домены. Указывайте конкретный хост: 'api.stripe.com/*'.
assertSent с одним только URL не подтверждает, что мы послали правильный payload. В платежах это критично: тест может проходить, отправляя $0 вместо $1000. Проверяйте $req->data().
Http::fake не закрывает все вызовы, реальный запрос уйдёт с боевым ключом. Обнулите STRIPE_SECRET в phpunit.xml и убедитесь, что код корректно реагирует на отсутствие ключа.
Http::assertSent проходит по всем запросам в порядке отправки. Если меняется порядок (например, параллельные HTTP-вызовы через Http::pool), хрупкие проверки порядка ломаются. Используйте assertSent с предикатом, не зависящим от порядка.
Coverage отвечает на вопрос «исполнялась ли строка кода во время тестов» — это слабое утверждение. Mutation testing отвечает на сильнее: «обнаружит ли сюта, если этот код изменится?». Инструмент (для PHP — Infection) делает множество мелких изменений в исходниках (мутаций) и прогоняет тесты на каждой. Если все тесты проходят на мутации — значит, сюта эту строку не реально проверяет; такая мутация называется survived. Если хотя бы один тест падает — мутация killed. Отношение убитых к общему числу — mutation score.
- Арифметика:
+↔-,*↔/; - Сравнения:
>↔>=,==↔!=,<↔<=; - Возвращаемые значения:
return $x→return null,return true→return false; - Условия циклов:
foreach ($items)→foreach ([]); - Логические операторы:
&&↔||,!убирается; - Удаление вызовов методов с типом возврата
void.
// 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 — запускайте ночью или вручную перед релизом.
x < 10 и x <= 9 для целых). Они помечены как survived, хотя тест корректен. Доля equivalent — шум, не свидетельство плохих тестов; научитесь распознавать.
app/Http/Controllers и аналогичные thin layers из source — иначе Infection тратит время на мутации, которые ничего не доказывают.
"timeout": 10 и снимите тяжёлые тесты с участка кода, тестируемого мутациями.
--threads=N — параллельные мутации, не параллельные тесты. Это N процессов Infection, каждый запускает обычную последовательную сюту. Параллельные тесты внутри (--parallel) могут конфликтовать; чаще проще использовать только Infection threads.
--git-diff-base — мутации только изменённых файлов.
@infection-ignore-all). Использование оправдано (примеры: вспомогательная статистика, нечеткая логика), но обильное применение — способ скрыть низкое качество тестов.
Coverage — популярная, но опасно неточная метрика. Менеджмент часто требует «80% coverage», команда подгоняет цифру тестами, которые ничего не проверяют. Чтобы метрика была полезной, важно понимать: что именно она измеряет, чем отличаются её разновидности и почему 100% line coverage не равно «всё протестировано».
Line coverage
Доля строк кода, исполненных хотя бы раз во время тестов. Самая распространённая и самая слабая метрика. Тест, который вызывает функцию, но не проверяет результат, даёт 100% line coverage функции.
Branch coverage
Доля ветвей условий, пройденных тестами. Для if ($a && $b) требует, чтобы и true, и false ветви были покрыты. Сильнее line, но не покрывает все комбинации short-circuit.
Path coverage
Доля уникальных путей через граф управления функции. Самая сильная из «обычных» метрик; экспоненциально дорогая в достижении. На практике замеряется редко.
MC/DC (Modified Condition / Decision 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 для адекватной картины.
icanhazstring/composer-unused и phpstan для поиска dead code; coverage только покажет последствия.
catch часто непропорционально дорого: нужно подменить зависимость так, чтобы она выкинула исключение. Если ошибка корректно прокидывается выше — иногда достаточно интеграционного теста на пограничном уровне.
$a = foo(); $b = bar();) считаются как одна строка. Coverage 100% не означает, что оба выражения исполнены.
Snapshot-тестирование — запись «снимка» вывода (JSON, HTML, текст) при первом прогоне и сравнение с ним на последующих. Цель — быстро ловить непредвиденные изменения в выводе сложной структуры (рендеринг шаблона, сериализация модели в JSON, ответ API). Используется парами: snapshot фиксирует факт, code review подтверждает легитимность обновлённого snapshot'а.
Подходит
- API-ответы с большой стабильной структурой (JSON Resource);
- Рендеринг HTML-писем;
- Сериализация сложных value-объектов в текст;
- Конфигурационные дампы.
Не подходит
- Бизнес-логика с конкретными правилами (snapshot не объясняет, что именно правильно);
- Выход с временными метками или случайными ID без нормализации;
- Очень нестабильный вывод (UI с динамической компоновкой);
- Когда «обновить snapshot» становится автоматическим жестом — теряется смысл фиксации.
// 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 и зафиксировать в коммите.
created_at от now() — snapshot будет валиться при каждом прогоне. Нормализуйте через $this->travelTo(specific) или замените временные значения placeholder'ом перед assert.
discount: 15} не объясняет, почему скидка 15. Бизнес-правила тестируются явными assert'ами: $this->assertSame(15, $price->discount(), 'New customer 15% rule').
App::setLocale, snapshot, снятый в en, не пройдёт под ru. Фиксируйте локаль в setUp.
Product::factory() создаёт случайное имя, snapshot будет уникальным каждый прогон. Указывайте детерминированные значения для каждого тестируемого поля.
Дано: модуль 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 часов; экономия времени поддержки — десятки часов в год.
assertTrue(condition) вместо специфичных assert'ов. Сообщение «expected true, got false» бесполезно. Используйте assertSame, assertEquals, assertCount, assertDatabaseHas — они дают понятное диагностическое сообщение при падении.
Order::factory() создаёт заказ с 20 связанными моделями «на всякий случай». 80% тестов используют только заказ, но получают целое дерево. Делайте минимальные фабрики и наращивайте через states (->withPayments(), ->withItems()).
.env. Тест проходит локально (где APP_DEBUG=true) и падает в CI (где false). Зафиксируйте критичные настройки в phpunit.xml — они переопределят окружение.
--parallel, а изоляцией состояний.
setUp, отличающаяся между тестами. Большой setUp с ветвлениями по типу теста — запах. Каждый тест должен явно объявлять свой Arrange; общее — через помощники, не через ветвящийся setUp.
if ($x > 0) должно быть как минимум 3 теста: меньше, ровно, больше.
1. Чем mock отличается от stub'а?
Stub возвращает заранее настроенные значения на вызовы — используется, чтобы подать тестируемому коду нужные входные данные. Mock заранее объявляет ожидания (какие методы будут вызваны, с какими аргументами, сколько раз) — используется, чтобы проверить протокол взаимодействия. Тесты со stub'ами устойчивее к рефакторингу; тесты с моками строже, но хрупче.
2. Когда уместен fake вместо mock'а?
Когда нужно реалистичное поведение коллаборатора (in-memory репозиторий, который сохраняет и читает данные), но без побочек реальной реализации. Fake позволяет писать state-based тесты: проверять итоговое состояние, а не цепочку вызовов. Это устойчивее к изменениям внутренней реализации — пока контракт fake'а соответствует продакшен-реализации.
3. Что такое over-mocking и чем он опасен?
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'ов через ожидание времени — антипаттерн; всегда подменяйте источник ошибки явно.