Saltar al contenido principal

Qué es Pest

Pest es un framework de tests construido sobre PHPUnit que se adopta por defecto en los proyectos nuevos desde Laravel 11. Aprovecha todas las aserciones de PHPUnit y añade una sintaxis concisa basada en closures. Principales diferencias con PHPUnit:
AspectoPestPHPUnit
Definición de testClosures con test() / it()Clases con métodos
AsercionesExpectation API (expect()->toBe())$this->assert*()
Datasetsdataset() / with()@dataProvider
HooksbeforeEach() / afterEach()setUp() / tearDown()
AnidamientoAgrupación con describe()Separación por clases
Como los tests de Pest se ejecutan sobre PHPUnit, pueden convivir con tests PHPUnit existentes. Los ejecutas con php artisan test o vendor/bin/pest.

Cuándo usar describe / it / test

test()

La definición más sencilla. Usa el nombre del test como su descripción.
test('el usuario puede iniciar sesión con email', function () {
    $user = User::factory()->create();

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

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

it()

Permite un estilo cercano al lenguaje natural, tipo «it should…».
it('redirige a login a los usuarios no autenticados', function () {
    $response = $this->get('/dashboard');

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

describe()

Agrupa tests relacionados. Útil para compartir beforeEach() y organizar lógicamente los archivos.
describe('Gestión de pedidos', function () {
    beforeEach(function () {
        $this->user = User::factory()->create();
        $this->actingAs($this->user);
    });

    it('obtiene la lista de pedidos', function () {
        Order::factory(3)->for($this->user)->create();

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

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

    it('no obtiene pedidos de otros usuarios', function () {
        $other = User::factory()->create();
        Order::factory()->for($other)->create();

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

        $response->assertOk()->assertJsonCount(0, 'data');
    });
});
describe() admite anidamiento, pero un anidamiento excesivo dificulta la lectura. Como regla general, no pases de dos niveles.

Expectation API

expect() es la sintaxis de aserción propia de Pest. Permite encadenar condiciones.

Aserciones básicas

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

Aserciones sobre modelos

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

// Verificar la persistencia en BD
expect(User::where('email', '[email protected]')->exists())->toBeTrue();

Cadena con and()

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

Verificar cada elemento con each()

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

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

Tests parametrizados con datasets

Para probar la misma lógica con varios valores, usa dataset() o with() en línea.

Dataset en línea

it('los emails inválidos disparan error de validación', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

    $response->assertInvalid('email');
})->with([
    'texto plano' => ['not-an-email'],
    'sin dominio' => ['user@'],
    'cadena vacía' => [''],
]);

Dataset con nombre

Define datasets en el directorio tests/Datasets.
// tests/Datasets/InvalidEmails.php
dataset('invalid_emails', [
    'plain' => ['not-an-email'],
    'no-domain' => ['user@'],
    'empty' => [''],
    'spaces' => ['user @example.com'],
]);
it('los emails inválidos disparan error de validación', function (string $email) {
    $response = $this->post('/register', ['email' => $email]);

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

Dataset dinámico con closures

it('cada plan tiene un límite distinto', function (string $plan, int $limit) {
    $user = User::factory()->create(['plan' => $plan]);

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

Tests unitarios con Mockery

Mockear un servicio

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

test('se invoca el servicio de pagos', 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

Un spy ejecuta la implementación real y registra las llamadas.
use App\Services\NotificationService;

test('se ha invocado el servicio de notificaciones', function () {
    $spy = $this->spy(NotificationService::class);

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

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

Mock parcial

Se mockean solo algunos métodos y el resto se usa con la implementación real.
use App\Services\ReportService;
use Mockery\MockInterface;

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

    $result = $mock->generate();

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

Pruebas de llamadas a APIs externas con HTTP fake

Con Http::fake() puedes hacer stub de las respuestas sin enviar peticiones reales.

Fake básico

use Illuminate\Support\Facades\Http;

test('obtiene información de usuario desde la API externa', 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');
});

Probar respuestas de error

test('lanza una excepción cuando la API devuelve error', function () {
    Http::fake([
        'api.example.com/*' => Http::response([], 503),
    ]);

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

Simular fallos de red

use Illuminate\Http\Client\ConnectionException;

test('gestiona los errores de conexión', function () {
    Http::fake(fn () => throw new ConnectionException('Connection refused'));

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

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

Fakes de eventos, correos y notificaciones

Event::fake()

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

test('se dispara el evento al crear un pedido', function () {
    Event::fake();

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

    Event::assertDispatched(OrderCreated::class, function ($event) {
        return $event->order->amount === 1000;
    });
});
También puedes falsear solo algunos eventos y dejar que los demás se procesen normalmente.
Event::fake([OrderCreated::class]);

Mail::fake()

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

test('se envía el email de bienvenida al registrarse', 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('se envía la notificación al hacer el envío', 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;
    });
});

Pruebas de comandos Artisan

use Illuminate\Support\Facades\Artisan;

test('el comando de limpieza de datos funciona', 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();
});

Comandos interactivos

test('un comando interactivo ejecuta el proceso tras la confirmación', function () {
    $this->artisan('reports:generate')
        ->expectsQuestion('¿Quieres ejecutarlo?', 'yes')
        ->expectsOutput('Report generado.')
        ->assertExitCode(0);
});

RefreshDatabase y LazilyRefreshDatabase

RefreshDatabase

Hace rollback de la BD tras cada test para mantenerla limpia. Ejecuta las migraciones al inicio del test suite.
// tests/Pest.php
uses(RefreshDatabase::class)->in('Feature');
use Illuminate\Foundation\Testing\RefreshDatabase;

test('permite crear un usuario', function () {
    $user = User::factory()->create(['name' => 'Laravel']);

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

LazilyRefreshDatabase

RefreshDatabase comprueba migraciones en todos los tests, pero LazilyRefreshDatabase las difiere hasta que un test toca de verdad la BD. Acelera los suites con muchos tests que no usan BD.
// tests/Pest.php
uses(LazilyRefreshDatabase::class)->in('Feature');
Para la mayoría de proyectos, RefreshDatabase es la opción segura. Considera LazilyRefreshDatabase cuando el suite es grande y muchos tests no tocan la BD.
AspectoRefreshDatabaseLazilyRefreshDatabase
Momento de ejecución de las migracionesAl iniciar el test suite.En la primera operación de BD.
Sobrecoste en tests sin BDSí.No.
Cuándo usarloCaso general.Suite grande con muchos tests sin BD.

Medición de cobertura de código

Requiere Xdebug o PCOV.
# Mostrar la cobertura en la terminal
php artisan test --coverage

# Cobertura mínima obligatoria (CI falla si baja)
php artisan test --coverage --min=80

# Generar un informe HTML
vendor/bin/pest --coverage-html=coverage/

# Detectar tests lentos
php artisan test --profile
La medición de cobertura aumenta mucho el tiempo de ejecución. En CI, es recomendable separarla en un job específico o ejecutarla solo en las pull requests.

Páginas relacionadas

Introducción a los tests

Consulta la forma estándar de escribir tests en Laravel y el uso de php artisan test.
Última modificación el 13 de julio de 2026