Тема
Тестирование Laravel — лучшие практики
Дополняет внутренний
../testing.md(стратегия, защита рабочей БД). КРИТИЧНО про тестовую БД (RefreshDatabaseтолько на*_test) — см.../testing.md.
Pest vs PHPUnit
Laravel 11+ ставит Pest по умолчанию. Оба валидны; Pest — лаконичнее, PHPUnit — классика.
php
// Pest
it('validates phone on contact form', function () {
$response = $this->post(route('contact.store'), ['phone' => 'invalid']);
$response->assertSessionHasErrors('phone');
});
// PHPUnit-эквивалент
public function test_contact_form_validates_phone(): void
{
$this->post(route('contact.store'), ['phone' => 'invalid'])
->assertSessionHasErrors('phone');
}Пирамида тестов
| Тип | Доля | Что |
|---|---|---|
| Feature | большинство | HTTP, роуты, формы, API, редиректы — основной фокус |
| Unit | критичная логика | Service с расчётами/ветвлением, formulas, edge cases |
| Browser (Pest v4 / Dusk) | минимум | ключевые JS-сценарии, финальная проверка |
Не писать unit на CRUD без логики (покрывается feature) и на Eloquent-связи (ответственность фреймворка).
Factories — основа тестовых данных
php
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'status' => PostStatus::Draft,
'author_id' => User::factory(),
];
}
public function published(): static // state
{
return $this->state(['status' => PostStatus::Published, 'published_at' => now()]);
}
}
// В тесте:
Post::factory()->count(3)->published()->for($user, 'author')->create();- Каждая модель → factory (правило из чеклиста коммита
../../CLAUDE.md). - States для вариаций, relationships через
for()/has().
Fakes — изоляция побочных эффектов
php
Mail::fake(); Queue::fake(); Event::fake(); Notification::fake(); Storage::fake('s3');
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
// Потом:
Mail::assertSent(OrderConfirmation::class);
Queue::assertPushed(ProcessImport::class);
Http::assertSent(fn ($r) => $r->url() === 'https://api.example.com/charge');Внешние зависимости (mail, queue, API, telegram) — мокать. НЕ мокать БД (использовать RefreshDatabase с реальными запросами — иначе тесты проходят, а миграция/SQL сломаны).
Что тестировать обязательно (Feature)
Чеклист для нового роута (из ../testing.md):
- GET → 200, наличие
<title>,<h1> - GET несуществующий slug → 404
- POST валидные данные → success (201/302)
- POST невалидные → 422 / redirect с errors
- 301 redirect → правильный destination
- API rate limit → 429 после лимита
- Sitemap содержит все сущности из БД, все URL → 200
Принципы
- Один assert = одно поведение (понятно, что сломалось).
- Тестировать поведение, не реализацию — рефакторинг не должен ломать тест.
- Изоляция — тест не зависит от порядка/других тестов (flaky иначе).
- Именование:
test_{что}_{ожидание}—test_guest_cannot_access_admin. - AAA: Arrange (factory/seed) → Act (запрос) → Assert.
Запуск (через Docker)
bash
docker exec {container} php artisan test
docker exec {container} php artisan test --filter=ContactFormTest
docker exec {container} php artisan test --parallel # параллельно (отдельные test-БД на процесс)
docker exec {container} php artisan test --coverage --min=70L11+ ускоряет :memory: SQLite-тесты (переиспользование PDO — до 2x). Но для проектов с PostGIS/jsonb — тестовая БД = тот же PostgreSQL с extensions (см. ../testing.md).
CI-связка
pint --test # стиль
larastan (phpstan) # статический анализ
php artisan test # тесты
composer audit # CVEВсё зелёное → merge. См. чеклист коммита в ../../CLAUDE.md.
Anti-patterns
| Паттерн | Проблема | Решение |
|---|---|---|
RefreshDatabase на рабочей БД | потеря всех данных | DB_DATABASE={project}_test в phpunit.xml |
| Mock БД | миграция/SQL сломаны, а тест зелёный | RefreshDatabase + реальные запросы |
| Тест зависит от порядка | flaky | изолировать каждый |
| 10 assert в одном тесте | непонятно что упало | один assert = одно поведение |
| Тест проверяет внутренности | ломается при рефакторинге | тестировать поведение |
| Нет теста на публичный URL | сломанная страница в prod | feature-тест на каждый роут |
| Реальный вызов внешнего API | flaky, медленно, платно | Http::fake() |