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База и 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,
]);
}Теперь 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('Новый заголовок');
});Запреты и валидация:
Позитивного теста мало. Политика из главы 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']);
});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']],
]);
});Фейки:
Реальное приложение после действия пользователя шлёт письма, ставит задачи в очередь, пишет файлы. Гонять это по-настоящему в тестах не нужно, у фасадов 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));
});Сидеры в тестах:
Сидеры из главы 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();И не проверяйте количество строк по всей таблице через assertDatabaseCount без нужды: стоит кому-то добавить сидер или запись в setUp, и тест ляжет. Проверяйте конкретные строки.
Итог:
У блога появилась страховка: фабрики генерируют данные, RefreshDatabase держит базу чистой, фейки отрезают почту, очереди и внешние API, а feature-тесты прощупывают приложение через те же маршруты, что и живые пользователи. Начните с тестов на самое страшное (права доступа, удаление данных, регистрация), причём первыми пишите запрещающие сценарии, и наращивайте покрытие по мере правок. В главе 16 заглянем под капот фреймворка: сервис-контейнер, провайдеры, собственные artisan-команды и планировщик.