Тесты в Laravel: частые ошибки, из-за которых падает CI
Флаки-тесты, забытый RefreshDatabase, мокинг фасадов не в ту сторону и тесты, которые проходят локально и падают в CI. Разбираем, что именно ломается в тестах на Laravel и как это чинить.

Тесты в Laravel пишутся легко: php artisan make:test, пара ассертов, зелёная строка в консоли. Проблемы начинаются, когда тестов становится двести, они начинают падать через раз, а на CI ведут себя не так, как на ноутбуке. Ниже — набор ошибок, которые встречаются в реальных проектах на Laravel 10.x и 11.x, с конкретной причиной и тем, что менять. Речь про PHPUnit (в Laravel 11 по умолчанию 11.x) и Pest — большинство пунктов применимо к обоим.
Состояние базы утекает между тестами
Самая частая причина «то падает, то нет». Тест создаёт запись, не откатывает её, следующий тест натыкается на дубликат по уникальному индексу или на неожиданное количество строк.
В Laravel за изоляцию отвечает один из трейтов. Их путают:
RefreshDatabase— оборачивает каждый тест в транзакцию и откатывает её после. Быстро, но миграции гоняются один раз за запуск.DatabaseMigrations— мигрирует и откатывает базу на каждый тест. Медленно, но нужен, когда тест делает что-то, что транзакция не откатит (например, вложенные транзакции в коде).DatabaseTransactions— просто транзакция вокруг теста, без миграций. Предполагает, что схема уже накатана.
Если в тесте, который меняет данные, вообще нет ни одного из этих трейтов — данные останутся в базе и отравят соседей. Проверка простая: запустите php artisan test дважды подряд. Если второй прогон падает там, где первый прошёл, у вас утечка состояния.
Тест, который проходит поодиночке и падает в общем прогоне, почти всегда виноват не сам — виноват тест, который отработал перед ним и не убрал за собой.
Трейт есть, а данные всё равно остаются
RefreshDatabase откатывает транзакцию соединения по умолчанию. Если тестируемый код пишет во второе соединение (отдельная база, очередь, лог в другую БД), это соединение трейт не трогает. Тогда либо переводите оба соединения в транзакции, либо используйте DatabaseMigrations для таких тестов.
Тесты зависят от порядка выполнения
PHPUnit не гарантирует порядок между классами, а с ключом рандомизации порядок вообще меняется от запуска к запуску. Тест, который «работает, потому что нужная запись уже создана предыдущим», — это не тест, а бомба замедленного действия.
Каждый тест обязан сам создавать всё, что ему нужно, во setUp() или в самом теле через фабрики:
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OrderTest extends TestCase
{
use RefreshDatabase;
public function test_user_sees_own_orders(): void
{
// Всё, что нужно тесту, создаётся здесь, а не берётся "из прошлого"
$user = User::factory()->hasOrders(3)->create();
$response = $this->actingAs($user)->getJson('/api/orders');
$response->assertOk()->assertJsonCount(3, 'data');
}
}
Чтобы поймать скрытые зависимости от порядка, запустите набор с перемешиванием: php artisan test --order-by=random. Если после этого что-то падает — зависимость есть.
Флаки из-за времени и рандома
Тест на «токен истекает через час» с реальным Carbon::now() рано или поздно упадёт на границе секунды или в другой таймзоне CI-раннера. Замораживайте время:
// Laravel 9.x+: заморозить и вернуть время автоматически после колбэка
$this->freezeTime(function () {
$token = $this->createToken();
$this->travel(61)->minutes();
$this->assertTrue($token->fresh()->isExpired());
});
Метод travel() и freezeTime() доступны через трейт Illuminate\Foundation\Testing\Concerns\InteractsWithTime, он подключён в базовом TestCase. То же с рандомом: если код дергает rand() или Str::random(), а тест проверяет конкретное значение — тест недетерминирован. Либо мокайте источник случайности, либо проверяйте свойство (длину, формат), а не точное значение.
Мок не в ту сторону
Фасады Laravel мокаются через свои же методы: Mail::fake(), Queue::fake(), Event::fake(), Http::fake(). Частая ошибка — ставить fake() после вызова кода, который отправляет письмо. Порядок жёсткий: сначала fake(), потом действие, потом ассерт.
public function test_registration_sends_welcome_mail(): void
{
Mail::fake(); // 1. подменяем
$this->post('/register', [ // 2. выполняем
'email' => 'ivan@example.com',
'password' => 'secret-pass-123',
]);
Mail::assertSent(WelcomeMail::class); // 3. проверяем
}
Вторая ошибка — мокать то, что тестируешь. Если тест проверяет, что джоба реально отправляет письмо, а вы поставили и Queue::fake(), и Mail::fake(), то джоба в очередь только «положится», но не выполнится, и письмо не уйдёт. assertSent провалится. Для проверки содержимого джобы её нужно диспатчить синхронно или вызывать handle() напрямую.
HTTP-запросы наружу в тестах
Тест, который реально ходит в чужой API, — не тест, а лотерея: чужой сервис лежит, лимит исчерпан, сеть на CI закрыта. Всегда Http::fake():
Http::fake([
'api.partner.com/*' => Http::response(['status' => 'ok'], 200),
]);
// ... вызов кода, который дергает partner API
Http::assertSent(fn ($request) =>
$request->url() === 'https://api.partner.com/pay'
);
Чтобы случайный настоящий запрос не проскочил, добавьте Http::preventStrayRequests() — тогда любой незамоканный вызов бросит исключение вместо тихого ухода в сеть.
Локально проходит, на CI падает
Классика. Причины почти всегда из этого списка:
| Симптом | Причина | Что делать |
|---|---|---|
| Падает на уникальном индексе только в CI | Другой драйвер БД: локально MySQL, в CI SQLite in-memory (или наоборот) | Гоняйте локально ту же БД, что в CI, либо явно задайте в phpunit.xml |
| Ошибки таймзоны/дат | Раннер в UTC, ноутбук в местном времени | Фиксируйте APP_TIMEZONE и замораживайте время в тестах |
| Кеш конфига «залип» | На CI закеширован старый config | php artisan config:clear перед прогоном |
| Фейл на файлах/загрузках | Реальный диск вместо фейкового | Storage::fake('public') в начале теста |
| Гонки в параллельном прогоне | --parallel делит одну БД между воркерами | Настройте ParallelTesting и отдельные БД на процесс |
Отдельно про SQLite in-memory: он быстрый, но ведёт себя не как MySQL. Внешние ключи по умолчанию не проверяются, типы столбцов вольнее, некоторые функции отсутствуют. Тест, зелёный на SQLite, может скрывать баг, который вылезет на проде с MySQL или PostgreSQL. Если приложение работает на MySQL — тестируйте на MySQL хотя бы в CI.
Ассерты, которые ничего не проверяют
Тест $response->assertOk() без единой проверки содержимого зелёный даже тогда, когда эндпоинт вернул пустоту вместо данных. Пара ошибок, которые дают ложное чувство покрытия:
- Только статус-код. 200 не значит, что тело корректное. Добавляйте
assertJsonStructureилиassertJsonPath. - Проверка через модель, а не через ответ. Если после POST вы делаете
$this->assertDatabaseHas(...), но не проверяете, что ответ был 201 — тест пройдёт, даже если контроллер вернул 422 и запись создалась побочно. - Тест без ассертов вовсе. PHPUnit пометит его как «risky», но в общем прогоне это легко пропустить. Включите вывод risky-тестов в конфиге и относитесь к ним как к падениям.
Слишком медленный набор
Когда прогон занимает десять минут, тесты перестают запускать перед пушем. Что съедает время:
DatabaseMigrationsвместоRefreshDatabaseтам, где транзакции достаточно. Первый мигрирует базу на каждый тест.- Реальный
bcryptна фабриках. В тестах снижайте стоимость хеширования — задайте низкийBCRYPT_ROUNDSв тестовом окружении. - Отсутствие
--parallel. На многоядерном раннере параллельный прогон ускоряет набор в разы, но требует изоляции БД по процессам.
Что настроить дальше
Начните с двух вещей: включите рандомный порядок (--order-by=random) и добавьте Http::preventStrayRequests() в базовый TestCase. Первое вскроет скрытые зависимости между тестами, второе — незамоканные запросы наружу. Дальше сверьте драйвер БД в CI с продовым и уберите SQLite in-memory, если прод на MySQL. Мерьте время прогона на CI как отдельную метрику: рост времени тестов — ранний сигнал, что кто-то тащит в набор медленные интеграционные проверки без изоляции.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Laravel Documentation — Testing: Getting Started, Laravel Documentation — HTTP Tests и Mocking
Частые вопросы
У меня Laravel 11, а примеры для 10 — сработают ли методы freezeTime и travel?
RefreshDatabase или DatabaseMigrations — что выбрать по умолчанию?
Тест падает только на CI, а локально всё зелёное — с чего начать?
Почему Mail::assertSent падает, хотя письмо реально приходит?
Стоит ли гонять тесты параллельно и что при этом ломается?
Как поймать тесты, которые проходят случайно из-за порядка выполнения?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.