🔎 Buscar

🧪 Testing en PHP

PHPUnit y Pest, asserts, data providers, fixtures, test doubles, TDD con ejemplo real paso a paso, testing de Laravel y Symfony, cobertura de código y análisis estático con PHPStan y Psalm.

Wiki / Apuntes📖 Contenido

Testing en PHP

Los tests no verifican solo que el código “funciona”: te dan seguridad para refactorizar, documentan el comportamiento esperado y fuerzan un diseño desacoplado. En PHP el estándar es PHPUnit, con Pest como alternativa ergonómica que crece rápido. Este artículo cubre el ciclo completo: herramientas, técnicas (doubles, fixtures, data providers), TDD paso a paso, cómo testear Laravel y Symfony, cobertura y análisis estático.

PHPUnit: instalación y primer test

composer require --dev phpunit/phpunit
./vendor/bin/phpunit

Configuración mínima en phpunit.xml:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
</phpunit>

Un test es una clase que extiende TestCase, con métodos test*:

<?php
use PHPUnit\Framework\TestCase;
final class CalculadoraTest extends TestCase
{
    public function test_suma_dos_numeros(): void
    {
        $calculadora = new Calculadora();
        $resultado = $calculadora->sumar(2, 3);
        $this->assertSame(5, $resultado);
    }
}

Asserts principales

Assert Comprueba
assertSame($esperado, $actual) Igualdad estricta (===, tipo incluido)
assertEquals($esperado, $actual) Igualdad suelta
assertTrue / assertFalse Valor booleano
assertNull / assertNotNull Nullidad
assertCount($n, $coleccion) Nº de elementos
assertInstanceOf(Clase::class, $obj) Tipo del objeto
assertThrows(Excepcion::class, fn) Excepción lanzada
<?php
final class ProductoTest extends TestCase
{
    public function test_precio_negativo_lanza_excepcion(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('no puede ser negativo');
        new Producto('Ratón', -5.0);
    }
}

Data providers

Un data provider ejecuta el mismo test con múltiples conjuntos de datos:

<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class CalculadoraTest extends TestCase
{
    #[DataProvider('proveedorSumas')]
    public function test_suma(int $a, int $b, int $esperado): void
    {
        $this->assertSame($esperado, (new Calculadora())->sumar($a, $b));
    }
    public static function proveedorSumas(): array
    {
        return [
            'positivos' => [1, 2, 3],
            'negativos' => [-1, -1, -2],
        ];
    }
}

Fixtures: setUp y tearDown

Los fixtures preparan el estado antes de cada test y lo limpian después.

<?php
final class CarritoTest extends TestCase
{
    private Carrito $carrito;
    protected function setUp(): void
    {
        parent::setUp();
        $this->carrito = new Carrito();
    }
    public function test_agrega_articulo(): void
    {
        $this->carrito->agregar('SKU-1', 2);
        $this->assertSame(2, $this->carrito->cantidadDe('SKU-1'));
    }
}

⚠️ setUp() se ejecuta antes de cada test, no una vez. Para algo costoso y compartido usa setUpBeforeClass() o, mejor, estructura tu test para no depender de estado compartido.

Test doubles: mocks, stubs y spies

Los test doubles sustituyen dependencias reales (API, base de datos, mailer) por dobles controlados.

Doble Función
Stub Devuelve valores fijos; no verifica nada
Mock Stub + verifica cómo se llamó (expectations)
Spy Registra llamadas y las verifica al final
<?php
final class NotificadorTest extends TestCase
{
    public function test_envia_correo_al_registrarse(): void
    {
        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->once())
            ->method('enviar')
            ->with('ana@example.com', 'Bienvenido')
            ->willReturn(true);
        $notificador = new NotificadorDeBienvenida($mailer);
        $notificador->notificar('ana@example.com');
    }
    public function test_usa_valores_sin_verificar(): void
    {
        $repositorio = $this->createMock(UsuarioRepositorio::class);
        $repositorio->method('porId')->with(42)->willReturn(new Usuario('Ana'));
        $servicio = new ServicioPerfil($repositorio);
        $this->assertSame('Ana', $servicio->nombreDe(42));
    }
}

💡 Cuando el doble solo aporta datos, prefiere un stub. Las expectativas (cómo y cuántas veces se llama) acoplan tu test a la implementación; úsalas solo cuando el efecto secundario importe (enviar correo, persistir, cobrar).

Pest: la alternativa ergonómica

Pest (requiere PHP 8.2+) envuelve a PHPUnit con una API funcional y tersa:

composer require pestphp/pest --dev
./vendor/bin/pest
<?php
it('suma dos números', function () {
    $resultado = (new Calculadora())->sumar(2, 3);
    expect($resultado)->toBe(5);
});

describe, beforeEach y higher-order tests

<?php
describe('Calculadora', function () {
    beforeEach(function () {
        $this->calculadora = new Calculadora();
    });
    it('suma', function () {
        expect($this->calculadora->sumar(1, 2))->toBe(3);
    });
    it('multiplica', function () {
        expect($this->calculadora->multiplicar(3, 4))->toBe(12);
    });
    // Higher-order test: expresado de forma declarativa
    it('es conmutativa en la suma')
        ->expect(fn() => (new Calculadora())->sumar(2, 3))
        ->toBe(5);
});

TDD en PHP: ejemplo real paso a paso

TDD (Test-Driven Development): rojo → verde → refactor. Primero escribes un test que falla, luego el mínimo código para que pase, y finalmente mejoras el diseño sin romper los tests. Implementemos un ValidadorDeContrasena.

Paso 1 — escribe el primer test (rojo):

<?php
final class ValidadorDeContrasenaTest extends TestCase
{
    public function test_rechaza_contrasena_corta(): void
    {
        $validador = new ValidadorDeContrasena();
        $this->assertFalse($validador->esValida('abc'));
    }
}

Paso 2 — crea el mínimo para que pase (verde):

<?php
final class ValidadorDeContrasena
{
    public function esValida(string $contrasena): bool
    {
        return strlen($contrasena) >= 8;
    }
}

Paso 3 — añade más casos de prueba:

<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class ValidadorDeContrasenaTest extends TestCase
{
    #[DataProvider('proveedorContrasenas')]
    public function test_es_valida(string $contrasena, bool $esperado): void
    {
        $validador = new ValidadorDeContrasena();
        $this->assertSame($esperado, $validador->esValida($contrasena));
    }
    public static function proveedorContrasenas(): array
    {
        return [
            'corta'         => ['abc', false],
            'sin_mayuscula' => ['abcdefgh', false],
            'valida'        => ['Abcdef1h', true],
        ];
    }
}

Paso 4 — implementa la lógica completa (verde):

<?php
final class ValidadorDeContrasena
{
    public function esValida(string $contrasena): bool
    {
        return strlen($contrasena) >= 8
            && preg_match('/[A-Z]/', $contrasena) === 1
            && preg_match('/[0-9]/', $contrasena) === 1;
    }
}

Paso 5 — refactoriza manteniendo los tests en verde (p. ej. extraer las condiciones a métodos privados). Repite el ciclo: cada nuevo requisito empieza con un test que falla.

💡 TDD no es “escribir tests”, es usar los tests como motor de diseño: al escribir primero el uso, descubres APIs poco ergonómicas antes de que cristalicen. Una buena suite se ejecuta en segundos y no depende de la red ni de servicios externos.

Testing de Laravel y Symfony

Laravel

php artisan make:model Pedido -m
php artisan make:test PedidoTest
<?php
namespace Tests\Feature;
use App\Models\Usuario;
use Illuminate\Foundation\Testing\RefreshDatabase;

final class PedidoTest extends TestCase
{
    use RefreshDatabase;
    public function test_crea_pedido_y_persiste(): void
    {
        $usuario = Usuario::factory()->create();
        $this->actingAs($usuario)
            ->postJson('/api/pedidos', [
                'direccion' => 'Calle Real 5',
                'metodo_pago' => 'card',
            ])
            ->assertCreated();
        $this->assertDatabaseHas('pedidos', ['usuario_id' => $usuario->id]);
    }
}

💡 RefreshDatabase reinicia la base de datos entre tests; las factories generan datos realistas con Usuario::factory()->create(). Los tests HTTP devuelven un TestResponse con asserts como assertOk, assertJsonStructure o assertSessionHas.

Symfony

composer require --dev symfony/test-pack
<?php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class ApiPedidosTest extends WebTestCase
{
    public function test_crear_pedido(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/pedidos', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['direccion' => 'Calle Real 5']));
        $this->assertResponseStatusCodeSame(201);
        $this->assertJsonContains(['estado' => 'creado']);
    }
}

Cobertura de código

La cobertura indica qué porcentaje de líneas/branches se ejecutaron durante los tests. No garantiza calidad (un código sin asserts tiene 100 % de cobertura), pero señala código muerto o sin probar.

XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text --coverage-html build/coverage
XDEBUG_MODE=coverage ./vendor/bin/pest --coverage

⚠️ Ajusta umbrales y respeta un objetivo razonable (70–80 % en src/). Más importante que el porcentaje global es la cobertura de branches en lógica de negocio: condiciones, early returns y ramas de error son las que se equivocan de verdad.

Análisis estático: PHPStan y Psalm

El análisis estático revisa el código sin ejecutarlo: detecta errores de tipos, llamadas a métodos inexistentes, posibles null y más. Ejecútalo en CI como parte del pipeline.

composer require --dev phpstan/phpstan
./vendor/bin/phpstan analyse src --level=max
composer require --dev vimeo/psalm
./vendor/bin/psalm
<?php
// PHPStan detecta este error sin ejecutar nada:
function nombreDelUsuario(?Usuario $usuario): string
{
    return $usuario->nombre; // ❌ "Cannot call method nombre() on Usuario|null"
}
Herramienta Características
PHPStan Niveles 0–10; inferencia excelente; --level=max exige tipado total
Psalm Nivel de seguridad/extensión; taints (flujo de entrada insegura)

Ejemplo completo: una clase con tests en PHPUnit y Pest

La clase de dominio:

<?php
namespace App\Dominio;
use InvalidArgumentException;
final class TarjetaDeFidelidad
{
    private int $puntos = 0;
    public function __construct(private readonly string $cliente) {}
    public function puntos(): int { return $this->puntos; }
    public function acumular(float $gasto): void
    {
        if ($gasto <= 0) {
            throw new InvalidArgumentException('El gasto debe ser positivo');
        }
        $this->puntos += (int) floor($gasto / 10);
    }
    public function canjear(int $puntos): bool
    {
        if ($puntos <= 0 || $puntos > $this->puntos) {
            return false;
        }
        $this->puntos -= $puntos;
        return true;
    }
}

Versión PHPUnit:

<?php
namespace Tests\Unit;
use App\Dominio\TarjetaDeFidelidad;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class TarjetaDeFidelidadTest extends TestCase
{
    #[DataProvider('proveedorGastos')]
    public function test_acumula_puntos(float $gasto, int $esperado): void
    {
        $tarjeta = new TarjetaDeFidelidad('Ana');
        $tarjeta->acumular($gasto);
        $this->assertSame($esperado, $tarjeta->puntos());
    }
    public static function proveedorGastos(): array
    {
        return [
            'menos_de_10' => [9.99, 0],
            'exacto'      => [10.0, 1],
            'redondea'    => [25.0, 2],
        ];
    }
    public function test_no_canjea_mas_de_lo_que_tiene(): void
    {
        $tarjeta = new TarjetaDeFidelidad('Ana');
        $tarjeta->acumular(30);
        $this->assertFalse($tarjeta->canjear(4));
        $this->assertSame(3, $tarjeta->puntos());
    }
}

Misma suite en Pest:

<?php
use App\Dominio\TarjetaDeFidelidad;
it('acumula un punto por cada 10 euros', function (float $gasto, int $esperado) {
    $tarjeta = new TarjetaDeFidelidad('Ana');
    $tarjeta->acumular($gasto);
    expect($tarjeta->puntos())->toBe($esperado);
})->with([
    'menos_de_10' => [9.99, 0],
    'exacto'      => [10.0, 1],
    'redondea'    => [25.0, 2],
]);
it('no permite canjear más puntos de los acumulados', function () {
    $tarjeta = new TarjetaDeFidelidad('Ana');
    $tarjeta->acumular(30);
    expect($tarjeta->canjear(4))->toBeFalse()
        ->and($tarjeta->puntos())->toBe(3);
});

Cheatsheet

Necesidad Herramienta
Framework de tests PHPUnit, Pest
Datos múltiples Data providers (#[DataProvider], ->with())
Estado por test setUp() / beforeEach
Aislamiento Mocks/stubs/spies (createMock)
Tests HTTP Laravel actingAs + asserts; Symfony WebTestCase
Base de datos RefreshDatabase, Doctrine Fixtures
Cobertura Xdebug + --coverage-*
Tipos en tiempo de diseño PHPStan (--level=max), Psalm

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.