Saltar al contenido

Un test de regresión es un test que reproduce un bug que ya has tenido, para que la suite te avise si vuelve a aparecer. La idea es sencilla: antes de tocar el código, escribes un test que falla por culpa del bug, aplicas la solución y el test pasa. A partir de ahí, ese bug no puede volver sin que te enteres.

En esta entrada vamos a hacerlo con un bug de Eloquent muy habitual: un orWhere() que hace que un cliente vea pedidos de otros. Escribiremos el test en rojo, aplicaremos la solución y comprobaremos que el test falla sin ella. Después blindaremos la respuesta de la API con un snapshot de Pest, que detecta algo que el test anterior no ve: un campo que cambia de nombre. Todo está probado, en el momento de crear esta entrada, con Laravel 13.36, PHP 8.5, Pest 5.3 y MySQL 8.4 en Laravel Sail.

El bug: un orWhere() que se salta el filtro del usuario

Imagina una API con el endpoint GET /api/orders, que devuelve los pedidos pagados o enviados del usuario autenticado. Este es su controlador, app/Http/Controllers/Api/OrderController.php:

public function index(Request $request): AnonymousResourceCollection
{
    $orders = $request->user()->orders()
        ->where('status', 'paid')
        ->orWhere('status', 'shipped')
        ->latest()
        ->get();

    return OrderResource::collection($orders);
}

El código se lee bien y, si pruebas el endpoint con un solo usuario, funciona. Pero un día un cliente avisa de que en su listado aparecen pedidos que no son suyos. Si sacas el SQL de la consulta con toRawSql(), se ve el motivo:

select * from `orders` where `orders`.`user_id` = 7 and `orders`.`user_id` is not null and `status` = 'paid' or `status` = 'shipped' order by `created_at` desc

La relación orders() añade el filtro del usuario, pero no lo pone entre paréntesis. Como el AND se evalúa antes que el OR, la consulta devuelve los pedidos pagados del usuario 7 y, además, todos los pedidos enviados de la tienda, sean de quien sean.

Primero, el test en rojo

Antes de tocar el controlador, vamos a escribir un test que reproduzca el aviso del cliente. Crea el test con ./vendor/bin/sail artisan make:test OrderTest --pest y cambia el contenido de tests/Feature/OrderTest.php por el siguiente:

<?php

use App\Models\Order;
use App\Models\User;

use function Pest\Laravel\actingAs;

test('a customer only sees their own orders', function () {
    $customer = User::factory()->create();
    Order::factory()->for($customer)->create(['status' => 'paid']);
    Order::factory()->create(['status' => 'shipped']);

    actingAs($customer)
        ->getJson('/api/orders')
        ->assertOk()
        ->assertJsonCount(1, 'data');
})->issue(42);

El proyecto tiene activado RefreshDatabase en tests/Pest.php para la carpeta Feature, y los tests van contra la base de datos testing de MySQL que crea Sail. Si te fijas, el pedido del otro cliente es shipped y no paid. Lo hemos comprobado: con los 2 pedidos paid, el test pasa aunque el bug siga ahí, porque esa parte de la consulta sí filtra por usuario. Un test de regresión tiene que reproducir las condiciones exactas del fallo.

Con ->issue(42) ligamos el test a la incidencia del bug. Si añades esta línea a tests/Pest.php, con tu repositorio:

pest()->project()->github('acme/tienda');

Pest muestra el número junto al nombre del test y lo enlaza a la issue de GitHub en las terminales que admiten enlaces. Vamos a lanzarlo con ./vendor/bin/sail pest tests/Feature/OrderTest.php:

   FAIL  Tests\Feature\OrderTest
  ⨯ a customer only sees their own orders #42                            0.71s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\OrderTest > a customer only sees their own orders
  Failed to assert that the response count matched the expected 1
Failed asserting that actual size 2 matches expected size 1.

Es justo lo que contaba el cliente: 2 pedidos en lugar de 1.

La solución, y el test sin ella

Lo que queremos es una sola condición sobre el estado, y para eso está whereIn(). En el controlador, cambia las 2 líneas del where por esta:

        ->whereIn('status', ['paid', 'shipped'])

El SQL queda con and `status` in ('paid', 'shipped') y el test pasa. Si la condición fuera más compleja y necesitaras el orWhere(), agrúpala en un closure, y Laravel pone ese grupo entre paréntesis:

->where(fn ($query) => $query->where('status', 'paid')->orWhere('status', 'shipped'))

Antes de hacer commit, falta un paso: comprobar que el test falla sin la solución. Vuelve a poner el orWhere() y lanza la suite. Si el test sigue en verde, no te protege de nada, y es mejor saberlo ahora que cuando el bug vuelva. Nosotros lo hemos hecho con la suite completa: el test de regresión vuelve a fallar con el mismo mensaje y el resto sigue en verde.

Un snapshot de la respuesta

En una API hay otra regresión muy típica: un campo de la respuesta que cambia de nombre. Alguien renombra total a amount en app/Http/Resources/OrderResource.php, el test de antes sigue en verde porque solo cuenta pedidos, y la aplicación que consume la API deja de mostrar el importe. Para esto Pest tiene toMatchSnapshot(): la primera vez guarda en un archivo lo que recibe y, a partir de ahí, lo compara con ese archivo en cada ejecución.

Añade este segundo test a tests/Feature/OrderTest.php e importa travelTo con use function Pest\Laravel\travelTo;, junto al use de actingAs:

test('the orders response keeps its shape', function () {
    travelTo('2026-10-01 10:00:00');
    $customer = User::factory()->create();
    Order::factory()->for($customer)->create([
        'id' => 1001,
        'reference' => 'PED-1001',
        'status' => 'paid',
        'total' => 4990,
    ]);

    $response = actingAs($customer)->getJson('/api/orders');

    expect($response->json())->toMatchSnapshot();
});

La primera ejecución no compara nada: crea el snapshot y marca el test como incompleto para que lo revises.

   WARN  Tests\Feature\OrderTest
  ✓ a customer only sees their own orders #42                            0.93s
  … the orders response keeps its shape → Snapshot created at [tests/.p… 0.02s

  Tests:    1 incomplete, 1 passed (2 assertions)

El archivo es tests/.pest/snapshots/Feature/OrderTest/the_orders_response_keeps_its_shape.snap, y tiene la respuesta tal como la devuelve el resource:

{
    "data": [
        {
            "id": 1001,
            "reference": "PED-1001",
            "status": "paid",
            "total": 4990,
            "created_at": "2026-10-01T10:00:00.000000Z"
        }
    ]
}

Ese archivo se sube al repositorio junto al test. En la versión que hemos probado, Pest no crea los snapshots que faltan cuando se ejecuta en integración continua (lo detecta por variables como CI o GITHUB_ACTIONS): el test falla con Snapshot is missing at [...]. Run Pest with --update-snapshots to create it.

Lo que no puede cambiar entre ejecuciones

Un snapshot solo sirve si la respuesta es la misma cada vez, y por eso el test fija 4 cosas. Las hemos ido encontrando al probarlo, cada una con su fallo:

  • Con Order::factory()->create() a secas, Faker inventa la referencia y el total en cada ejecución, y la segunda ya falla con otros valores. Por eso el test los escribe a mano.
  • created_at toma la hora actual, así que también cambia. travelTo() fija el reloj del test en una fecha concreta. freezeTime() no basta, porque congela la hora a la que se ejecuta el test, y lo hemos visto fallar en la segunda ejecución.
  • En MySQL, el contador del autoincremento no vuelve atrás cuando RefreshDatabase deshace la transacción del test anterior. Sin el 'id' => 1001, el pedido sale con el id 3 al lanzar el archivo entero (el primer test crea 2 pedidos) y con el 1 al lanzar solo este test con --filter. El snapshot falla según cómo ejecutes la suite, y eso es un flaky test. En SQLite en memoria no pasa, porque el contador vuelve atrás con la transacción: es otra diferencia entre los 2 motores, como las que vimos en Tests en SQLite, producción en MySQL.
  • El test pasa $response->json(), que es un array, y Pest lo guarda como JSON con una línea por campo. Si le pasas $response, guarda el cuerpo tal como llega, en una sola línea, y el error marca la respuesta entera como distinta.

Cuando alguien renombra un campo

Vamos a hacer el cambio de antes: en el resource, 'total' => $this->total pasa a ser 'amount' => $this->total. El test de regresión sigue en verde, y el del snapshot falla y señala el campo exacto:

   FAILED  Tests\Feature\OrderTest > the orders response keeps its shape
  Failed asserting that the string value matches its snapshot (tests/.pest/snapshots/Feature/OrderTest/the_orders_response_keeps_its_shape.snap).
Failed asserting that two strings are identical.
               "id": 1001,
               "reference": "PED-1001",
               "status": "paid",
  -            "total": 4990,
  +            "amount": 4990,
               "created_at": "2026-10-01T10:00:00.000000Z"

Si el cambio no es intencionado, ya sabes qué línea deshacer. Si lo es, porque has versionado la API o has avisado a quien la consume, actualiza el snapshot de ese test:

./vendor/bin/sail pest --update-snapshots --filter="keeps its shape"

Pest reescribe el archivo y lo indica con Snapshot updated at [...]. Lo recomendable es usarlo siempre con --filter. Sin filtro, en una ejecución de toda la suite, Pest borra la carpeta tests/.pest/snapshots entera y vuelve a crear todos los snapshots. Lo hemos probado con el amount todavía en el resource: el test no avisa de que el snapshot ha cambiado, solo muestra Snapshot created at [...], así que cualquier otro cambio de forma que tuvieras pendiente pasa al snapshot sin que lo veas.

Revisa siempre el diff de los .snap antes del commit, también cuando el cambio lo hace un agente de IA: actualizar un snapshot sin mirarlo equivale a adaptar el test al bug, una de las trampas de TDD con IA.

Con estos 2 tests, el bug del orWhere() no puede volver sin que la suite falle, y la forma de la respuesta tampoco puede cambiar sin que alguien lo decida. Espero que te sirva. Si quieres que la suite se ejecute en cada push, no te pierdas el curso Laravel CI - Integración Continua con GitHub Actions.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje