Pest en 2026: el framework de testing PHP que hace que los tests den gusto escribir

PHPUnit lleva desde 2004 siendo el estándar de testing en PHP. Ha funcionado bien durante veinte años, y eso no cambia de golpe. Pero tiene un problema: escribir un test sencillo requiere crear una clase, extender TestCase, declarar un método público con el prefijo test y gestionar el setup y teardown con anotaciones o métodos especiales. Mucho código para lo que a veces es una comprobación de tres líneas.

Pest llega como una capa sobre PHPUnit con una API funcional. No lo reemplaza ni compite contra él, simplemente lo envuelve. Debajo sigue corriendo PHPUnit, así que toda la infraestructura de CI, los plugins y las integraciones que ya tengas siguen funcionando igual.

La diferencia en código es bastante gráfica. Con PHPUnit, un test básico de creación de usuario puede ocupar 15 o 20 líneas con la declaración de clase, el método, el assert y el cierre. Con Pest, el mismo test queda así:

it('crea un usuario correctamente', function () {
    expect(User::create([
        'name'  => 'Ana García',
        'email' => '[email protected]',
    ]))->toBeInstanceOf(User::class);
});

Una función, una expectation, sin clases ni extends. Eso es todo.

Pest 3, lanzado en 2024, añade además arch(), que permite escribir tests de arquitectura para verificar que el código sigue las convenciones del proyecto sin tener que leerlo manualmente. Más adelante lo vemos.

La sintaxis básica de Pest

Pest ofrece dos formas equivalentes de declarar un test:

test('suma dos números', function () {
    expect(1 + 1)->toBe(2);
});

it('devuelve el nombre del usuario', function () {
    expect($this->usuario->name)->toBe('Ana García');
});

test() e it() hacen lo mismo. La diferencia es estilística: it() queda más natural cuando describes comportamiento («it does X»), y test() cuando el nombre ya es descriptivo por sí solo.

Las expectations son encadenables, lo que permite escribir varias comprobaciones sobre el mismo valor sin repetir expect():

expect($valor)->toBe(1)->toBeGreaterThan(0)->not->toBeNull();

Fíjate en not: es una propiedad de la expectation que invierte la comprobación siguiente. Sin paréntesis, sin métodos extra, simplemente ->not->toBe(null).

Un fichero de test en Pest es literalmente un archivo con funciones. Sin clases, sin métodos, sin extends. Eso hace que sea mucho más fácil de leer de un vistazo, especialmente en proyectos con decenas de ficheros de test.

Las expectations más usadas

Pest incluye un catálogo amplio de expectations. Estas son las que vas a usar el 90% de las veces:

  • expect($x)->toBe($y): comparación estricta (===). Para tipos y valores exactos.
  • expect($x)->toEqual($y): comparación no estricta (==). Para cuando el tipo no importa.
  • expect($x)->toBeNull(), ->toBeTrue(), ->toBeFalse(): comprobaciones de tipo/valor.
  • expect($array)->toHaveCount(3)->toContain('elemento'): para arrays y colecciones.
  • expect($objeto)->toBeInstanceOf(MiClase::class): verificar instancia.
  • expect($string)->toContain('texto')->toStartWith('inicio')->toEndWith('fin'): para strings.
  • expect($callable)->toThrow(MiExcepcion::class): verificar que lanza una excepción.

Todas se pueden negar con ->not antes:

expect($usuario)->not->toBeNull();
expect($lista)->not->toContain('admin');

Y se pueden encadenar sin límite, aunque en la práctica más de tres o cuatro en la misma cadena empieza a ser difícil de leer. Mejor dividir en varias líneas o varios expect().

Dataset: tests parametrizados

Una de las funcionalidades más útiles de Pest son los datasets. Permiten ejecutar el mismo test con distintos valores sin duplicar código.

it('valida emails', function (string $email, bool $valido) {
    expect(validarEmail($email))->toBe($valido);
})->with([
    ['[email protected]',   true],
    ['no-es-email',     false],
    ['[email protected]', true],
]);

Pest ejecuta ese test tres veces, una por cada fila del array, y en el output muestra cada combinación por separado. Si uno falla, sabes exactamente qué valor lo causa.

También puedes definir datasets con nombre y reutilizarlos en varios tests:

dataset('emails_validos', [
    '[email protected]',
    '[email protected]',
]);

it('acepta emails válidos', function (string $email) {
    expect(validarEmail($email))->toBeTrue();
})->with('emails_validos');

Y si necesitas el producto cartesiano de dos conjuntos de datos, encadenas dos ->with(). Pest genera automáticamente todas las combinaciones posibles.

beforeEach, afterEach, beforeAll, afterAll

El setup y teardown funciona con funciones globales, no con métodos de clase:

beforeEach(function () {
    $this->usuario = User::factory()->create();
});

afterEach(function () {
    DB::rollBack();
});

Los datos creados en beforeEach se acceden con $this->variable dentro del test. Es el mismo mecanismo de PHPUnit, pero sin necesidad de declarar propiedades en una clase.

beforeAll y afterAll se ejecutan una sola vez por fichero de test, útil para operaciones costosas que no necesitas repetir en cada caso.

Si usas Laravel, el trait RefreshDatabase se aplica con uses() al principio del fichero:

uses(TestsTestCase::class, IlluminateFoundationTestingRefreshDatabase::class)->in('Feature');

Eso aplica el trait a todos los tests en el directorio Feature sin tener que declararlo en cada uno.

Tests de arquitectura con arch()

Pest 3 introduce arch(), que es probablemente la funcionalidad más diferencial respecto a PHPUnit. Permite escribir tests que verifican las convenciones del proyecto a nivel de estructura de clases.

arch('los controladores no usan repositorios directamente')
    ->expect('AppHttpControllers')
    ->not->toUse('AppRepositories');

arch('los modelos no tienen métodos públicos extra')
    ->expect('AppModels')
    ->toBeClasses()
    ->not->toHavePublicMethods();

La idea es que estas reglas se ejecuten en CI y fallen si alguien introduce una dependencia que viola la arquitectura acordada. Sin necesidad de una herramienta separada ni de revisar el código manualmente.

Para proyectos Laravel, Pest incluye presets que comprueban las convenciones del framework de un tirón:

arch()->preset()->laravel();

Ese único test verifica que los controladores extienden la clase base correcta, que los modelos están en el directorio adecuado, que los observers siguen la convención de nombres y un buen número de reglas más. Es una forma rápida de mantener consistencia en equipos.

Pest con Laravel

La integración de Pest con Laravel es muy directa. Al instalar el paquete pestphp/pest-plugin-laravel, el archivo tests/Pest.php configura el caso base:

uses(TestsTestCase::class)->in('Feature');
uses(TestsTestCase::class)->in('Unit');

A partir de ahí, los helpers de Laravel funcionan dentro de los tests sin ninguna configuración adicional:

it('devuelve 200 en la página principal', function () {
    get('/')->assertStatus(200);
});

it('crea una sesión al hacer login', function () {
    $usuario = User::factory()->create();

    actingAs($usuario)
        ->get('/dashboard')
        ->assertStatus(200);
});

El output de Pest en la terminal es bastante más limpio que el de PHPUnit: barra de progreso con colores, tiempo por test, agrupación visual por fichero y mensajes de error más legibles cuando algo falla. No es un cambio de fondo, pero en proyectos con muchos tests marca la diferencia en el día a día.

Mutation testing con Pest

Una suite de tests con alta cobertura de líneas puede dar una falsa sensación de seguridad. Si los tests no comprueban bien los valores de retorno, pueden pasar aunque el código sea incorrecto.

El mutation testing ataca ese problema desde otro ángulo: en lugar de medir qué líneas ejecutan los tests, genera variantes del código (mutaciones) y verifica que los tests las detectan. Una mutación puede ser cambiar > por >=, invertir un condicional, devolver null donde habría un valor, o eliminar una llamada a función.

Pest incluye mutation testing integrado desde la versión 2:

./vendor/bin/pest --mutate

Al ejecutarlo, Pest modifica el código fuente temporalmente, lanza los tests y comprueba si alguno falla. Si un test pasa con la mutación activa significa que no está detectando ese cambio, lo que indica un gap en la cobertura real.

El mutation testing tarda más que una ejecución normal porque genera y prueba docenas de variantes. Pero es el siguiente paso natural después de tener cobertura de líneas decente, especialmente en lógica de negocio crítica.

Para combinar Pest con TDD en PHP con Laravel: el ciclo que Pest hace más fluido, la recomendación es escribir primero los tests con la API de Pest y después correr --mutate cuando la funcionalidad ya esté estable. También puedes integrar el testing de rendimiento en PHP: cómo combinar Pest con benchmarks para tener una visión completa de la salud del proyecto.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP