Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase

Рейтинг: 60.1% · 14 голосов
Курс по Laravel: маршруты, Eloquent, Blade, миграции, очереди и API. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
oleg_php
Сообщения: 25
Зарегистрирован: 14 май 2026, 08:06

Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase

Сообщение oleg_php »

АкадемияLaravel с нуляГлава 15 из 18
Оглавление курса (18)
  1. Знакомство с Laravel и установка окружения
  2. Маршруты и контроллеры
  3. Blade: шаблоны и вёрстка страниц
  4. Миграции и структура базы данных
  5. Eloquent ORM: модели и CRUD
  6. Связи в Eloquent: hasMany, belongsTo и другие
  7. Формы и валидация данных
  8. Аутентификация пользователей
  9. Middleware и защита маршрутов
  10. Очереди и фоновые задачи
  11. Отправка почты и уведомления
  12. Строим REST API на Laravel
  13. Авторизация: Gates и Policies
  14. Работа с файлами: загрузка, Storage, диски local и S3
  15. Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase (вы здесь)
  16. Сервис-контейнер, провайдеры, свои artisan-команды и планировщик
  17. Деплой в продакшен и обзор современного фронтенда (Vite, Livewire, Inertia)
  18. События и слушатели, кеширование, логирование
Четырнадцать глав мы писали код и проверяли его глазами: открыл браузер, потыкал форму, вроде работает. Пока в проекте три страницы, это терпимо. Но блог оброс авторизацией, политиками и API, и прокликивать всё руками после каждой правки уже нереально. Тесты решают ровно эту проблему: один раз описываете ожидаемое поведение, дальше машина проверяет его за секунды.

Pest или PHPUnit:

PHPUnit это классика, тесты как классы с методами. Pest это обёртка над тем же PHPUnit с функциональным синтаксисом, её развивает Nuno Maduro из core-команды Laravel. Установщик laravel/installer спрашивает, на чём вы будете писать тесты, начиная со своей пятой версии (вышла 1 августа 2023, ещё времена Laravel 10), но дефолтом в этом вопросе сначала был PHPUnit, а Pest стал вариантом по умолчанию только с v5.5.0 в январе 2024. Движок один, ассерты совместимы, спорить о выборе бессмысленно. Глава написана на Pest, перенос на PHPUnit механический: каждое it() превращается в метод класса.

В свежем проекте уже есть tests/Feature и tests/Unit с примерами. Запуск:

Код: Выделить всё

php artisan test
php artisan test --filter=ArticleTest
php artisan test --parallel
php artisan test --profile
--filter гоняет один класс или тест, --profile печатает десятку самых медленных, --parallel раскидывает прогон по ядрам, и Laravel сам заводит каждому процессу отдельную тестовую базу. Есть ещё --coverage, ему нужен Xdebug или PCOV.

База и RefreshDatabase:

Тут зарыт нюанс версий, и он важный. В скелетоне Laravel 12 в env-секции phpunit.xml уже активны строки DB_CONNECTION=sqlite и DB_DATABASE=:memory:, то есть тесты работают с базой в памяти и рабочую не трогают. А вот в Laravel 11 те же строки всю жизнь ветки пролежали закомментированными, поэтому тесты берут подключение из .env и по умолчанию бьют в тот же файл database/database.sqlite, с которым вы работаете локально.

Трейт RefreshDatabase выдаёт каждому тесту чистую базу: один раз за прогон выполняет migrate:fresh, а дальше оборачивает каждый тест в транзакцию и откатывает её в конце. С базой в памяти он дополнительно переиспользует одно соединение на весь прогон, иначе база умирала бы вместе с PDO-объектом между тестами. Есть и вариант LazilyRefreshDatabase: то же самое, только миграции и транзакция стартуют в момент первого реального обращения к базе, на наборах с кучей юнит-тестов это ощутимо экономит время.

Отсюда главное предупреждение главы. На Laravel 11 это не теория: первый же прогон с RefreshDatabase сделает migrate:fresh вашей дев-базе и снесёт её вместе с данными. Раскомментируйте DB_CONNECTION=sqlite и DB_DATABASE=:memory: в phpunit.xml до первого запуска, а не после. На Laravel 12 всё уже включено из коробки, но в env-секцию всё равно загляните, мало ли кто её правил до вас.

Подключить трейт сразу всем Feature-тестам можно в tests/Pest.php, нужная строка там уже есть, закомментирована:

Код: Выделить всё

pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');
Фабрики:

Тестам нужны данные. Собирать их руками через Article::create() с десятком полей это путь к нечитаемым тестам. Фабрика описывает типичную запись, а тест переопределяет только то, что реально проверяет. Трейт HasFactory у наших моделей стоит с главы 5, осталось описать саму фабрику.

Код: Выделить всё

// php artisan make:factory ArticleFactory --model=Article
// database/factories/ArticleFactory.php
public function definition(): array
{
    return [
        'title' => fake()->sentence(4),
        'body' => fake()->paragraphs(3, true),
        'user_id' => User::factory(),
        'published_at' => now(),
    ];
}

public function draft(): static
{
    return $this->state(fn (array $attributes) => [
        'published_at' => null,
    ]);
}
Строка user_id => User::factory() означает: если автора не передали явно, фабрика создаст его сама. Метод draft() это state, именованный вариант записи. И мелочь, которая радует: поставьте в .env APP_FAKER_LOCALE=ru_RU, и fake() начнёт выдавать русские имена и адреса.

Теперь feature-тест целиком:

Код: Выделить всё

// tests/Feature/ArticleTest.php
use App\Models\Article;

it('shows only published articles', function () {
    Article::factory()->create(['title' => 'Свежий пост']);
    Article::factory()->draft()->create(['title' => 'Черновик']);

    $this->get('/articles')
        ->assertOk()
        ->assertSee('Свежий пост')
        ->assertDontSee('Черновик');
});

it('lets the author edit own article', function () {
    $article = Article::factory()->create();

    $this->actingAs($article->user)
        ->put(route('articles.update', $article), [
            'title' => 'Новый заголовок',
            'body' => 'Обновлённый текст',
        ])
        ->assertRedirect();

    expect($article->refresh()->title)->toBe('Новый заголовок');
});
Здесь get() и put() не настоящие HTTP-запросы, Laravel прогоняет их через своё ядро без веб-сервера, поэтому feature-тесты такие быстрые. actingAs() логинит пользователя без формы и пароля, и это штатный способ тестировать всё, что мы закрыли middleware в главе 9 и политиками в главе 13. Для проверки базы есть и $this->assertDatabaseHas('articles', [...]), удобно, когда модели под рукой нет.

Запреты и валидация:

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

Код: Выделить всё

use App\Models\Article;
use App\Models\User;

it('forbids editing someone else article', function () {
    $article = Article::factory()->create();
    $stranger = User::factory()->create();

    $this->actingAs($stranger)
        ->put(route('articles.update', $article), [
            'title' => 'Взлом',
            'body' => 'Не должно сохраниться',
        ])
        ->assertForbidden();

    expect($article->refresh()->title)->not->toBe('Взлом');
});

it('rejects article without title', function () {
    $article = Article::factory()->create();

    $this->actingAs($article->user)
        ->put(route('articles.update', $article), [
            'title' => '',
            'body' => 'Текст на месте',
        ])
        ->assertInvalid(['title']);
});
assertForbidden() ловит 403 от политики, и заодно убеждаемся, что запись не изменилась. assertInvalid() проверяет, что валидация положила ошибку по полю title; assertSessionHasErrors() делает то же самое старым синтаксисом, а assertValid() проверяет обратное. В тестах на права запрещающий сценарий важнее разрешающего: дыра в доступе стоит дороже сломанной кнопки.

JSON и API:

API блога тестируется теми же инструментами, только запросы шлём через getJson() и postJson(), они сами ставят правильные заголовки Accept и Content-Type:

Код: Выделить всё

it('returns published articles as json', function () {
    Article::factory()->count(2)->create();
    Article::factory()->draft()->create();

    $this->getJson('/api/articles')
        ->assertOk()
        ->assertJsonCount(2, 'data')
        ->assertJsonStructure([
            'data' => [['id', 'title', 'published_at']],
        ]);
});
assertJson() сверяет фрагмент ответа с ожидаемым массивом, assertJsonStructure() проверяет только форму без конкретных значений, assertJsonCount() считает элементы. Ошибки валидации в API приходят как 422 с массивом errors, и assertInvalid() в паре с postJson() тоже работает.

Фейки:

Реальное приложение после действия пользователя шлёт письма, ставит задачи в очередь, пишет файлы. Гонять это по-настоящему в тестах не нужно, у фасадов Laravel есть штатные подмены: Mail::fake(), Queue::fake(), Storage::fake(), Event::fake(), Notification::fake() и Http::fake() для исходящих запросов к чужим API. Допустим, при публикации статьи автору уходит письмо:

Код: Выделить всё

use App\Mail\ArticlePublished;
use Illuminate\Support\Facades\Mail;

it('emails the author on publish', function () {
    Mail::fake();

    $article = Article::factory()->draft()->create();

    $this->actingAs($article->user)
        ->post(route('articles.publish', $article))
        ->assertRedirect();

    Mail::assertSent(ArticlePublished::class, fn ($mail) => $mail->hasTo($article->user->email));
});
Схема всегда одна: fake() до действия, assertSent, assertPushed или assertDispatched после. Storage::fake('public') вдобавок поднимает временный диск, так что загрузку обложек можно тестировать честно, не засоряя реальный storage. Http::fake() перехватывает запросы HTTP-клиента Laravel и отдаёт заготовленные ответы, внешний сервис не дёргается, и тест не зависит от его доступности.

Сидеры в тестах:

Сидеры из главы 4 работают и тут. Когда приложению нужны справочные данные, например роли из главы 13, не зашивайте их в фабрики. Посадите точечно: $this->seed(RoleSeeder::class) в начале теста или в beforeEach(). Полный DatabaseSeeder в тестах гонять не надо, он медленный и тащит лишнее.

Типичные грабли:

Тест проходит в одиночку и падает в общем прогоне, или наоборот. Значит, он опирается на данные чужого теста. С RefreshDatabase каждый тест обязан создавать всё нужное сам, никаких общих записей между тестами.

Путаница make() и create(). Первый только собирает модель в памяти, в базу не пишет. assertDatabaseHas() не найдёт данные, созданные только через make(); а если такой ассерт вдруг прошёл, значит совпадающую строку записал сидер или другая подготовка, и проверяете вы не то, что думали.

Фабрики плодят лишнее. Article::factory()->count(50)->create() создаст не только 50 статей, но и 50 пользователей, по одному на статью. На больших прогонах это минуты впустую. Лекарство называется recycle():

Код: Выделить всё

$user = User::factory()->create();
Article::factory()->count(50)->recycle($user)->create();
Тесты, завязанные на время, падают раз в месяц или ровно в полночь. Не надейтесь, что now() в тесте и в коде совпадут, фиксируйте время через $this->freezeTime() или travelTo().

И не проверяйте количество строк по всей таблице через assertDatabaseCount без нужды: стоит кому-то добавить сидер или запись в setUp, и тест ляжет. Проверяйте конкретные строки.

Итог:

У блога появилась страховка: фабрики генерируют данные, RefreshDatabase держит базу чистой, фейки отрезают почту, очереди и внешние API, а feature-тесты прощупывают приложение через те же маршруты, что и живые пользователи. Начните с тестов на самое страшное (права доступа, удаление данных, регистрация), причём первыми пишите запрещающие сценарии, и наращивайте покрытие по мере правок. В главе 16 заглянем под капот фреймворка: сервис-контейнер, провайдеры, собственные artisan-команды и планировщик.
👍3 ❤️4 🔥3 😄 🤔2
✔ Лучший ответ сформирован автоматически — raspberry_pilot
oleg_php писал(а):Если тестовое окружение смотрит в вашу дев-базу, RefreshDatabase снесёт её вместе с данными подтверждаю, не теория. на старом проекте с laravel 8 эти строки в phpunit.xml были закомментированы, запустил php artisan test и попрощался с локальной базой за месяц работы. теперь первым делом открываю env-секцию phpunit.xml, а потом уже всё остальное
Перейти к ответу →
Аватара пользователя
raspberry_pilot
Сообщения: 3
Зарегистрирован: 22 май 2026, 11:01

Re: Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase

Сообщение raspberry_pilot »

✔ Лучший ответ — сформирован автоматически
oleg_php писал(а):Если тестовое окружение смотрит в вашу дев-базу, RefreshDatabase снесёт её вместе с данными
подтверждаю, не теория. на старом проекте с laravel 8 эти строки в phpunit.xml были закомментированы, запустил php artisan test и попрощался с локальной базой за месяц работы. теперь первым делом открываю env-секцию phpunit.xml, а потом уже всё остальное
👍 ❤️ 🔥 😄 🤔
Аватара пользователя
rwiley
Сообщения: 2
Зарегистрирован: 16 май 2026, 04:33

Re: Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase

Сообщение rwiley »

а как быть, если в проде mysql 8 и в запросах JSON_CONTAINS плюс fulltext индексы? sqlite в памяти такое не умеет, тесты валятся. поднимать отдельный mysql в docker и прописывать его в .env.testing, или есть путь попроще?
👍1 ❤️ 🔥1 😄 🤔1
Аватара пользователя
markhal
Сообщения: 1
Зарегистрирован: 12 май 2026, 16:10

Re: Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase

Сообщение markhal »

про recycle жиза. у нас прогон в ci шёл девять минут, --profile показал, что фабрики наплодили тысячи лишних юзеров. прошлись по тестам с count(), добавили recycle, стало четыре с копейками. так что --profile стоит гонять не только когда всё уже совсем плохо
👍2 ❤️1 🔥 😄 🤔1
Аватара пользователя
cornholi
Сообщения: 1
Зарегистрирован: 08 июн 2026, 21:28

Re: Тестирование: Pest и PHPUnit, фабрики, сидеры, RefreshDatabase

Сообщение cornholi »

вопрос: проект старый, тестов на phpunit штук двести. pest можно поставить рядом и новые писать на нём, а старые не трогать? или начнётся конфликт с существующим phpunit.xml
👍 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Работа с файлами: загрузка, Storage, диски local и S3
Следующая глава →
Сервис-контейнер, провайдеры, свои artisan-команды и планировщик

Все главы курса «Laravel с нуля»

Поделиться темой: ✈ Telegram VK

Вернуться в «Laravel с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 0 гостей