Laravel14 min de lecture
Tests avec Pest 4
Un test est la preuve que ton code fait ce que le ticket demande. Chez nous, une PR sans test n'est pas relue : voici comment en écrire de bons, vite.
Quels tests écrire
Dans une API Laravel, les tests Feature apportent le plus de confiance pour l'effort : ils traversent la route, la validation, l'Action et la base. Les tests Unit couvrent la logique pure, les tests d'architecture posent les garde-fous.
Largeur = proportion de tests dans la suite
Feature
Une requête HTTP réelle contre l'application, avec base SQLite en mémoire. C'est la majorité de la suite.
- Durée d'un test
- ~80 ms
- Volume typique
- 200+
- Dossier
- tests/Feature
Installation et configuration
Laravel 13 propose Pest par défaut. La configuration commune vit dans tests/Pest.php : trait RefreshDatabase et helpers partagés.
# New project: Pest is the default test runner
laravel new invoicing --pest
# Existing project
composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
./vendor/bin/pest --init
# Browser testing (Pest 4, Playwright under the hood)
composer require pestphp/pest-plugin-browser --dev
npm install playwright@latest && npx playwright install<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature', 'Browser');
/** Valid payload shared by every invoice test. Override only what the test is about. */
function invoicePayload(array $overrides = []): array
{
return array_replace_recursive([
'customer_id' => \App\Models\Customer::factory()->create()->id,
'due_at' => now()->addMonth()->toDateString(),
'lines' => [
['label' => 'Audit UX', 'quantity' => 2, 'unit_price' => 45_000],
],
], $overrides);
}
function signIn(?User $user = null): User
{
$user ??= User::factory()->create();
test()->actingAs($user);
return $user;
}<!-- phpunit.xml: tests run on an in-memory SQLite database -->
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_STORE" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
<env name="BCRYPT_ROUNDS" value="4"/>
</php>Le cycle rouge, vert, refactor
Pour un bug, commence toujours par un test rouge qui le reproduit. Pour une fonctionnalité, écris au moins le test du cas nominal avant le code.
Écris le test du comportement attendu et lance-le : il doit échouer, pour la bonne raison.
$ pest --filter="rejects overdue"
Test Unit : la logique pure
Pas de base, pas de HTTP. Les datasets (->with()) testent plusieurs cas avec un seul test lisible.
<?php
use App\Data\InvoiceData;
use App\Enums\InvoiceStatus;
it('computes the total in cents', function () {
// Arrange
$data = InvoiceData::fromArray([
'customer_id' => 1,
'due_at' => '2026-10-01',
'lines' => [
['label' => 'Design', 'quantity' => 2, 'unit_price' => 10_000],
['label' => 'Dev', 'quantity' => 1, 'unit_price' => 25_050],
],
]);
// Act + Assert
expect($data->total())->toBe(45_050);
});
it('only allows forward status transitions', function (InvoiceStatus $from, InvoiceStatus $to, bool $allowed) {
expect($from->canTransitionTo($to))->toBe($allowed);
})->with([
'draft to sent' => [InvoiceStatus::Draft, InvoiceStatus::Sent, true],
'sent to paid' => [InvoiceStatus::Sent, InvoiceStatus::Paid, true],
'draft to paid' => [InvoiceStatus::Draft, InvoiceStatus::Paid, false],
'paid to draft' => [InvoiceStatus::Paid, InvoiceStatus::Draft, false],
]);Test Feature : l'endpoint complet
Pour chaque endpoint, on couvre au minimum : le cas nominal, la validation, l’authentification et l’autorisation. On regroupe avec describe().
<?php
use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\User;
describe('POST /api/v1/invoices', function () {
it('creates a draft invoice', function () {
$user = signIn();
$this->postJson('/api/v1/invoices', invoicePayload())
->assertCreated()
->assertJsonPath('data.status', InvoiceStatus::Draft->value)
->assertJsonPath('data.total', 90_000);
$this->assertDatabaseHas('invoices', [
'author_id' => $user->id,
'total' => 90_000,
]);
});
it('rejects invalid payloads', function (array $overrides, string $field) {
signIn();
$this->postJson('/api/v1/invoices', invoicePayload($overrides))
->assertUnprocessable()
->assertJsonValidationErrors($field);
})->with([
'past due date' => [['due_at' => '2020-01-01'], 'due_at'],
'no lines' => [['lines' => []], 'lines'],
'zero quantity' => [['lines' => [['quantity' => 0]]], 'lines.0.quantity'],
]);
it('requires authentication', function () {
$this->postJson('/api/v1/invoices', invoicePayload())->assertUnauthorized();
});
it('forbids users without permission', function () {
signIn(User::factory()->readOnly()->create());
$this->postJson('/api/v1/invoices', invoicePayload())->assertForbidden();
expect(Invoice::count())->toBe(0);
});
});Tester une Action directement
Quand l'Action est réutilisée hors HTTP (commande, job), teste-la directement. On isole les effets de bord avec les fakes.
<?php
use App\Actions\Invoice\CreateInvoice;
use App\Data\InvoiceData;
use App\Events\InvoiceCreated;
use App\Models\User;
use Illuminate\Support\Facades\Event;
it('dispatches InvoiceCreated once the invoice is stored', function () {
Event::fake([InvoiceCreated::class]);
$author = User::factory()->create();
$invoice = app(CreateInvoice::class)->handle(
$author,
InvoiceData::fromArray(invoicePayload()),
);
expect($invoice)
->lines->toHaveCount(1)
->author_id->toBe($author->id);
Event::assertDispatched(
InvoiceCreated::class,
fn (InvoiceCreated $event) => $event->invoice->is($invoice),
);
});Factories et états
Ne dépends jamais des seeders ou de données existantes. Chaque test crée ce dont il a besoin avec une factory, et les états nomment les situations métier.
<?php
namespace Database\Factories;
use App\Enums\InvoiceStatus;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<\App\Models\Invoice> */
class InvoiceFactory extends Factory
{
public function definition(): array
{
return [
'customer_id' => Customer::factory(),
'author_id' => User::factory(),
'status' => InvoiceStatus::Draft,
'total' => fake()->numberBetween(10_000, 500_000),
'due_at' => now()->addMonth(),
];
}
public function sent(): static
{
return $this->state(['status' => InvoiceStatus::Sent]);
}
public function overdue(): static
{
return $this->sent()->state(['due_at' => now()->subWeek()]);
}
}Isoler le monde extérieur
Un test ne doit jamais envoyer un vrai mail, appeler une vraie API ou attendre une vraie journée.
| Besoin | Outil Laravel |
|---|---|
| Vérifier qu'un événement est émis | Event::fake() |
| Vérifier qu'un job part en file | Queue::fake() |
| Vérifier qu'un mail est envoyé | Mail::fake() |
| Simuler une API externe | Http::fake() |
| Écrire des fichiers sans disque réel | Storage::fake('s3') |
| Avancer dans le temps | $this->travel(5)->days() |
<?php
use App\Jobs\SendInvoiceReminder;
use App\Mail\InvoiceOverdue;
use App\Models\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('queues a reminder for overdue invoices', function () {
Queue::fake();
$invoice = Invoice::factory()->sent()->create(['due_at' => now()->subDay()]);
$this->artisan('invoices:remind')->assertSuccessful();
Queue::assertPushed(SendInvoiceReminder::class, fn ($job) => $job->invoice->is($invoice));
});
it('mails the customer 30 days after the due date', function () {
Mail::fake();
$invoice = Invoice::factory()->sent()->create(['due_at' => now()]);
$this->travel(30)->days();
(new SendInvoiceReminder($invoice))->handle();
Mail::assertQueued(InvoiceOverdue::class);
});
it('marks the invoice paid when the payment provider confirms', function () {
Http::preventStrayRequests();
Http::fake([
'api.payments.test/*' => Http::response(['status' => 'succeeded'], 200),
]);
$invoice = Invoice::factory()->sent()->create();
signIn();
$this->postJson("/api/v1/invoices/{$invoice->id}/pay")->assertOk();
expect($invoice->fresh()->status->value)->toBe('paid');
});Tests d'architecture
Les règles de la page Architecture deviennent exécutables. Les presets Pest couvrent les bases, on ajoute nos conventions par-dessus.
<?php
arch()->preset()->php(); // no dd(), dump(), var_dump()...
arch()->preset()->security(); // no eval(), md5(), unserialize()...
arch()->preset()->laravel(); // Laravel naming and folder conventions
arch('controllers stay thin')
->expect('App\Http\Controllers')
->not->toUse(['Illuminate\Support\Facades\DB', 'Illuminate\Support\Facades\Mail']);
arch('actions are final and expose handle()')
->expect('App\Actions')
->toBeFinal()
->toHaveMethod('handle');
arch('DTOs are immutable')
->expect('App\Data')
->toBeReadonly();
arch('env() is only read in config files')
->expect('env')
->not->toBeUsed(); // config/ is not scanned, app/ isTests navigateur (Pest 4)
Nouveauté de Pest 4 : des tests dans un vrai navigateur (Playwright), écrits avec la même syntaxe et les mêmes factories. À réserver aux parcours critiques des applications rendues par Laravel. Pour un front Next.js, on utilise Playwright côté front.
<?php
use App\Models\Invoice;
it('lets an accountant mark an invoice as paid', function () {
$user = signIn();
$invoice = Invoice::factory()->sent()->for($user, 'author')->create();
visit("/invoices/{$invoice->id}")
->assertSee('En attente de paiement')
->click('Marquer comme payée')
->assertSee('Payée')
->assertNoJavascriptErrors();
});
it('has no smoke on public pages', function () {
visit(['/', '/login', '/forgot-password'])->assertNoSmoke();
});Lancer les tests
Pendant le développement, lance seulement ce que tu touches. Avant de pousser, lance tout.
php artisan test --parallel # everything, on all CPU cores
./vendor/bin/pest --filter="draft" # only matching tests
./vendor/bin/pest --dirty # only tests touched by uncommitted changes
./vendor/bin/pest --bail # stop at the first failure
./vendor/bin/pest --profile # list the slowest tests
./vendor/bin/pest --coverage --min=80 # fail under 80% line coverage
./vendor/bin/pest --type-coverage --min=95
./vendor/bin/pest --mutate --parallel # do your tests really catch bugs?Dans la CI
La même suite tourne sur chaque PR, découpée en shards parallèles. Une couverture sous 80 % bloque la fusion.
name: api-tests
on:
pull_request:
push:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: pcov
- uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env && php artisan key:generate
- run: ./vendor/bin/pint --test
- run: ./vendor/bin/phpstan analyse --memory-limit=1G
- run: ./vendor/bin/pest --parallel --coverage --min=80 --shard=${{ matrix.shard }}/3Un bon test
À éviter
test('invoice', function () {
$user = User::first(); // depends on seeded data
$response = $this->post('/api/v1/invoices', [/* ... */]);
$this->assertTrue($response->status() == 201);
$this->assertTrue(Invoice::count() > 0);
// ...then tests update, delete and pay in the same test
});À faire
it('creates a draft invoice', function () {
$user = signIn();
$this->postJson('/api/v1/invoices', invoicePayload())
->assertCreated()
->assertJsonPath('data.status', 'draft');
$this->assertDatabaseHas('invoices', ['author_id' => $user->id]);
});- Un comportement par test, avec un nom qui se lit comme une phrase :
it('rejects an invoice without lines'). - Arrange, Act, Assert : préparer, agir, vérifier, séparés par une ligne vide.
- Indépendant : il passe seul, dans n'importe quel ordre, en parallèle.
- Tester le comportement, pas l'implémentation : vérifie la réponse et la base, pas les méthodes appelées.
- Assertions précises :
assertCreated()plutôt queassertTrue($status == 201). - Mutation testing de temps en temps :
pest --mutaterévèle les tests qui ne détectent rien.