Tema
Testing en Laravel
¿Cómo sabes que al agregar una función nueva no rompiste el login o el carrito? Probando todo a mano cada vez… o escribiendo tests automáticos que lo comprueban en segundos. En esta lección aprenderás a escribir tests de funcionalidad (feature) y unitarios con Pest y PHPUnit, usar factories, simular correos y colas, y probar tu API.
Concepto
Un test es un pequeño programa que usa tu aplicación como lo haría una persona y verifica que el resultado sea el esperado. Es como una lista de verificación de un piloto antes de despegar, pero ejecutada por la computadora.
- Feature tests: prueban una funcionalidad completa a través de HTTP ("si un usuario envía este formulario, se crea un proyecto y lo redirige").
- Unit tests: prueban una pieza aislada ("este método calcula bien el IVA").
Laravel 11/12 te deja elegir Pest (sintaxis moderna y corta, la opción por defecto del instalador) o PHPUnit (clásico, basado en clases). Pest corre sobre PHPUnit, así que los conceptos son los mismos. Aquí verás ambos.
Configuración
Los tests viven en tests/Feature y tests/Unit. El archivo phpunit.xml ya configura un entorno de pruebas con SQLite en memoria (rápido y sin tocar tu base real):
html
<!-- phpunit.xml (fragmento) -->
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>Si esas líneas de DB_ están comentadas, descoméntalas.
bash
# Ejecutar todos los tests
php artisan test
# Sólo un archivo o un filtro por nombre
php artisan test tests/Feature/ProjectTest.php
php artisan test --filter=puede_crear
# En paralelo (más rápido en proyectos grandes)
php artisan test --parallel
# Crear tests
php artisan make:test ProjectTest # feature
php artisan make:test PriceCalculatorTest --unit
php artisan make:test ProjectTest --pest # forzar PestTu primer test (Pest)
Supón un CRUD de proyectos protegido con login y una ProjectPolicy donde cada usuario sólo ve lo suyo.
php
<?php
// tests/Feature/ProjectTest.php
use App\Models\Project;
use App\Models\User;
// Pest: con "uses" en tests/Pest.php ya se aplica RefreshDatabase a Feature
// (verifica que tenga ->use(Illuminate\Foundation\Testing\RefreshDatabase::class))
test('un invitado es redirigido al login', function () {
$this->get('/projects')->assertRedirect('/login');
});
test('un usuario ve sólo sus proyectos', function () {
$ana = User::factory()->create();
$beto = User::factory()->create();
Project::factory()->for($ana)->create(['name' => 'Web de Ana']);
Project::factory()->for($beto)->create(['name' => 'App de Beto']);
$this->actingAs($ana) // simula sesión iniciada
->get('/projects')
->assertOk() // 200
->assertSee('Web de Ana')
->assertDontSee('App de Beto');
});
test('un usuario puede crear un proyecto', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/projects', [
'name' => 'Tienda en línea',
'description' => 'E-commerce para la panadería',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
// Verifica que se guardó en la BD
$this->assertDatabaseHas('projects', [
'name' => 'Tienda en línea',
'user_id' => $user->id,
]);
});
test('el nombre es obligatorio', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/projects', ['name' => ''])
->assertSessionHasErrors('name');
expect(Project::count())->toBe(0);
});
test('no se puede borrar un proyecto ajeno', function () {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$project = Project::factory()->for($owner)->create();
$this->actingAs($intruder)
->delete("/projects/{$project->id}")
->assertForbidden(); // 403
$this->assertModelExists($project);
});tests/Pest.php debe tener algo así (el instalador lo crea):
php
<?php
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');RefreshDatabase ejecuta las migraciones y deja la base limpia en cada test, así un test no afecta a otro.
El mismo test con PHPUnit
php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class ProjectTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function un_usuario_puede_crear_un_proyecto(): void
{
// Arrange (preparar)
$user = User::factory()->create();
// Act (actuar)
$response = $this->actingAs($user)->post('/projects', [
'name' => 'Tienda en línea',
]);
// Assert (verificar)
$response->assertRedirect();
$this->assertDatabaseHas('projects', ['name' => 'Tienda en línea', 'user_id' => $user->id]);
}
#[Test]
public function no_se_puede_borrar_un_proyecto_ajeno(): void
{
$project = Project::factory()->create();
$intruder = User::factory()->create();
$this->actingAs($intruder)
->delete(route('projects.destroy', $project))
->assertForbidden();
}
}Buena práctica
Sigue el patrón Arrange – Act – Assert (preparar, actuar, verificar) y nombra los tests como frases: un_usuario_no_verificado_no_puede_publicar. Cuando falle, el nombre te dirá qué se rompió.
Factories para preparar datos
Las factories crean datos falsos realistas. Ejemplo de ProjectFactory:
php
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProjectFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(), // crea un usuario si no se indica
'name' => fake()->sentence(3),
'description' => fake()->paragraph(),
'status' => 'active',
];
}
// Estado reutilizable: Project::factory()->closed()->create()
public function closed(): static
{
return $this->state(fn () => ['status' => 'closed']);
}
}php
Project::factory()->count(5)->create(); // 5 proyectos
Project::factory()->for($user)->closed()->create(); // de un usuario, cerrado
User::factory()->has(Project::factory()->count(3))->create(); // usuario con 3 proyectosAserciones más útiles
php
// Respuesta HTTP
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401
$response->assertUnprocessable(); // 422
$response->assertRedirect('/dashboard');
$response->assertViewIs('projects.index');
$response->assertViewHas('projects');
$response->assertSee('Texto');
// Sesión y validación
$response->assertSessionHas('status', 'Proyecto creado.');
$response->assertSessionHasErrors(['name', 'email']);
// Base de datos
$this->assertDatabaseHas('users', ['email' => 'ana@example.com']);
$this->assertDatabaseMissing('projects', ['id' => 5]);
$this->assertDatabaseCount('tasks', 3);
$this->assertSoftDeleted($project);
// Autenticación
$this->assertAuthenticated();
$this->assertGuest();
// Pest: expectativas encadenables
expect($total)->toBe(116.0)->toBeFloat();
expect($user->isAdmin())->toBeTrue();
expect($lista)->toHaveCount(3)->toContain('rojo');Probar APIs (JSON)
php
use Laravel\Sanctum\Sanctum;
test('lista las tareas del usuario en JSON', function () {
$user = User::factory()->create();
Task::factory()->count(3)->for($user)->create();
Task::factory()->count(2)->create(); // de otros usuarios
Sanctum::actingAs($user); // autentica con Sanctum (sin token real)
$this->getJson('/api/tasks')
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [['id', 'title', 'completed', 'due_date']],
'meta' => ['total', 'current_page'],
]);
});
test('valida el título al crear una tarea', function () {
Sanctum::actingAs(User::factory()->create());
$this->postJson('/api/tasks', ['title' => ''])
->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
test('sin token responde 401', function () {
$this->getJson('/api/tasks')->assertUnauthorized();
});Fíjate en getJson / postJson: envían Accept: application/json por ti.
Simular correos, colas, eventos y archivos (fakes)
No quieres enviar correos reales al correr tests. Laravel permite "falsificar" estos servicios y luego verificar qué se habría hecho:
php
use App\Jobs\GenerateMonthlyReport;
use App\Mail\OrderShipped;
use App\Events\UserRegistered;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
test('al enviar un pedido se manda el correo', function () {
Mail::fake();
$order = Order::factory()->create();
$this->actingAs(User::factory()->admin()->create())
->post(route('orders.ship', $order));
// Como OrderShipped implementa ShouldQueue, se verifica con assertQueued
Mail::assertQueued(OrderShipped::class, fn ($mail) => $mail->hasTo($order->user->email));
});
test('solicitar el reporte encola el job', function () {
Queue::fake();
$this->actingAs($user = User::factory()->create())
->post('/reports');
Queue::assertPushed(GenerateMonthlyReport::class, fn ($job) => $job->user->is($user));
});
test('el registro dispara el evento', function () {
Event::fake([UserRegistered::class]);
$this->post('/register', [
'name' => 'Ana', 'email' => 'ana@example.com',
'password' => 'Secreta123!', 'password_confirmation' => 'Secreta123!',
]);
Event::assertDispatched(UserRegistered::class);
});
test('sube el avatar', function () {
Storage::fake('public'); // disco falso en memoria/temporal
$user = User::factory()->create();
$file = UploadedFile::fake()->image('foto.jpg', 300, 300)->size(500); // KB
$this->actingAs($user)
->put(route('avatar.update'), ['avatar' => $file])
->assertSessionHasNoErrors();
$user->refresh();
Storage::disk('public')->assertExists($user->avatar_path);
});Tests unitarios
Para lógica pura, sin HTTP ni base de datos:
php
<?php
// app/Support/PriceCalculator.php
namespace App\Support;
class PriceCalculator
{
public function __construct(private float $taxRate = 0.16) {}
public function total(float $subtotal, float $discountPercent = 0): float
{
if ($discountPercent < 0 || $discountPercent > 100) {
throw new \InvalidArgumentException('Descuento inválido');
}
$conDescuento = $subtotal * (1 - $discountPercent / 100);
return round($conDescuento * (1 + $this->taxRate), 2);
}
}php
<?php
// tests/Unit/PriceCalculatorTest.php
use App\Support\PriceCalculator;
test('suma el IVA', function () {
expect((new PriceCalculator())->total(100))->toBe(116.0);
});
test('aplica descuento antes del IVA', function () {
expect((new PriceCalculator())->total(100, 10))->toBe(104.4);
});
test('rechaza descuentos fuera de rango', function () {
(new PriceCalculator())->total(100, 150);
})->throws(InvalidArgumentException::class);
// Dataset: el mismo test con varios valores
test('calcula totales', function (float $subtotal, float $desc, float $esperado) {
expect((new PriceCalculator())->total($subtotal, $desc))->toBe($esperado);
})->with([
[200, 0, 232.0],
[50, 50, 29.0],
[0, 0, 0.0],
]);Error común
Tests que pasan solos pero fallan juntos. Casi siempre es porque falta RefreshDatabase y un test deja datos que afectan al siguiente. Otro clásico: usar MAIL_MAILER=smtp en testing y enviar correos reales.
Buena práctica
No necesitas 100% de cobertura. Prioriza lo que más dolería si fallara: login, permisos (que nadie vea datos ajenos), pagos, cálculos de dinero y validaciones críticas. Ejecuta php artisan test antes de cada despliegue (o automáticamente con GitHub Actions).
Ejercicios
- Escribe un test que verifique que un usuario no admin recibe 403 al entrar a
/admin, y un admin recibe 200. - Escribe un test para la API:
DELETE /api/tasks/{id}de una tarea propia responde 204 y la borra; de una tarea ajena responde 404.
Ver solución 1
php
test('sólo admins entran al panel', function () {
$cliente = User::factory()->create(['role' => 'client']);
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($cliente)->get('/admin')->assertForbidden();
$this->actingAs($admin)->get('/admin')->assertOk();
});Tip: crea un estado admin() en UserFactory para escribir User::factory()->admin()->create().
Ver solución 2
php
use App\Models\Task;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
test('borra su propia tarea', function () {
$user = User::factory()->create();
$task = Task::factory()->for($user)->create();
Sanctum::actingAs($user);
$this->deleteJson("/api/tasks/{$task->id}")->assertNoContent();
$this->assertModelMissing($task);
});
test('no puede borrar una tarea ajena', function () {
$task = Task::factory()->create(); // de otro usuario
Sanctum::actingAs(User::factory()->create());
$this->deleteJson("/api/tasks/{$task->id}")->assertNotFound();
$this->assertModelExists($task);
});Resumen
- Ejecuta tests con
php artisan test; créalos conphp artisan make:test. - Pest (por defecto) y PHPUnit comparten las mismas aserciones de Laravel.
RefreshDatabase+ SQLite en memoria = tests rápidos y aislados.actingAs()simula login;Sanctum::actingAs()para APIs;getJson/postJsonpara JSON.- Usa factories con estados para preparar datos.
Mail::fake(),Queue::fake(),Event::fake(),Storage::fake()evitan efectos reales.- Prioriza tests de permisos, login, dinero y validaciones.