Testing en Laravel con Pest: Guía práctica paso a paso
Testing en Laravel con Pest: Guía práctica paso a paso
Si has estado escribiendo tests en Laravel, probablemente conoces PHPUnit. Es potente, pero su sintaxis puede ser verbosa y poco intuitiva para nuevos desarrolladores. Pest PHP es una alternativa moderna que simplifica enormemente el proceso de testing manteniendo toda la potencia subyacente.
En este artículo, exploraremos cómo instalar, configurar y escribir tests efectivos con Pest en Laravel, desde lo más básico hasta patrones avanzados.
¿Qué es Pest y por qué deberías usarlo?
Pest es un framework de testing para PHP que se construye sobre PHPUnit, pero con una sintaxis más limpia, intuitiva y similar a la de Jest (JavaScript). Aunque se menciona en la lista de artículos publicados la extensión de VS Code para Pest, nunca hemos cubierto un tutorial completo sobre cómo usarlo.
Las ventajas principales de Pest son:
- Sintaxis más legible: Las pruebas parecen casi historias en lugar de código técnico
- Curva de aprendizaje menor: Ideal para desarrolladores junior
- Excelente integración con Laravel: Diseñado específicamente teniendo en mente el ecosistema Laravel
- Características poderosas: Assertions fluidas, test coverage, fixtures y más
- Mejor output en la terminal: Los resultados de tests son más visuales y fáciles de entender
Instalación de Pest en Laravel
Instalar Pest en un proyecto Laravel existente es muy simple. Solo necesitas ejecutar un comando:
composer require pestphp/pest --dev
php artisan pest:install
El comando pest:install configura automáticamente la estructura de directorios y crea los archivos necesarios. Una vez instalado, tu estructura de directorios incluirá dos carpetas principales en tests/:
tests/
├── Feature/
├── Unit/
└── Pest.php
- Feature: Para tests que prueban características completas (controllers, rutas, middlewares)
- Unit: Para tests de componentes individuales (modelos, métodos específicos)
Configuración básica
El archivo tests/Pest.php es donde ocurre la magia. Aquí defines helpers globales y configuraciones compartidas:
<?php
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(TestCase::class, RefreshDatabase::class)
->in('Feature');
uses(TestCase::class)
->in('Unit');
Esta configuración aplica automáticamente traits a todos los tests en cada directorio. RefreshDatabase resetea la base de datos entre tests, esencial para tests de integración.
Tu primer test con Pest
Veamos un ejemplo simple. Crearemos un test para una ruta que devuelve un listado de usuarios.
Primero, crea un test en tests/Feature/UsersTest.php:
<?php
test('usuarios pueden listar todos los usuarios', function () {
// Arrange
User::factory()->count(3)->create();
// Act
$response = $this->get('/api/users');
// Assert
$response->assertStatus(200)
->assertJsonCount(3);
});
¿Ves la diferencia? No hay clases ni métodos complicados. Solo test(), una descripción, y tu lógica. Es Python-like, es hermoso.
Tests unitarios con Pest
Los tests unitarios son más simples. Por ejemplo, probar un método de un modelo:
<?php
test('usuario puede obtener nombre completo', function () {
$user = User::factory()->create([
'first_name' => 'Juan',
'last_name' => 'Pérez'
]);
expect($user->fullName())->toBe('Juan Pérez');
});
Aquí usamos expect() en lugar de assertions tradicionales. Es más fluida y fácil de leer.
Organizando tests con describe y describe groups
Cuando tus tests crecen, es útil organizarlos. Pest ofrece describe() para agrupar tests relacionados:
<?php
describe('UserController', function () {
describe('index', function () {
test('retorna todos los usuarios', function () {
$response = $this->get('/users');
$response->assertStatus(200);
});
test('puede filtrar por rol', function () {
User::factory()->create(['role' => 'admin']);
User::factory()->create(['role' => 'user']);
$response = $this->get('/users?role=admin');
$response->assertJsonCount(1);
});
});
describe('store', function () {
test('crea un nuevo usuario', function () {
$response = $this->post('/users', [
'name' => 'Carlos',
'email' => 'carlos@example.com',
'password' => 'password123'
]);
$response->assertStatus(201);
expect(User::where('email', 'carlos@example.com')->exists())->toBeTrue();
});
test('valida email duplicado', function () {
User::factory()->create(['email' => 'test@example.com']);
$response = $this->post('/users', [
'name' => 'Test',
'email' => 'test@example.com',
'password' => 'password123'
]);
$response->assertStatus(422);
});
});
});
Esta estructura es mucho más legible y mantenible.
Usando fixtures y datasets
Pest permite reutilizar datos con fixtures:
<?php
test('usuario puede actualizar su perfil', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->put("/users/{$user->id}", [
'name' => 'Nuevo Nombre',
'email' => 'nuevo@example.com'
]);
$response->assertStatus(200);
expect($user->fresh()->name)->toBe('Nuevo Nombre');
});
Para múltiples casos con datos diferentes, usa datasets:
<?php
test('validación de email', function ($email, $valido) {
$response = $this->post('/register', [
'email' => $email,
'password' => 'password123'
]);
$valido
? $response->assertStatus(201)
: $response->assertStatus(422);
})->with([
['test@example.com', true],
['invalid-email', false],
['', false],
['test@test.com', true],
]);
Assertions más comunes en Pest
Pest proporciona una gran variedad de assertions optimizadas:
<?php
// Assertions JSON
$response->assertJson(['status' => 'success']);
$response->assertJsonPath('user.name', 'Juan');
$response->assertJsonCount(5, 'data');
// Assertions de base de datos
$this->assertDatabaseHas('users', ['email' => 'test@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'deleted@example.com']);
// Assertions de modelos
$user = User::factory()->create();
expect($user->is_active)->toBeTrue();
expect($user->email)->toContain('@');
// Assertions de excepciones
$this->expectException(ModelNotFoundException::class);
User::findOrFail(99999);
Testing de APIs con Pest
Para APIs, Pest proporciona helpers específicos:
<?php
test('obtener usuario retorna datos correctos', function () {
$user = User::factory()->create();
$this->getJson("/api/users/{$user->id}")
->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
]
]);
});
test('crear usuario requiere autenticación', function () {
$this->postJson('/api/users', [
'name' => 'Test',
'email' => 'test@example.com'
])->assertStatus(401);
});
test('usuario autenticado puede crear recursos', function () {
$user = User::factory()->create();
$this->actingAs($user)
->postJson('/api/posts', [
'title' => 'Mi primer post',
'content' => 'Contenido interesante'
])
->assertStatus(201)
->assertJsonPath('data.title', 'Mi primer post');
});
Ejecutando tests con Pest
Ejecutar tests es simple:
# Ejecutar todos los tests
php artisan pest
# Ejecutar un archivo específico
php artisan pest tests/Feature/UsersTest.php
# Ejecutar un test específico
php artisan pest --filter="usuarios pueden listar"
# Con coverage
php artisan pest --coverage
# Watch mode (rerun tests al cambiar archivos)
php artisan pest --watch
El output es limpio y visual, mostrando exactamente qué pasó:
PASS tests/Feature/UsersTest.php
✓ usuarios pueden listar todos los usuarios
✓ usuario puede obtener nombre completo
✓ validación de email
Tests: 3 passed (45 assertions)
Mejores prácticas con Pest
1. Usa nombres descriptivos
// ✅ Bueno
test('usuario no autenticado no puede acceder a dashboard', function () {
$this->get('/dashboard')->assertRedirect('/login');
});
// ❌ Malo
test('test', function () {
// ...
});
2. Sigue el patrón AAA (Arrange, Act, Assert)
// ✅ Bueno: Claramente separado
test('crear producto valida nombre requerido', function () {
// Arrange
$datos = ['price' => 99.99]; // nombre falta
// Act
$response = $this->post('/products', $datos);
// Assert
$response->assertStatus(422);
});
// ❌ Malo: Todo mezclado
test('crear producto', function () {
$producto = new Product();
$producto->price = 99.99;
$this->post('/products', $producto->toArray());
// ¿qué se está testeando?
});
3. Un concepto por test
// ✅ Bueno: Cada test verifica una cosa
test('usuario requiere email único', function () {
User::factory()->create(['email' => 'test@example.com']);
$response = $this->post('/register', [
'name' => 'Test',
'email' => 'test@example.com'
]);
$response->assertStatus(422);
});
test('usuario requiere contraseña mínimo 8 caracteres', function () {
$response = $this->post('/register', [
'name' => 'Test',
'email' => 'unique@example.com',
'password' => 'short'
]);
$response->assertStatus(422);
});
Integración con CI/CD
Pest se integra perfectamente con pipelines de CI/CD. Aquí un ejemplo con GitHub Actions:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- name: Install dependencies
run: composer install
- name: Run tests
run: php artisan pest --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
Puntos clave
- Pest simplifica el testing: Sintaxis más legible que PHPUnit, perfecta para nuevos desarrolladores
- Describe y describe groups: Organiza tus tests en grupos coherentes para mejor mantenibilidad
- Usa datasets para casos múltiples: Evita repetir lógica cuando necesitas probar múltiples escenarios
- AAA pattern: Mantén tus tests claros separando Arrange, Act y Assert
- Un concepto por test: Cada test debe verificar una cosa específica, facilita el debugging
- Assertions fluidas: Pest proporciona assertions intuitivas para JSON, base de datos, modelos y excepciones
- Testing de APIs: Especializado con helpers como
actingAs(),getJson()ypostJson() - Watch mode: Usa
--watchdurante desarrollo para rerun tests automáticamente - Coverage reports:
--coveragete muestra qué código está testeado y qué no - CI/CD integration: Se integra perfectamente con GitHub Actions y otros servicios