Тесты в Laravel: чек-лист, что проверять перед мержем
Тесты в Laravel часто дают ложное чувство защищённости: они зелёные, но не ловят регрессии, текут по базе и тормозят CI. Чек-лист того, что стоит проверить в тестах перед вливанием ветки.

Тесты в проекте есть, CI зелёный, но баги всё равно уезжают в прод. Знакомая картина: тест дёргает контроллер, проверяет статус 200 и молчит про то, что метод вернул пустой массив вместо данных. Либо наоборот — набор из трёхсот тестов гоняется восемь минут, потому что каждый поднимает полную базу через RefreshDatabase без транзакций. Ниже — чек-лист по тестам в Laravel 11.x/12.x с PHPUnit и Pest: что смотреть на ревью тестового кода и где привычные приёмы дают трещину. Речь именно про качество и надёжность тестов, а не про то, как написать первый тест — это отдельная тема.
С чем сравниваем: три вида тестов в Laravel
Laravel из коробки делит тесты на две папки — tests/Unit и tests/Feature. На практике вы работаете с тремя уровнями, и путаница между ними — первый источник хрупких тестов.
| Уровень | Что проверяет | База данных | Скорость |
|---|---|---|---|
| Unit | Один класс/метод в изоляции | Не трогает, мокает | Миллисекунды |
| Feature | HTTP-запрос через роут → контроллер → БД | Реальная (тестовая) | Десятки мс на тест |
| Browser (Dusk) | Сценарий в реальном браузере | Реальная + JS | Секунды |
Основная масса ценности — в Feature-тестах: они проверяют цепочку целиком. Unit-тесты стоит писать на чистую бизнес-логику без БД и HTTP. Если ваш «unit-тест» лезет в базу через модель — это Feature-тест, лежащий не в той папке.
Чек-лист: база данных в тестах
Здесь ломается чаще всего. Тесты трогают базу, и от того, как вы её откатываете, зависит и корректность, и скорость.
- Трейт
RefreshDatabaseоборачивает каждый тест в транзакцию и откатывает её. Это быстро. Но он не сработает корректно, если ваш код внутри теста сам управляет транзакциями или использует движок без их поддержки (MyISAM в MySQL). Проверьте, что тестовые таблицы на InnoDB. - Не путайте
RefreshDatabaseиDatabaseMigrations. Второй прогоняет полныйmigrate:freshперед каждым тестом — это на порядок медленнее. Он нужен, только если тест реально пересобирает схему. - Отдельная база для тестов. В
phpunit.xmlдолжно быть переопределение подключения, а не «случайно та же, что в dev». Иначе один сбойный тест снесёт данные, над которыми вы работаете. - SQLite in-memory ускоряет, но врёт. Он не поддерживает часть возможностей PostgreSQL/MySQL: некоторые типы, ограничения внешних ключей по умолчанию, оконные функции. Тест на SQLite может быть зелёным, а на боевом PostgreSQL — падать. Если в проде PostgreSQL, гоняйте хотя бы CI на PostgreSQL.
Зелёный тест на SQLite при боевом PostgreSQL — это не тест, а обещание, что «наверное, будет работать». Разница в диалектах вылезает ровно там, где вы её не ждёте.
Чек-лист: сами утверждения
Тест, который ничего не утверждает по существу, хуже отсутствия теста — он даёт ложную уверенность и увеличивает время CI.
- Проверяете не только статус ответа, но и тело.
assertStatus(200)проходит и на пустом ответе. ДобавьтеassertJson,assertJsonCountилиassertJsonPath— проверьте, что данные действительно те. - Проверяете состояние БД после действия. После POST-запроса на создание —
assertDatabaseHasс ключевыми полями. После удаления —assertDatabaseMissingилиassertSoftDeleted, если модель на мягком удалении. - Негативные сценарии. Что вернёт эндпоинт при невалидных данных, без авторизации, при чужом ресурсе (403 vs 404). Тесты только на happy path пропускают половину багов.
- Валидация проверяется отдельно.
assertInvalid(['email'])подтверждает, что именно это поле отвалидировалось, а не абстрактный 422.
Пример осмысленного Feature-теста на Pest (Laravel 11.x):
<?php
use App\Models\User;
use App\Models\Order;
it('создаёт заказ и пишет его в базу', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/orders', [
'product_id' => 42,
'quantity' => 3,
]);
// Проверяем и код ответа, и структуру тела
$response->assertStatus(201)
->assertJsonPath('data.quantity', 3);
// Проверяем, что заказ реально появился в БД у нужного пользователя
$this->assertDatabaseHas('orders', [
'user_id' => $user->id,
'quantity' => 3,
]);
});
it('запрещает создание заказа неавторизованному', function () {
$this->postJson('/api/orders', ['product_id' => 42, 'quantity' => 1])
->assertUnauthorized(); // 401
});
Чек-лист: изоляция и внешний мир
Тест, который ходит в реальный платёжный шлюз или отправляет письмо, — не тест, а бомба замедленного действия. Laravel даёт фейки для большинства сервисов.
- HTTP-клиент.
Http::fake()перехватывает исходящие запросы. Без него тест зависит от чужого API и падает, когда тот недоступен. - Очереди.
Queue::fake()и затемQueue::assertPushed(SomeJob::class)— проверяете, что задача поставлена, не выполняя её. - Почта и уведомления.
Mail::fake(),Notification::fake()и соответствующиеassertSent. - События.
Event::fake(), но с оговоркой: он отключает слушателей. Если тест проверяет побочный эффект слушателя — фейкать событие нельзя, иначе эффекта не будет. - Время. Для логики, зависящей от дат, используйте подмену времени вместо реального
now()— Laravel предоставляет$this->travelTo()и$this->freezeTime(). Тест на «истекла ли подписка» не должен зависеть от того, в какой день его запустили.
Фейк (fake) — подмена реализации сервиса на тестовую, которая записывает вызовы вместо выполнения. В отличие от мока, фейки Laravel имеют встроенные ассерты вроде assertSent.
Чек-лист: фабрики и данные
- Фабрики вместо ручного создания моделей.
User::factory()->create()читается лучше и переживает добавление обязательных полей — их достаточно задать один раз в фабрике. - Не хардкодьте ID. Тест с
product_id => 1в предположении, что запись с таким ID существует, ломается при смене порядка сидов. Создавайте нужную запись через фабрику в самом тесте. - Минимум данных на тест. Создавайте ровно то, что проверяете. Полтора десятка связанных сущностей ради проверки одного поля замедляют прогон и запутывают чтение.
- Состояния фабрик (states) для вариантов:
User::factory()->admin()->create()яснее, чем ручное присвоение роли в теле теста.
Что ломается и как это увидеть
Типичные симптомы плохих тестов и их причины:
- Тесты падают через раз (flaky). Почти всегда — зависимость от порядка выполнения или от времени. Проверьте, не осталось ли состояния между тестами: незафейканная очередь, кеш, реальный
now(). - Проходят по одному, падают в пачке. Общее состояние: статические свойства, кеш конфигурации, не откатанная транзакция. В Feature-тестах включите
RefreshDatabaseи проверьте, что кеш фейкается черезCache::fake(). - CI 10 минут. Смотрите на самые медленные тесты — PHPUnit и Pest умеют выводить их. Обычно виновники:
DatabaseMigrationsвместоRefreshDatabase, реальные HTTP-запросы, генерация тяжёлых фикстур в каждом тесте. - Тест зелёный, а фича сломана. Ассерты проверяют форму, а не содержание — только статус, только факт вызова. Добавьте проверку значений.
Что настроить дальше
После того как чек-лист пройден, стоит поставить измеримые ограничения:
- Включите параллельный прогон:
php artisan test --parallel. Он поднимает несколько тестовых БД и даёт кратное ускорение на многоядерной машине. Проверьте, что тесты не делят внешнее состояние. - Посмотрите покрытие через
--coverage, но не гонитесь за процентом ради процента: 100% покрытия геттеров не стоят ничего, а один непокрытый метод расчёта скидки может стоить денег. - Настройте прогон тестов в CI на той же СУБД, что в проде, — это снимает целый класс расхождений между зелёным CI и упавшим релизом.
- Заведите правило ревью: новый PR с изменением логики без теста на неё не мержится.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Laravel Documentation — Database Testing, Laravel Documentation — HTTP Tests & Mocking
Частые вопросы
У меня Laravel 10, чек-лист подойдёт?
Тесты проходят локально, но падают в CI. С чего начать?
RefreshDatabase или DatabaseMigrations — что выбрать?
Обязательно ли гнаться за высоким покрытием?
Почему тесты иногда падают случайно (flaky)?
Можно ли тестировать код, который ходит во внешний API?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.