Passer au contenu principal

Qu’est-ce que Pest

Pest est un framework de test construit au-dessus de PHPUnit et adopté par défaut dans tout nouveau projet depuis Laravel 11. Il reprend l’ensemble des assertions de PHPUnit tout en offrant une syntaxe concise basée sur des closures. Principales différences avec PHPUnit :
AspectPestPHPUnit
Définition de testclosures test() / it()méthodes dans une classe
AssertionsExpectation API (expect()->toBe())$this->assert*()
Datasetsdataset() / with()@dataProvider
HooksbeforeEach() / afterEach()setUp() / tearDown()
Imbricationregroupement via describe()séparation par classe
Les tests Pest étant exécutés par PHPUnit, ils cohabitent sans problème avec des tests PHPUnit existants. Lancez-les avec php artisan test ou vendor/bin/pest.

Quand utiliser describe / it / test

test()

La définition la plus simple : le nom du test sert directement de description.
test('un utilisateur peut se connecter par e-mail', function () {
    $user = User::factory()->create();

    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'password',
    ]);

    $response->assertRedirect('/dashboard');
});

it()

Permet une écriture proche du langage naturel, sous forme « it should… ».
it('redirige les utilisateurs non authentifiés vers la page de connexion', function () {
    $response = $this->get('/dashboard');

    $response->assertRedirect('/login');
});

describe()

Regroupe des tests apparentés. Utile pour partager un beforeEach() ou structurer logiquement un fichier.
describe('gestion des commandes', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->actingAs($this->user);
    });

    it('récupère la liste des commandes', function () {
        Order::factory(3)->for($this->user)->create();

        $response = $this->get('/orders');

        $response->assertOk()->assertJsonCount(3, 'data');
    });

    it("ne récupère pas les commandes des autres utilisateurs", function () {
        $other = User::factory()->create();
        Order::factory()->for($other)->create();

        $response = $this->get('/orders');

        $response->assertOk()->assertJsonCount(0, 'data');
    });
});
describe() peut être imbriqué, mais une imbrication trop profonde nuit à la lisibilité. Deux niveaux au maximum sont un bon repère.

Expectation API

expect() est la syntaxe d’assertion propre à Pest. Vous pouvez enchaîner des conditions via des appels de méthodes.

Assertions de base

expect($value)->toBe(42);               // égalité stricte (===)
expect($value)->toEqual(['a' => 1]);    // égalité lâche (==)
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($value)->toBeNull();
expect($value)->not->toBeNull();

expect($string)->toContain('Laravel');
expect($array)->toHaveCount(3);
expect($array)->toHaveKey('email');
expect($array)->toContain('admin');

expect($number)->toBeGreaterThan(0);
expect($number)->toBeLessThanOrEqual(100);

Assertions sur des modèles

expect($user)->toBeInstanceOf(User::class);
expect($user->email)->toMatchRegex('/^.+@.+\..+$/');

// Vérifier l'enregistrement en base
expect(User::where('email', '[email protected]')->exists())->toBeTrue();

Chaîne and()

expect($response->status())->toBe(200)
    ->and($response->json('name'))->toBe('Laravel')
    ->and($response->json('version'))->toBeGreaterThan(12);

Vérifier chaque élément d’un tableau avec each()

$users = User::factory(3)->create();

expect($users)->each(function ($user) {
    $user->toBeInstanceOf(User::class)
        ->email->not->toBeNull();
});

Tests paramétrés avec les datasets

Pour exécuter la même logique avec plusieurs entrées, utilisez dataset() ou with() en ligne.

Dataset en ligne

it("une adresse e-mail invalide provoque une erreur de validation", function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with([
    'texte brut' => ['not-an-email'],
    'sans domaine' => ['user@'],
    'chaîne vide' => [''],
]);

Datasets nommés

Définissez les datasets dans le répertoire tests/Datasets.
// tests/Datasets/InvalidEmails.php
dataset('invalid_emails', [
    'plain' => ['not-an-email'],
    'no-domain' => ['user@'],
    'empty' => [''],
    'spaces' => ['user @example.com'],
]);
it("une adresse e-mail invalide provoque une erreur de validation", function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with('invalid_emails');

Dataset dynamique avec closure

it("chaque plan utilisateur a sa propre limite", function (string $plan, int $limit) {
    $user = User::factory()->create(['plan' => $plan]);

    expect($user->requestLimit())->toBe($limit);
})->with([
    ['free', 100],
    ['pro', 1000],
    ['enterprise', 10000],
]);

Tests unitaires avec Mockery

Mocker un service

use App\Services\PaymentGateway;
use Mockery\MockInterface;

test('le service de paiement est invoqué', function () {
    $mock = $this->mock(PaymentGateway::class, function (MockInterface $mock) {
        $mock->expects('charge')
            ->with(1000, 'eur')
            ->andReturn(['status' => 'succeeded']);
    });

    $result = app(PaymentGateway::class)->charge(1000, 'eur');

    expect($result['status'])->toBe('succeeded');
});

Espion (spy)

Contrairement à un mock, un spy exécute l’implémentation tout en enregistrant les appels.
use App\Services\NotificationService;

test("vérifier que le service de notifications a été appelé", function () {
    $spy = $this->spy(NotificationService::class);

    $this->post('/orders', ['amount' => 1000]);

    $spy->shouldHaveReceived('send')->once()->with('order.created');
});

Mock partiel

Mocker certaines méthodes et laisser les autres utiliser l’implémentation réelle.
use App\Services\ReportService;
use Mockery\MockInterface;

test("l'écriture du fichier de rapport est passée en mock", function () {
    $mock = $this->partialMock(ReportService::class, function (MockInterface $mock) {
        $mock->expects('writeFile')->andReturnNull();
    });

    $result = $mock->generate();

    expect($result)->not->toBeNull();
});

Tests d’appels d’API externes via HTTP fake

Http::fake() permet de stubber les réponses sans envoyer de vraie requête HTTP.

Fake de base

use Illuminate\Support\Facades\Http;

test("récupère les infos utilisateur depuis une API externe", function () {
    Http::fake([
        'https://api.example.com/users/*' => Http::response([
            'id' => 1,
            'name' => 'Laravel User',
        ], 200),
    ]);

    $result = app(UserApiClient::class)->find(1);

    expect($result['name'])->toBe('Laravel User');
    Http::assertSent(fn ($request) => $request->url() === 'https://api.example.com/users/1');
});

Tester une réponse d’erreur

test("une erreur d'API provoque une exception", function () {
    Http::fake([
        'api.example.com/*' => Http::response([], 503),
    ]);

    expect(fn () => app(UserApiClient::class)->find(1))
        ->toThrow(\App\Exceptions\ApiUnavailableException::class);
});

Simuler une panne réseau

use Illuminate\Http\Client\ConnectionException;

test("gestion d'une erreur de connexion", function () {
    Http::fake(fn () => throw new ConnectionException('Connection refused'));

    $result = app(UserApiClient::class)->findWithFallback(1);

    expect($result)->toBeNull();
});

Fake d’événements, e-mails et notifications

Event::fake()

use Illuminate\Support\Facades\Event;
use App\Events\OrderCreated;

test("un événement est dispatché à la création d'une commande", function () {
    Event::fake();

    $this->post('/orders', ['product_id' => 1, 'amount' => 1000]);

    Event::assertDispatched(OrderCreated::class, function ($event) {
        return $event->order->amount === 1000;
    });
});
Vous pouvez ne fake que certains événements et laisser les autres s’exécuter réellement.
Event::fake([OrderCreated::class]);

Mail::fake()

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

test("un e-mail de bienvenue est envoyé à l'inscription", function () {
    Mail::fake();

    $this->post('/register', [
        'name' => 'Test User',
        'email' => '[email protected]',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);

    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('[email protected]');
    });
});

Notification::fake()

use Illuminate\Support\Facades\Notification;
use App\Notifications\OrderShipped;

test("une notification est envoyée lors de l'expédition", function () {
    Notification::fake();

    $user = User::factory()->create();
    $order = Order::factory()->for($user)->create();

    $this->post("/orders/{$order->id}/ship");

    Notification::assertSentTo($user, OrderShipped::class, function ($notification) use ($order) {
        return $notification->order->id === $order->id;
    });
});

Tester une commande Artisan

use Illuminate\Support\Facades\Artisan;

test("la commande de nettoyage fonctionne", function () {
    $expired = Order::factory()->create(['expires_at' => now()->subDay()]);
    $valid = Order::factory()->create(['expires_at' => now()->addDay()]);

    $this->artisan('orders:cleanup')
        ->assertSuccessful()
        ->expectsOutput('Cleaned up 1 expired order(s).');

    expect(Order::find($expired->id))->toBeNull();
    expect(Order::find($valid->id))->not->toBeNull();
});

Tester une commande interactive

test("la commande interactive s'exécute après confirmation", function () {
    $this->artisan('reports:generate')
        ->expectsQuestion('Exécuter ?', 'yes')
        ->expectsOutput('Rapport généré.')
        ->assertExitCode(0);
});

RefreshDatabase et LazilyRefreshDatabase

RefreshDatabase

Rollback la base après chaque test pour garantir un état propre. Les migrations sont exécutées au démarrage de la suite de tests.
// tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');
use Illuminate\Foundation\Testing\RefreshDatabase;

test("un utilisateur peut être créé", function () {
    $user = User::factory()->create(['name' => 'Laravel']);

    expect(User::count())->toBe(1);
    expect($user->name)->toBe('Laravel');
});

LazilyRefreshDatabase

Là où RefreshDatabase prépare les migrations pour tous les tests, LazilyRefreshDatabase diffère les migrations jusqu’à ce qu’un test modifie effectivement la base. Cela accélère les suites où de nombreux tests ne touchent pas à la base.
// tests/Pest.php
uses(LazilyRefreshDatabase::class)->in('Feature');
Pour la plupart des projets, RefreshDatabase est un choix sûr. Envisagez LazilyRefreshDatabase uniquement quand la suite est grande et comporte beaucoup de tests sans base de données.
AspectRefreshDatabaseLazilyRefreshDatabase
Moment d’exécution des migrationsdémarrage de la suitepremier accès à la base
Surcoût pour les tests sans baseouinon
Cas d’usage recommandécas généralgrande suite avec beaucoup de tests sans base

Mesure de la couverture de code

Xdebug ou PCOV est requis.
# Afficher la couverture dans le terminal
php artisan test --coverage

# Forcer un seuil minimum (échec CI en dessous)
php artisan test --coverage --min=80

# Générer un rapport HTML
vendor/bin/pest --coverage-html=coverage/

# Identifier les tests lents
php artisan test --profile
La mesure de couverture allonge significativement le temps d’exécution des tests. En CI, il est recommandé de la faire dans un job dédié ou uniquement lors des pull requests.

Pages associées

Introduction aux tests

Découvrez la façon d’écrire des tests avec Laravel et l’utilisation de php artisan test.
Dernière modification le 13 juillet 2026