Una opinión polémica: la mayor parte de tu suite deberían ser pruebas de integración, no unitarias. No «debería inclinarse hacia ahí». Casi por completo. api-server tiene 72 ficheros de prueba bajo tests/Feature y cero bajo tests/Unit. api-license tiene tres y ningún directorio unitario. merchants, la aplicación más grande de la flota, ejecuta 187 pruebas de integración frente a 3 unitarias.
¿Por qué? Una prueba unitaria demuestra que una función funciona aislada. Una API no publica funciones aisladas: publica un ciclo petición/respuesta que atraviesa middleware, validación, una facade, una llamada externa, un resource y un wrapper de respuesta.
Request
↓
EnsureTokenIsValidMiddleware
↓
CreateLicenseRequest (validation)
↓
LicenseFacade → StatamicAPI (external call)
↓
LicenseDTOMapper → LicenseResource
↓
ModelResponse
Prueba unitariamente cualquier caja de esa cadena y no habrás demostrado nada sobre si la cadena funciona. El middleware podría rechazar un token que la facade habría aceptado. El mapper podría atragantarse con una forma que el resource nunca produce en la práctica. Una prueba de integración ejercita la tubería entera como lo hará producción, que es el único sitio donde viven de verdad los fallos en aplicaciones Laravel: en las costuras entre componentes, no dentro de ellos.
Las pocas pruebas unitarias que existen se ganan su sitio. api-ai mantiene un pequeño tests/Unit para lógica pura que no tiene por qué tocar el kernel HTTP: un validador de esquema de contenido, una factory de DTOs, un algoritmo de ordenación de imágenes. Sin rutas, sin middleware, sin base de datos: sólo una función y una aserción. Ese es el listón para una prueba unitaria en esta flota: si necesita getJson() para demostrar algo, es una prueba de integración.
Simular los servicios que no son tuyos
La License API no emite licencias. Le pide a Statamic que las emita y reformatea la respuesta. Tus pruebas no tienen permitido hacer esa llamada de verdad, no porque sea lenta, sino porque una suite que depende del tiempo de actividad de un tercero no es una suite: es una apuesta.
// tests/Feature/LicenseControllerTest.php
beforeEach(function () {
$this->token = Bearer::factory()->create();
Http::fake(function ($request) {
if ($request->method() === 'GET') {
return Http::response(['data' => [
['key' => 'license-1', 'name' => 'Production License', 'domains' => ['example.com'], 'created_at' => '2026-01-28T10:30:00Z'],
]], 200);
} elseif ($request->method() === 'POST') {
return Http::response(['data' => [
'key' => 'new-license-key', 'name' => 'New License', 'domains' => ['newdomain.com'], 'created_at' => '2026-01-29T12:00:00Z',
]], 201);
} elseif ($request->method() === 'DELETE') {
return Http::response([], 202);
}
return Http::response([], 404);
});
});
Http::fake() intercepta cada llamada saliente del cliente HTTP de Laravel y devuelve lo que le digas: sin abrir un socket, sin resolución DNS, sin depender de que Statamic esté despierto. La forma con closure importa: en lugar de una respuesta fija, ramifica según el método, así que la misma simulación cubre listar, crear y borrar sin tres bloques de preparación esparcidos por el fichero.
Puedes ir más allá del camino feliz. Cuando un trait compartido reintenta ante un 429, Http::sequence() encola respuestas en orden en lugar de una fija, así que la primera llamada recibe el límite y la segunda tiene éxito. Http::assertSentCount(2) demuestra entonces que el reintento ocurrió de verdad, no sólo que la respuesta final tenía buen aspecto. Simular no va sólo de velocidad: es la única forma de poner un servicio hoja en un estado de fallo concreto a demanda, algo que no puedes hacerle a una dependencia real que ahora mismo está sana.