Aller au contenu
Lantorian

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
Survole une couche. La largeur représente la part de la suite de tests.

Installation et configuration

Laravel 13 propose Pest par défaut. La configuration commune vit dans tests/Pest.php : trait RefreshDatabase et helpers partagés.

terminal
# 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
tests/Pest.php
<?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;
}

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"

Clique sur une phase pour l'arrêter. Chaque tour dure quelques minutes, pas une journée.

Test Unit : la logique pure

Pas de base, pas de HTTP. Les datasets (->with()) testent plusieurs cas avec un seul test lisible.

tests/Unit/InvoiceDataTest.php
<?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().

tests/Feature/Invoice/CreateInvoiceTest.php
<?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.

tests/Feature/Invoice/CreateInvoiceActionTest.php
<?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.

database/factories/InvoiceFactory.php
<?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.

BesoinOutil Laravel
Vérifier qu'un événement est émisEvent::fake()
Vérifier qu'un job part en fileQueue::fake()
Vérifier qu'un mail est envoyéMail::fake()
Simuler une API externeHttp::fake()
Écrire des fichiers sans disque réelStorage::fake('s3')
Avancer dans le temps$this->travel(5)->days()
tests/Feature/Invoice/RemindersTest.php
<?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.

tests/Arch/ArchTest.php
<?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/ is

Tests 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.

tests/Browser/PayInvoiceTest.php
<?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.

terminal
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.

.github/workflows/api-tests.yml
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 }}/3

Un bon test

À éviter

tests/Feature/InvoiceTest.php
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

tests/Feature/Invoice/CreateInvoiceTest.php
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 que assertTrue($status == 201).
  • Mutation testing de temps en temps : pest --mutate révèle les tests qui ne détectent rien.