Skip to content

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 Pest

Tu 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 proyectos

Aserciones 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 ​

  1. Escribe un test que verifique que un usuario no admin recibe 403 al entrar a /admin, y un admin recibe 200.
  2. 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 con php 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/postJson para 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.

Academia Web — aprende, copia y construye.