Vai al contenuto principale

Cos’è Pest

Pest è un framework di test costruito sopra PHPUnit; nei nuovi progetti Laravel 11 e successivi è adottato per default. Puoi riutilizzare tutte le assertion di PHPUnit scrivendo però test con una sintassi concisa basata su closure. Differenze principali con PHPUnit:
AspettoPestPHPUnit
Definizione dei testClosure test() / it()Classi con metodi
AssertionExpectation API (expect()->toBe())$this->assert*()
Datasetdataset() / with()@dataProvider
HookbeforeEach() / afterEach()setUp() / tearDown()
NestingRaggruppamento con describe()Separazione per classe
I test Pest vengono eseguiti da PHPUnit, quindi coesistono con i test PHPUnit esistenti. Esegui con php artisan test o vendor/bin/pest.

Quando usare describe / it / test

test()

Definizione più semplice. Il nome del test è la sua descrizione.
test('l\'utente può fare login con la sua email', function () {
    $user = User::factory()->create();

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

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

it()

Sintassi “it should…” in stile inglese, più vicina al linguaggio naturale.
it('reindirizza gli utenti non autenticati alla pagina di login', function () {
    $response = $this->get('/dashboard');

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

describe()

Raggruppa test correlati. Utile per condividere beforeEach() e organizzare logicamente il file.
describe('Gestione ordini', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->actingAs($this->user);
    });

    it('può ottenere l\'elenco degli ordini', function () {
        Order::factory(3)->for($this->user)->create();

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

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

    it('non può ottenere gli ordini di altri utenti', function () {
        $other = User::factory()->create();
        Order::factory()->for($other)->create();

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

        $response->assertOk()->assertJsonCount(0, 'data');
    });
});
describe() può essere annidato, ma nesting troppo profondi rendono i test difficili da leggere. Rimani su un massimo di 2 livelli.

Expectation API

expect() è la sintassi di assertion specifica di Pest. Puoi concatenare le condizioni.

Assertion di base

expect($value)->toBe(42);               // uguaglianza stretta (===)
expect($value)->toEqual(['a' => 1]);    // uguaglianza lasca (==)
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);

Assertion su modelli

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

// verifica il salvataggio nel database
expect(User::where('email', '[email protected]')->exists())->toBeTrue();

Chain and()

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

each() per validare ogni elemento di un array

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

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

Test parametrici con i dataset

Per testare la stessa logica con più input usa dataset() o l’inline with().

Dataset inline

it('le email non valide restituiscono un errore di validazione', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with([
    'testo semplice' => ['not-an-email'],
    'senza dominio' => ['user@'],
    'stringa vuota' => [''],
]);

Dataset con nome

Definisci i dataset nella directory tests/Datasets.
// tests/Datasets/InvalidEmails.php
dataset('invalid_emails', [
    'plain' => ['not-an-email'],
    'no-domain' => ['user@'],
    'empty' => [''],
    'spaces' => ['user @example.com'],
]);
it('le email non valide restituiscono un errore di validazione', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

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

Dataset dinamico con closure

it('ogni piano utente ha un limite diverso', function (string $plan, int $limit) {
    $user = User::factory()->create(['plan' => $plan]);

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

Test unitari con Mockery

Mock di un servizio

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

test('il servizio di pagamento viene chiamato', function () {
    $mock = $this->mock(PaymentGateway::class, function (MockInterface $mock) {
        $mock->expects('charge')
            ->with(1000, 'jpy')
            ->andReturn(['status' => 'succeeded']);
    });

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

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

Spy

Uno spy, a differenza di un mock, chiama l’implementazione reale registrando comunque le chiamate.
use App\Services\NotificationService;

test('verifica che il servizio di notifica sia stato chiamato', function () {
    $spy = $this->spy(NotificationService::class);

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

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

Mock parziale

Mocka solo alcuni metodi, gli altri usano l’implementazione reale.
use App\Services\ReportService;
use Mockery\MockInterface;

test('salta la scrittura del file di report', function () {
    $mock = $this->partialMock(ReportService::class, function (MockInterface $mock) {
        $mock->expects('writeFile')->andReturnNull();
    });

    $result = $mock->generate();

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

Test di chiamate API esterne con HTTP fake

Con Http::fake() stubbi le risposte senza inviare richieste HTTP reali.

Fake di base

use Illuminate\Support\Facades\Http;

test('ottiene le informazioni utente da un\'API esterna', 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');
});

Test di risposte di errore

test('viene sollevata un\'eccezione quando l\'API restituisce un errore', function () {
    Http::fake([
        'api.example.com/*' => Http::response([], 503),
    ]);

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

Simulazione di errori di rete

use Illuminate\Http\Client\ConnectionException;

test('gestisce gli errori di connessione', function () {
    Http::fake(fn () => throw new ConnectionException('Connection refused'));

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

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

Fake di eventi, email e notifiche

Event::fake()

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

test('emette un evento quando viene creato un ordine', function () {
    Event::fake();

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

    Event::assertDispatched(OrderCreated::class, function ($event) {
        return $event->order->amount === 1000;
    });
});
Puoi fakeare solo eventi specifici lasciando gli altri normali.
Event::fake([OrderCreated::class]);

Mail::fake()

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

test('invia una email di benvenuto alla registrazione', 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('invia una notifica alla spedizione', 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;
    });
});

Test dei comandi Artisan

use Illuminate\Support\Facades\Artisan;

test('il comando per la pulizia dei dati funziona', 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();
});

Test di comandi interattivi

test('un comando interattivo esegue dopo la conferma', function () {
    $this->artisan('reports:generate')
        ->expectsQuestion('Vuoi eseguire?', 'yes')
        ->expectsOutput('Report generato.')
        ->assertExitCode(0);
});

RefreshDatabase e LazilyRefreshDatabase

RefreshDatabase

Dopo ogni test fa rollback del database, mantenendolo pulito. All’avvio della test suite esegue la migrazione.
// tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');
use Illuminate\Foundation\Testing\RefreshDatabase;

test('può creare un utente', function () {
    $user = User::factory()->create(['name' => 'Laravel']);

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

LazilyRefreshDatabase

Con RefreshDatabase la migrazione viene verificata in tutti i test; con LazilyRefreshDatabase viene rimandata finché un test non modifica effettivamente il DB. Se hai molti test che non toccano il DB, velocizza la suite.
// tests/Pest.php
uses(LazilyRefreshDatabase::class)->in('Feature');
Per la maggior parte dei progetti, RefreshDatabase è la scelta sicura. Valuta LazilyRefreshDatabase se la test suite è grande e molti test non usano il DB.
AspettoRefreshDatabaseLazilyRefreshDatabase
Momento della migrazioneAll’avvio della suiteAlla prima operazione DB
Overhead sui test senza DBNo
Caso consigliatoCaso generaleTest numerosi con molti senza DB

Misurare la code coverage

Servono Xdebug o PCOV.
# mostra la coverage nel terminale
php artisan test --coverage

# imposta una coverage minima (fallisce la CI se sotto soglia)
php artisan test --coverage --min=80

# genera un report HTML
vendor/bin/pest --coverage-html=coverage/

# individua i test più lenti
php artisan test --profile
La misurazione della code coverage aumenta molto il tempo di esecuzione dei test. In CI consigliamo di separarla in un job dedicato o di eseguirla solo sulle pull request.

Pagine correlate

Introduzione ai test

Rivedi le basi dei test in Laravel e l’uso di php artisan test.
Ultima modifica il 13 luglio 2026