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:
Aspecto
Pest
PHPUnit
Definición de test
Closures con test() / it()
Clases con métodos
Aserciones
Expectation API (expect()->toBe())
$this->assert*()
Datasets
dataset() / with()
@dataProvider
Hooks
beforeEach() / afterEach()
setUp() / tearDown()
Anidamiento
Agrupació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.
expect($user)->toBeInstanceOf(User::class);expect($user->email)->toMatchRegex('/^.+@.+\..+$/');// Verificar la persistencia en BDexpect(User::where('email', '[email protected]')->exists())->toBeTrue();
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],]);
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');});
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();});
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);});
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();});
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.
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; });});
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();});
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);});
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');});
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.
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.
# Mostrar la cobertura en la terminalphp artisan test --coverage# Cobertura mínima obligatoria (CI falla si baja)php artisan test --coverage --min=80# Generar un informe HTMLvendor/bin/pest --coverage-html=coverage/# Detectar tests lentosphp 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.