Disciplina de Pruebas Pest
Las funcionalidades enviadas sin pruebas — o "probadas" con suites sobrecargadas de mocks que verifican detalles de implementación — se rompen silenciosamente en producción y se pudren en cada refactorización. Esta habilidad te hace escribir pruebas Pest 3/4 que prueban comportamiento, cubren el camino infeliz y la autorización, y sobreviven a refactorizaciones.
El peligro
Dos modos de fallo, ambos costosos:
- Sin pruebas / solo pruebas del camino feliz. La funcionalidad funciona en la demo, pero luego en producción un invitado accede al endpoint, un inquilino lee datos de otro inquilino, una brecha de validación permite la entrada de basura, o un 404 se convierte en 500. Los agujeros de autorización (caminos
403/404que nunca se afirmaron) son cómo las fugas de datos llegan a producción. - Pruebas frágiles y sobrecargadas de mocks. Mockeas tu propio
UserRepository/servicio conshouldReceive('save')->once(), por lo que la prueba pasa incluso cuando la consulta real es incorrecta, y explota en cuanto alguien renombra un método. La suite está verde, da falsa confianza, y se elimina por frustración durante la próxima refactorización, dejándote de vuelta en el modo 1.
La solución es la misma disciplina en ambos casos: falsea los límites que no posees, ejercita el código que sí posees, y verifica resultados observables (filas de BD, estado HTTP, trabajos/correos enviados) — incluyendo los resultados de fallo y autorización.
Reglas
- Escribe la prueba con la funcionalidad, no "después". Un cambio en un controlador/trabajo/comando/regla de autorización está incompleto hasta que tenga una prueba Pest. Nunca reportes una funcionalidad como terminada sin una prueba correspondiente que verifique su comportamiento.
- Usa modismos de Pest. Define casos con
it('...')otest('...'); verifica conexpect(...)fluido; comparte configuración conbeforeEach(). No escribasclass FooTest extends TestCasede PHPUnit a menos que el archivo ya use ese estilo. - Elige el tipo correcto. Comportamiento HTTP/de extremo a extremo → Prueba de característica (inicia el framework, accede a rutas mediante
$this->get/post/...). Lógica pura sin framework/BD → Prueba unitaria. Por defecto, elige una prueba de característica si tienes dudas: atrapa más. - Resetea la base de datos con un trait, mediante
uses(). AplicaRefreshDatabase(envuelto en transacciones) oLazilyRefreshDatabase(solo migra cuando una prueba toca la BD) entests/Pest.phppara todo el directorioFeature, ouses(RefreshDatabase::class)al inicio de un archivo. NUNCA dejes que las pruebas de BD dependan de estado residual. - Construye datos con factories, nunca inserciones hechas a mano. Usa
User::factory()->create()/->make()y factories de relaciones. Las inserciones hechas a mano conDB::table()->insert([...])onew Model([...])->save()en pruebas son frágiles y omiten conversiones/valores predeterminados — prohíbelas. - Desduplica con conjuntos de datos, no copiando y pegando. Cuando la misma verificación se ejecuta sobre muchas entradas (cargas válidas/inválidas, matrices de roles), usa
->with([...])(en línea o un conjunto de datos con nombre entests/Datasets/) en lugar de N pruebas casi idénticas. - Falsea los límites que no posees. Para cualquier cosa externa o asíncrona, usa los falsos de las fachadas antes de la acción:
Http::fake(),Queue::fake(),Bus::fake(),Mail::fake(),Notification::fake(),Event::fake(),Storage::fake(). Para el tiempo, usa$this->travel(...),$this->travelTo(...), o$this->freezeTime()— nunca verifiques contra unnow()no congelado. - NO mockees tus propios modelos/servicios hasta hacerlos insignificantes. Evita
Mockery::mock(MyService::class)->shouldReceive(...)yModel::shouldReceive(...)para el código bajo prueba. Deja que el código real se ejecute contra la BD de prueba y los límites falseados. Mockea tu propia clase solo en una costura genuina que no estés ejercitando (por ejemplo, un wrapper lento de SDK de terceros). - Verifica resultados, no detalles internos. Prefiere
assertDatabaseHas/assertDatabaseMissing/assertModelExists,assertStatus/assertOk/assertCreated,assertJson/assertJsonPath,assertRedirect,Mail::assertSent,Notification::assertSentTo,Queue::assertPushed,Bus::assertDispatched,Storage::disk()->assertExists. No verifiques "el método X se llamó una vez" como proxy del comportamiento. - Prueba siempre el camino infeliz Y la autorización. Cada endpoint/acción necesita, como mínimo: el camino feliz, un camino de validación/fallo, y los caminos de autorización — invitado →
401/redirección, usuario/rol incorrecto →403, recurso faltante/ajeno →404. Para cualquier funcionalidad sensible a la seguridad, añade una prueba explícita de "otro usuario no puede acceder/modificar esto". Esto no es negociable para cualquier cosa que toque propiedad o inquilinato. - Añade pruebas de arquitectura como barandillas globales económicas. Mantén un
tests/Arch.php(o casos de arquitectura enPest.php) con los preajustes que correspondan:arch()->preset()->php()(prohíbe la salida de depuración tipodie/var_dump/ddy funciones PHP obsoletas),arch()->preset()->security()(prohíbeeval,extract,unserialize, hash débiles/md5/sha1, aleatoriedad insegura, etc.), yarch()->preset()->laravel()(aplica las convenciones de Laravel — incluyendo no usarenv()fuera de config). Añade invariantes del proyecto comoarch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller'). - Un comportamiento por prueba, nombre descriptivo.
it('bloquea a los invitados del panel'), noit('funciona'). Si un nombre de prueba necesita "y", divídelo. - Controla la cobertura en CI. Ejecuta
./vendor/bin/pest --coverage --min=NNpara forzar un mínimo, y./vendor/bin/pest --type-coverage(requiere el plugin type-coverage) para forzar firmas tipadas. Trata las nuevas líneas no probadas como un defecto. - Pruebas de navegador/E2E con moderación (Pest 4). Pest 4 incluye pruebas de navegador mediante
visit('/')->...->assertSee(...)(basado en Playwright). Resérvalas para flujos full-stack/JS reales; no reimplementes verificaciones a nivel HTTP como pruebas de navegador lentas.
Bueno vs malo
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// ❌ sobrecargado de mocks: prueba que se llamó un método, no lo que pasó.
// Pasa incluso si se actualiza el post incorrecto o falta autorización.
it('actualiza un post', function () {
$service = Mockery::mock(App\Services\PostService::class);
$service->shouldReceive('update')->once()->andReturnTrue();
$this->app->instance(App\Services\PostService::class, $service);
$this->put('/posts/1', ['title' => 'Nuevo'])->assertOk();
});
// ✅ ejercita código real contra la BD de prueba; verifica el resultado Y la autorización.
use App\Models\Post;
use App\Models\User;
it('permite al propietario actualizar su post', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => 'Viejo']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => 'Nuevo'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Nuevo']);
});
it('prohíbe a un no-propietario actualizar el post', function () {
$post = Post::factory()->create(['title' => 'Viejo']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => 'Hackeado'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Viejo']);
});
it('bloquea a los invitados', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => 'Nuevo'])
->assertRedirect('/login'); // o assertUnauthorized() para una API
});
// ❌ accede a la red real (inestable, lento, puede filtrar claves); no verifica nada útil.
it('notifica a slack', function () {
$resp = (new App\Services\Slack)->ping('despliegue ok');
expect($resp)->toBeArray();
});
// ✅ falsea el límite que no posees; verifica la solicitud que enviaste.
use Illuminate\Support\Facades\Http;
it('envía el mensaje de despliegue a slack', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('despliegue ok');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === 'despliegue ok'
);
});
// ❌ pruebas de validación casi idénticas copiadas y pegadas.
it('rechaza email vacío', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rechaza email malo', function () {
$this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// ✅ una prueba, guiada por tabla con un conjunto de datos.
it('rechaza emails inválidos', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'vacío' => '',
'sin arroba' => 'nope',
'sin dominio' => 'a@',
]);
// ✅ límites asíncronos + tiempo falseados; verifica el envío y el efecto secundario.
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('pone en cola un recordatorio y da la bienvenida al usuario al registrarse', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'contraseña-secreta',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php — ✅ barandillas globales económicas
arch()->preset()->php(); // prohíbe die/var_dump/dd y funciones PHP obsoletas
arch()->preset()->security(); // prohíbe eval, extract, unserialize, md5/sha1, aleatoriedad débil, etc.
arch()->preset()->laravel(); // aplica convenciones de Laravel, incl. no env() fuera de config
arch('los controladores extienden el controlador base')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('los modelos viven en el namespace correcto')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
Cómo verificar
Ejecuta esto antes de afirmar que una funcionalidad y sus pruebas están terminadas:
# 1. Toda la suite debe estar en verde.
./vendor/bin/pest
# 2. Ejecuta solo el/los archivo(s) nuevo(s) o modificado(s) mientras iteras.
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. Exige un mínimo de cobertura (requiere Xdebug o PCOV).
./vendor/bin/pest --coverage --min=80
# 4. Cobertura de tipos (requiere pestphp/pest-plugin-type-coverage).
./vendor/bin/pest --type-coverage --min=100
# 5. El estilo se mantiene limpio.
./vendor/bin/pint --test
Luego audita que NO reintrodujiste el peligro:
# Cobertura de autorización: cada prueba de funcionalidad protegida debe verificar un camino 401/403/404.
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\\((401|403|404)\\)" tests/
# Olor a sobrecarga de mocks: hacer mock de tus PROPIAS clases de la app es una señal de alerta — revisa cada coincidencia.
grep -rEn "Mockery::|->shouldReceive\\(|::partialMock\\(|::spy\\(" tests/
# Inserciones hechas a mano en lugar de factories — reemplázalas con ->factory().
grep -rEn "DB::table\\(.*\\)->insert|new App\\\\Models" tests/
# Los límites externos deben falsearse — confirma que existen falsos donde el código hace llamadas externas.
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# Los preajustes de arquitectura de seguridad + php (las barandillas globales económicas) deben estar presentes en algún lugar.
grep -rEn "preset\\(\\)->(security|php|laravel)\\(\\)" tests/
Verifica en pruebas, no solo en código: para cada nuevo endpoint, confirma que hay (a) una verificación de resultado del camino feliz (assertDatabaseHas/assertOk/assertJsonPath), (b) al menos una verificación de fallo/validación, y (c) las verificaciones de autorización de invitado + usuario incorrecto. Si falta alguna, la funcionalidad no está terminada.
Cuándo está bien doblar la regla
- Mockear tu propio código en una costura externa real. Un wrapper delgado alrededor de un SDK de terceros de pago (SMS, pasarela de pago) que no puedes falsear a nivel HTTP es un objetivo legítimo de
Mockery— estás aislando su dependencia, no falseando el comportamiento bajo prueba. - Probar lógica pura sin la BD. Objetos de valor, formateadores y calculadoras no necesitan
RefreshDatabaseni factories — instáncialos directamente y usaexpect()para el resultado; es más rápido y correcto. make()en lugar decreate()cuando una prueba nunca persiste —User::factory()->make()evita una escritura en BD para verificaciones de lógica pura.- Umbrales de cobertura más bajos en código heredado. Establece un
--minrealista y auméntalo con el tiempo en lugar de bloquear todo el trabajo; nunca dejes que "100% o nada" se convierta en una excusa para no enviar ninguna prueba. - Pruebas de navegador para flujos genuinamente impulsados por JS (interacciones de Livewire/Inertia/Vue, selectores de archivos) — allí,
visit()es la herramienta correcta, no un exceso.
Referencias
- Pest — Escribir pruebas, expectativas, hooks: https://pestphp.com/docs/writing-tests
- Pest — Conjuntos de datos: https://pestphp.com/docs/datasets
- Pest — Pruebas de arquitectura y preajustes (
security,laravel,toExtend): https://pestphp.com/docs/arch-testing - Pest — Cobertura y
--min: https://pestphp.com/docs/coverage - Pest — Plugin de cobertura de tipos: https://pestphp.com/docs/type-coverage
- Pest v4 — Pruebas de navegador (
visit(), regresión visual): https://pestphp.com/docs/browser-testing - Laravel — Pruebas HTTP (verificaciones de estado/JSON/redirección): https://laravel.com/docs/testing
- Laravel — Pruebas de base de datos (
RefreshDatabase, factories,assertDatabaseHas): https://laravel.com/docs/database-testing - Laravel — Mocking y falsos de fachadas (
Http,Queue,Bus,Mail,Notification,Event,Storage, tiempo): https://laravel.com/docs/mocking