Ir al contenido principal
Laravel, shipping fast.

Una suite que solo demuestra el camino feliz ha demostrado que tu API funciona cuando nada sale mal, que es la única condición para la que no necesitabas pruebas. Los bugs interesantes viven en lo que pasa cuando el proveedor está lento, equivocado o ausente.

it('treats a 404 on delete as success', function () {
    actingAsConsumer('licenses:write');
    Http::fake(['*' => Http::response([], 404)]);

    Licenses::delete('already-gone');
})->throwsNoExceptions();

it('lets a 500 on delete fail', function () {
    actingAsConsumer('licenses:write');
    Http::fake(['*' => Http::response([], 500)]);

    Licenses::delete('boom');
})->throws(RequestException::class);

Estos dos tests parecen casi idénticos y significan cosas opuestas. Un 404 al borrar significa que lo que querías que desapareciera ha desaparecido. Un 500 significa que el proveedor se rompió, y el job del Capítulo 10 tiene que ver la excepción para reintentar. Fúndelos en un solo test de «maneja los errores con elegancia» y nunca notarías que alguien intercambió los dos comportamientos. Escritos por separado, la distinción forma parte de la especificación.

Los reintentos también son un modo de fallo:

it('retries when the provider throttles', function () {
    actingAsConsumer('licenses:read');

    Http::fake(['*' => Http::sequence()
        ->push(status: 429, headers: ['Retry-After' => '2'])
        ->push(['data' => []]),
    ]);

    Licenses::all();

    Http::assertSentCount(2);
    Sleep::assertSleptTimes(1);
});

La primera llamada se topa con el límite, la segunda sale bien, y el test demuestra tanto que el reintento ocurrió como que el cliente esperó. Gracias a Sleep::fake(), la espera no duró nada.

Y aquel en el que este libro se ha apoyado desde el Capítulo 4: el llamante equivocado.

it('refuses a caller without the ability', function (
    string $ability,
    string $method,
    string $uri,
) {
    actingAsConsumer($ability);

    $this->json($method, $uri)->assertForbidden();
})->with([
    ['licenses:read', 'POST', '/api/licenses'],
    ['licenses:read', 'DELETE', '/api/licenses/lic_1'],
    ['licenses:write', 'GET', '/api/licenses'],
    ['licenses:write', 'GET', '/api/consumers'],
    ['licenses:write', 'POST', '/api/consumers/1/tokens'],
    ['licenses:write', 'PATCH', '/api/consumers/1'],
    ['licenses:write', 'DELETE', '/api/consumers/1'],
    ['licenses:read', 'POST', '/api/license-batches'],
]);

Una fila por ruta y por llamante al que hay que rechazar. El Capítulo 19 llama a esta tabla la página más valiosa que produce una revisión de seguridad. Aquí está como un test que se ejecuta en cada commit.

El test que lo habría atrapado

He visto a un proveedor cambiar su respuesta sin anunciarlo, y no por descuido. Un campo que siempre había estado presente empezó a llegar como null, o a faltar, porque un cambio en su origen decidió que una lista vacía no merecía serializarse. Nada en el contrato decía que eso no pudiera pasar. La mayoría de las suites de tests tampoco lo comprueban, porque el test del camino feliz es fácil de escribir y el test de «¿y si el campo simplemente no está?» exige imaginar primero el fallo.

it('lists a license that has no domains', function () {
    actingAsConsumer('licenses:read');

    fakeProvider([[
        'key' => 'k1',
        'name' => 'No Domains',
        'created_at' => '2026-01-28T10:30:00Z',
    ]]);

    $this->getJson('/api/licenses')
        ->assertOk()
        ->assertJsonPath('data.0.domains', []);
});

La simulación no devuelve un domains vacío. Omite el campo, y el test comprueba que la API no se cae y no deja escapar un null en un campo que sus consumidores esperan que sea un array. El código que hace pasar esto es pequeño: una regla nullable y un ?? [] en License::fromProvider(). El test es lo que los convierte en una decisión y no en un accidente, comprobada otra vez en cada cambio futuro.

Ese es el argumento a favor de probar el fallo: no que atrape el bug de hoy, sino que atrapa la versión que todavía no ha ocurrido.

No se pudo cargar el audio. Inténtalo de nuevo en un momento.