Módulo 18: Testing con PHPUnit

Objetivos del módulo

  • Entender por qué escribir tests automáticos y qué ganas con ellos
  • Distinguir los tipos de test (unitario, integración, funcional) y la pirámide de testing
  • Instalar PHPUnit con Composer y configurarlo con phpunit.xml
  • Escribir tus primeros tests con TestCase y los atributos #[Test], #[DataProvider] y #[CoversClass]
  • Conocer las aserciones principales y probar excepciones
  • Usar data providers, setUp() y tearDown()
  • Crear dobles de prueba con createStub() y createMock()
  • Probar una clase que usa la base de datos con SQLite en memoria
  • Medir la cobertura de código con Xdebug o PCOV
  • Practicar TDD (rojo, verde, refactor) con un ejemplo paso a paso
  • Conocer Pest como alternativa y ejecutar los tests en GitHub Actions

1. ¿Por qué escribir tests?

Hasta ahora, para comprobar que tu código funcionaba, lo ejecutabas y mirabas el resultado: recargabas el navegador, rellenabas el formulario, mirabas la base de datos… Eso es un test manual. Funciona, pero:

  • Es lento: repetir 30 comprobaciones después de cada cambio es inviable.
  • Es poco fiable: un día te olvidas de probar algo.
  • No escala: cuanto más crece la aplicación, más cosas puedes romper sin darte cuenta.

📘 Concepto: Un test automático es un trozo de código que ejecuta otro trozo de código y comprueba que el resultado es el esperado. Se ejecutan todos con un solo comando, en segundos, tantas veces como quieras.

Analogía: un piloto de avión no despega “porque parece que todo está bien”. Sigue una lista de comprobación antes de cada vuelo. Los tests son la lista de comprobación de tu código, pero la repasa una máquina en segundos.

Lo que ganas

Beneficio Explicación
Confianza para cambiar Puedes refactorizar o actualizar dependencias sabiendo que si rompes algo, un test fallará
Detectar errores pronto Un bug encontrado al escribir el código cuesta minutos; en producción, horas (y clientes)
Documentación viva Los tests muestran cómo se usa cada clase y qué casos contempla
Mejor diseño El código difícil de testear suele estar mal diseñado (demasiadas responsabilidades, dependencias ocultas)
Evitar regresiones Cuando arreglas un bug, escribes un test que lo reproduce: ese bug no volverá

2. Tipos de test y la pirámide

Tipo Qué prueba Velocidad Ejemplo
Unitario Una clase o función aislada, sin BD, red ni ficheros Milisegundos Carrito::total() suma bien y aplica descuentos
Integración Varias piezas juntas, incluidas dependencias reales (BD, sistema de ficheros) Décimas de segundo ProductoRepository guarda y recupera de una BD real
Funcional / extremo a extremo La aplicación completa como la usaría un usuario (peticiones HTTP, navegador) Segundos “Al enviar el formulario de registro se crea el usuario y se ve el mensaje de bienvenida”
flowchart TB
    E2E["Funcionales / E2E<br/>pocos, lentos, frágiles"]
    INT["Integración<br/>bastantes"]
    UNI["Unitarios<br/>muchos, rápidos, baratos"]
    E2E --- INT --- UNI
    style E2E fill:#f8d7da,stroke:#842029
    style INT fill:#fff3cd,stroke:#997404
    style UNI fill:#d1e7dd,stroke:#0f5132

📘 Concepto: La pirámide de testing recomienda tener muchos tests unitarios (la base), bastantes de integración y pocos funcionales (la punta). Los unitarios son rápidos y señalan exactamente qué falla; los funcionales dan confianza global pero son lentos y se rompen con cualquier cambio de interfaz.


3. Instalar PHPUnit

PHPUnit es el framework de testing estándar de PHP, creado por Sebastian Bergmann. Cada febrero sale una versión mayor:

Versión Publicación PHP mínimo
PHPUnit 12 Febrero 2025 8.3
PHPUnit 13 Febrero 2026 8.4

Con PHP 8.5 usaremos PHPUnit 13. Se instala como dependencia de desarrollo del proyecto (no globalmente), para que cada proyecto tenga su versión:

composer require --dev phpunit/phpunit:^13

Comprueba que funciona:

vendor/bin/phpunit --version
# PHPUnit 13.x.x by Sebastian Bergmann and contributors.

En Windows con PowerShell o CMD usa vendor\bin\phpunit.

Estructura del proyecto

mi-proyecto/
├── composer.json
├── phpunit.xml
├── src/                     ← código de la aplicación (namespace App\)
│   ├── Carrito.php
│   └── ...
├── tests/                   ← tests (namespace App\Tests\)
│   ├── Unit/
│   │   └── CarritoTest.php
│   └── Integration/
│       └── ProductoRepositoryTest.php
└── vendor/

En composer.json añade el autoload de los tests en la sección autoload-dev, para que no se cargue en producción:

{
    "autoload": {
        "psr-4": { "App\\": "src/" }
    },
    "autoload-dev": {
        "psr-4": { "App\\Tests\\": "tests/" }
    },
    "require-dev": {
        "phpunit/phpunit": "^13"
    },
    "scripts": {
        "test": "phpunit"
    }
}
composer dump-autoload
composer test     # equivale a vendor/bin/phpunit

phpunit.xml

PHPUnit puede generar un fichero de configuración inicial con vendor/bin/phpunit --generate-configuration. Una configuración recomendada:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         failOnWarning="true"
         failOnRisky="true"
         displayDetailsOnTestsThatTriggerDeprecations="true"
         displayDetailsOnTestsThatTriggerWarnings="true">
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>

    <php>
        <env name="APP_ENV" value="test"/>
    </php>
</phpunit>
Elemento Para qué sirve
bootstrap Fichero que se carga antes de los tests (el autoload de Composer)
cacheDirectory Carpeta de caché (añádela a .gitignore)
failOnWarning / failOnRisky Considera fallo un warning o un test “arriesgado” (sin aserciones)
<testsuites> Grupos de tests; puedes ejecutar uno con --testsuite unit
<source> El código de la aplicación, necesario para la cobertura
<php><env> Variables de entorno para los tests

4. Tu primer test

Partimos de una clase sencilla:

<?php
declare(strict_types=1);

// src/Calculadora.php
namespace App;

final class Calculadora
{
    public function sumar(float $a, float $b): float
    {
        return $a + $b;
    }

    public function dividir(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new \InvalidArgumentException('No se puede dividir entre cero');
        }
        return $a / $b;
    }
}

Y su test:

<?php
declare(strict_types=1);

// tests/Unit/CalculadoraTest.php
namespace App\Tests\Unit;

use App\Calculadora;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(Calculadora::class)]
final class CalculadoraTest extends TestCase
{
    public function testSumaDosNumerosPositivos(): void
    {
        // Arrange (preparar)
        $calculadora = new Calculadora();

        // Act (actuar)
        $resultado = $calculadora->sumar(2, 3);

        // Assert (comprobar)
        $this->assertSame(5.0, $resultado);
    }

    #[Test]
    public function divide_correctamente(): void
    {
        $this->assertSame(2.5, (new Calculadora())->dividir(5, 2));
    }
}

Convenciones

Convención Regla
Nombre de la clase de test El de la clase probada + Test: CalculadoraTest
Fichero Mismo nombre que la clase: CalculadoraTest.php
Herencia extends PHPUnit\Framework\TestCase
Métodos de test public, devuelven void, y empiezan por test… o llevan el atributo #[Test]
Nombres descriptivos Que se lea qué se comprueba: testLanzaExcepcionSiElCarritoEstaVacio

📘 Concepto: El patrón AAA (Arrange, Act, Assert) organiza cada test en tres bloques: preparar los datos, ejecutar lo que quieres probar y comprobar el resultado. Un test = un comportamiento.

📘 Concepto: #[CoversClass] indica qué clase cubre ese test. Sirve para que el informe de cobertura sea preciso (solo cuenta como cubierto el código que el test pretende probar, no todo lo que toca de rebote).

⚠️ Importante: En PHPUnit 12 y 13 la configuración de los tests se hace solo con atributos PHP (#[Test], #[DataProvider]…). Las antiguas anotaciones en comentarios (/** @test */, @dataProvider) ya no se leen. Si copias ejemplos antiguos de Internet, conviértelos a atributos.

Ejecutar los tests

vendor/bin/phpunit                          # todos
vendor/bin/phpunit tests/Unit               # una carpeta
vendor/bin/phpunit --testsuite unit         # una suite
vendor/bin/phpunit --filter divide          # tests cuyo nombre contenga "divide"
vendor/bin/phpunit --testdox                # salida legible como especificación
PHPUnit 13.x.x by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.x
Configuration: /proyecto/phpunit.xml

..                                                                  2 / 2 (100%)

Time: 00:00.012, Memory: 8.00 MB

OK (2 tests, 2 assertions)

Cada . es un test superado. Otros símbolos: F (fallo de aserción), E (error/excepción inesperada), R (arriesgado), S (omitido), D (provocó una deprecación), W (provocó un warning).

Con --testdox:

Calculadora (App\Tests\Unit\Calculadora)
 ✔ Suma dos numeros positivos
 ✔ Divide correctamente

Si un test falla, PHPUnit muestra qué se esperaba y qué se obtuvo:

1) App\Tests\Unit\CalculadoraTest::testSumaDosNumerosPositivos
Failed asserting that 6.0 is identical to 5.0.

/proyecto/tests/Unit/CalculadoraTest.php:22

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

5. Aserciones principales

Aserción Comprueba que…
assertSame($esperado, $real) Son idénticos (===): mismo valor y tipo
assertEquals($esperado, $real) Son iguales (==); útil para objetos y arrays
assertEqualsWithDelta($e, $r, 0.001) Dos float son iguales con un margen
assertTrue($x) / assertFalse($x) Es exactamente true / false
assertNull($x) / assertNotNull($x) Es / no es null
assertCount(3, $array) Tiene 3 elementos
assertEmpty($x) / assertNotEmpty($x) Está vacío / no está vacío
assertContains($valor, $array) El array contiene el valor
assertArrayHasKey('clave', $array) El array tiene esa clave
assertInstanceOf(Clase::class, $obj) El objeto es de esa clase
assertStringContainsString('ab', $s) La cadena contiene el texto
assertStringStartsWith('ab', $s) La cadena empieza por el texto
assertMatchesRegularExpression('/^\d+$/', $s) La cadena cumple la expresión regular
assertGreaterThan(5, $x) / assertLessThan(5, $x) Es mayor / menor que 5
assertFileExists($ruta) El fichero existe
assertJsonStringEqualsJsonString($a, $b) Dos JSON son equivalentes
<?php
declare(strict_types=1);

namespace App\Tests\Unit;

use PHPUnit\Framework\TestCase;

final class EjemplosAsercionesTest extends TestCase
{
    public function testDiferenciaEntreSameYEquals(): void
    {
        $this->assertEquals(5, '5');     // ✔ 5 == '5'
        // $this->assertSame(5, '5');    // ✘ int y string no son idénticos
        $this->assertSame(5, 2 + 3);     // ✔
    }

    public function testFloatsConMargen(): void
    {
        // 0.1 + 0.2 = 0.30000000000000004
        $this->assertEqualsWithDelta(0.3, 0.1 + 0.2, 0.00001);
    }

    public function testArrays(): void
    {
        $usuario = ['nombre' => 'Ana', 'roles' => ['admin', 'editor']];

        $this->assertArrayHasKey('roles', $usuario);
        $this->assertCount(2, $usuario['roles']);
        $this->assertContains('admin', $usuario['roles']);
    }

    public function testMensajePersonalizado(): void
    {
        $stock = 3;
        // El último parámetro se muestra si falla: ayuda a entender el fallo
        $this->assertGreaterThan(0, $stock, 'El producto debería tener stock');
    }
}

💡 Consejo: Prefiere assertSame() a assertEquals() con valores escalares: es más estricta y detecta errores de tipo (por ejemplo, un "10" que debería ser 10). Usa assertEquals() para comparar objetos por su contenido.

💡 Consejo: PHPUnit 13 añade aserciones específicas para arrays, como assertArraysAreIdentical() o assertArraysHaveIdenticalValuesIgnoringOrder(), que dejan claro si te importan las claves y el orden. Consulta la documentación oficial si las necesitas.


6. Probar excepciones

Probar el “camino feliz” no basta: también hay que comprobar que tu código falla bien.

<?php
declare(strict_types=1);

namespace App\Tests\Unit;

use App\Calculadora;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Calculadora::class)]
final class CalculadoraExcepcionesTest extends TestCase
{
    public function testDividirEntreCeroLanzaExcepcion(): void
    {
        // Las expectativas se declaran ANTES de ejecutar el código
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('No se puede dividir entre cero');

        (new Calculadora())->dividir(10, 0);

        // Las líneas que vinieran aquí no se ejecutarían
    }
}
Método Comprueba
expectException(Clase::class) El tipo de excepción
expectExceptionMessage('texto') Que el mensaje contiene el texto
expectExceptionMessageMatches('/regex/') Que el mensaje cumple la expresión regular
expectExceptionCode(404) El código

⚠️ Importante: expectException() debe ir antes de la línea que lanza la excepción. Si la pones después, nunca se llega a ejecutar.

Si necesitas comprobar propiedades de una excepción personalizada (como las del módulo 14), captúrala tú:

<?php
public function testStockInsuficienteIndicaCuantasFaltan(): void
{
    try {
        $this->almacen->reservar('SKU-1', 10);   // solo hay 4
        $this->fail('Se esperaba StockInsuficiente');
    } catch (StockInsuficiente $e) {
        $this->assertSame(4, $e->disponible);
        $this->assertSame(6, $e->faltan());
    }
}

7. Data providers

Cuando quieres probar la misma lógica con muchos datos, en lugar de copiar el test diez veces, usas un data provider: un método que devuelve los casos de prueba.

<?php
declare(strict_types=1);

// src/ValidadorNif.php
namespace App;

final class ValidadorNif
{
    private const string LETRAS = 'TRWAGMYFPDXBNJZSQVHLCKE';

    public static function esValido(string $nif): bool
    {
        $nif = strtoupper(trim($nif));

        if (!preg_match('/^(\d{8})([A-Z])$/', $nif, $m)) {
            return false;
        }
        return self::LETRAS[(int) $m[1] % 23] === $m[2];
    }
}
<?php
declare(strict_types=1);

// tests/Unit/ValidadorNifTest.php
namespace App\Tests\Unit;

use App\ValidadorNif;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;

#[CoversClass(ValidadorNif::class)]
final class ValidadorNifTest extends TestCase
{
    /** El provider debe ser public y static */
    public static function nifsValidos(): array
    {
        return [
            'número normal'       => ['12345678Z'],
            'con minúscula'       => ['12345678z'],
            'con espacios'        => ['  00000000T  '],
            'otro válido'         => ['87654321X'],
        ];
    }

    public static function nifsInvalidos(): iterable
    {
        yield 'letra incorrecta'   => ['12345678A'];
        yield 'demasiado corto'    => ['1234567Z'];
        yield 'sin letra'          => ['12345678'];
        yield 'vacío'              => [''];
        yield 'letras en medio'    => ['1234A678Z'];
    }

    #[DataProvider('nifsValidos')]
    public function testAceptaNifsValidos(string $nif): void
    {
        $this->assertTrue(ValidadorNif::esValido($nif));
    }

    #[DataProvider('nifsInvalidos')]
    public function testRechazaNifsInvalidos(string $nif): void
    {
        $this->assertFalse(ValidadorNif::esValido($nif));
    }

    // Para pocos casos, #[TestWith] evita escribir el provider
    #[TestWith(['12345678Z', true])]
    #[TestWith(['12345678A', false])]
    public function testConTestWith(string $nif, bool $esperado): void
    {
        $this->assertSame($esperado, ValidadorNif::esValido($nif));
    }
}
Validador Nif (App\Tests\Unit\ValidadorNif)
 ✔ Acepta nifs validos with data set "número normal"
 ✔ Acepta nifs validos with data set "con minúscula"
 ✔ Acepta nifs validos with data set "con espacios"
 ✔ Acepta nifs validos with data set "otro válido"
 ✔ Rechaza nifs invalidos with data set "letra incorrecta"
 ...

💡 Consejo: Usa claves descriptivas en el provider ('letra incorrecta' => [...]). Cuando un caso falle, PHPUnit te dirá cuál por su nombre en lugar de “data set #3”.


8. setUp() y tearDown()

Si varios tests necesitan la misma preparación, ponla en setUp(): PHPUnit lo ejecuta antes de cada test. tearDown() se ejecuta después de cada test (para limpiar ficheros temporales, por ejemplo).

<?php
declare(strict_types=1);

// src/Carrito.php
namespace App;

final class Carrito
{
    /** @var array<string, array{precio: float, cantidad: int}> */
    private array $lineas = [];

    public function agregar(string $sku, float $precio, int $cantidad = 1): void
    {
        if ($cantidad < 1) {
            throw new \InvalidArgumentException('La cantidad debe ser al menos 1');
        }
        $this->lineas[$sku] ??= ['precio' => $precio, 'cantidad' => 0];
        $this->lineas[$sku]['cantidad'] += $cantidad;
    }

    public function quitar(string $sku): void
    {
        unset($this->lineas[$sku]);
    }

    public function unidades(): int
    {
        return array_sum(array_column($this->lineas, 'cantidad'));
    }

    public function total(float $descuentoPorcentaje = 0): float
    {
        $bruto = array_sum(array_map(fn(array $l) => $l['precio'] * $l['cantidad'], $this->lineas));
        return round($bruto * (1 - $descuentoPorcentaje / 100), 2);
    }

    public function estaVacio(): bool
    {
        return $this->lineas === [];
    }
}
<?php
declare(strict_types=1);

namespace App\Tests\Unit;

use App\Carrito;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Carrito::class)]
final class CarritoTest extends TestCase
{
    private Carrito $carrito;

    protected function setUp(): void
    {
        // Cada test recibe un carrito NUEVO: los tests no se contaminan entre sí
        $this->carrito = new Carrito();
        $this->carrito->agregar('TECLADO', 79.90);
        $this->carrito->agregar('RATON', 34.50, 2);
    }

    public function testCuentaLasUnidades(): void
    {
        $this->assertSame(3, $this->carrito->unidades());
    }

    public function testCalculaElTotal(): void
    {
        $this->assertSame(148.90, $this->carrito->total());
    }

    public function testAplicaDescuento(): void
    {
        $this->assertSame(134.01, $this->carrito->total(descuentoPorcentaje: 10));
    }

    public function testAgregarElMismoProductoSumaCantidades(): void
    {
        $this->carrito->agregar('RATON', 34.50);
        $this->assertSame(4, $this->carrito->unidades());
    }

    public function testQuitarUnProducto(): void
    {
        $this->carrito->quitar('TECLADO');
        $this->carrito->quitar('RATON');
        $this->assertTrue($this->carrito->estaVacio());
    }

    public function testRechazaCantidadCero(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->carrito->agregar('CABLE', 5.0, 0);
    }
}

⚠️ Importante: Los tests deben ser independientes: cada uno debe funcionar solo y en cualquier orden. Nunca hagas que un test dependa del estado que dejó otro. setUp() garantiza un punto de partida limpio.


9. Dobles de prueba: stubs y mocks

Imagina una clase ServicioPedidos que cobra con una pasarela de pago y envía un email. En un test unitario no quieres cobrar de verdad ni enviar emails. Necesitas sustitutos.

📘 Concepto: Un doble de prueba (test double) es un objeto falso que sustituye a una dependencia real durante un test. Como los dobles de cine: se parecen al actor y hacen la escena peligrosa en su lugar. Los dos tipos principales son:

  • Stub: devuelve respuestas preparadas. Responde a “¿qué pasa si la pasarela acepta el pago?”.
  • Mock: además, verifica que se le ha llamado como se esperaba. Responde a “¿se ha enviado el email exactamente una vez?”.

Para poder sustituir dependencias, la clase debe recibirlas (inyección de dependencias) y depender de interfaces:

<?php
declare(strict_types=1);

namespace App\Pedidos;

interface PasarelaPago
{
    /** Devuelve el id de la transacción o lanza PagoRechazado */
    public function cobrar(int $centimos, string $tarjeta): string;
}

interface Notificador
{
    public function enviar(string $email, string $asunto, string $mensaje): void;
}

final class PagoRechazado extends \RuntimeException {}

final class ServicioPedidos
{
    public function __construct(
        private PasarelaPago $pasarela,
        private Notificador $notificador,
    ) {}

    public function pagar(int $pedidoId, int $centimos, string $tarjeta, string $email): string
    {
        if ($centimos <= 0) {
            throw new \InvalidArgumentException('Importe no válido');
        }

        $transaccion = $this->pasarela->cobrar($centimos, $tarjeta);

        $this->notificador->enviar(
            $email,
            "Pedido $pedidoId confirmado",
            sprintf('Hemos cobrado %.2f €. Referencia: %s', $centimos / 100, $transaccion),
        );

        return $transaccion;
    }
}

Stubs con createStub()

<?php
declare(strict_types=1);

namespace App\Tests\Unit\Pedidos;

use App\Pedidos\{Notificador, PagoRechazado, PasarelaPago, ServicioPedidos};
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(ServicioPedidos::class)]
final class ServicioPedidosTest extends TestCase
{
    public function testDevuelveLaReferenciaDeLaTransaccion(): void
    {
        $pasarela = $this->createStub(PasarelaPago::class);
        $pasarela->method('cobrar')->willReturn('TX-123');

        $servicio = new ServicioPedidos($pasarela, $this->createStub(Notificador::class));

        $this->assertSame('TX-123', $servicio->pagar(1, 4990, '4242...', 'ana@example.com'));
    }

    public function testSiElPagoSeRechazaLaExcepcionSePropaga(): void
    {
        $pasarela = $this->createStub(PasarelaPago::class);
        $pasarela->method('cobrar')->willThrowException(new PagoRechazado('Fondos insuficientes'));

        $servicio = new ServicioPedidos($pasarela, $this->createStub(Notificador::class));

        $this->expectException(PagoRechazado::class);
        $servicio->pagar(1, 4990, '4242...', 'ana@example.com');
    }

Mocks con createMock() y expects()

<?php
    public function testEnviaUnEmailDeConfirmacionConLosDatosCorrectos(): void
    {
        $pasarela = $this->createStub(PasarelaPago::class);
        $pasarela->method('cobrar')->willReturn('TX-777');

        $notificador = $this->createMock(Notificador::class);
        $notificador->expects($this->once())               // exactamente una vez
            ->method('enviar')
            ->with(
                'ana@example.com',
                'Pedido 42 confirmado',
                $this->stringContains('49.90'),             // constraint: el mensaje contiene "49.90"
            );

        (new ServicioPedidos($pasarela, $notificador))->pagar(42, 4990, '4242...', 'ana@example.com');
        // Al terminar el test, PHPUnit verifica que se cumplieron las expectativas
    }

    public function testSiElPagoFallaNoSeEnviaEmail(): void
    {
        $pasarela = $this->createStub(PasarelaPago::class);
        $pasarela->method('cobrar')->willThrowException(new PagoRechazado());

        $notificador = $this->createMock(Notificador::class);
        $notificador->expects($this->never())->method('enviar');

        $this->expectException(PagoRechazado::class);
        (new ServicioPedidos($pasarela, $notificador))->pagar(42, 4990, '4242...', 'ana@example.com');
    }

    public function testCobraElImporteExacto(): void
    {
        $pasarela = $this->createMock(PasarelaPago::class);
        $pasarela->expects($this->once())
            ->method('cobrar')
            ->with(4990, '4242...')
            ->willReturn('TX-1');

        (new ServicioPedidos($pasarela, $this->createStub(Notificador::class)))
            ->pagar(1, 4990, '4242...', 'ana@example.com');
    }
}

📘 Concepto: En with() puedes pasar valores exactos ('ana@example.com') o constraints (restricciones) como $this->stringContains(), $this->greaterThan(), $this->isInstanceOf() o $this->callback(fn($x) => ...). Así compruebas solo lo que te importa del argumento y el test no se rompe si cambias, por ejemplo, la redacción exacta del email.

Configuración Significado
->method('x')->willReturn($v) Al llamar a x(), devuelve $v
->willReturn($a, $b, $c) Devuelve $a, luego $b, luego $c en llamadas sucesivas
->willThrowException($e) Lanza la excepción
->willReturnCallback(fn(...) => ...) Calcula la respuesta con una función
->expects($this->once()) Debe llamarse exactamente una vez (solo mocks)
->expects($this->never()) No debe llamarse nunca
->expects($this->exactly(3)) Exactamente 3 veces
->with($a, $b) Con esos argumentos (o constraints como $this->stringContains())

💡 Consejo: Usa createStub() cuando solo necesitas que la dependencia responda, y createMock() solo cuando quieres verificar la interacción. Las versiones recientes de PHPUnit avisan si creas un mock sin configurar ninguna expectativa (en ese caso debería ser un stub) y PHPUnit 13 desaconseja el comparador any(): elige once(), exactly()… o usa un stub.

⚠️ Importante: No abuses de los mocks. Si para probar una clase necesitas cinco mocks con expectativas detalladas, el test está acoplado a la implementación y se romperá con cualquier refactorización. Suele ser señal de que la clase hace demasiadas cosas.


10. Probar código que usa la base de datos: SQLite en memoria

Para un test de integración del repositorio queremos una base de datos real pero rápida, aislada y desechable. SQLite en memoria (sqlite::memory:) es perfecta: se crea en microsegundos y desaparece al terminar.

<?php
declare(strict_types=1);

// src/Repository/ProductoRepository.php
namespace App\Repository;

use PDO;

final class ProductoRepository
{
    public function __construct(private PDO $pdo) {}

    public function crear(string $nombre, float $precio, int $stock): int
    {
        $stmt = $this->pdo->prepare('INSERT INTO productos (nombre, precio, stock) VALUES (?, ?, ?)');
        $stmt->execute([$nombre, $precio, $stock]);
        return (int) $this->pdo->lastInsertId();
    }

    public function buscarPorId(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM productos WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    /** @return list<array> */
    public function conStockBajo(int $umbral = 5): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM productos WHERE stock < ? ORDER BY stock');
        $stmt->execute([$umbral]);
        return $stmt->fetchAll();
    }

    public function descontarStock(int $id, int $cantidad): void
    {
        $stmt = $this->pdo->prepare('UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?');
        $stmt->execute([$cantidad, $id, $cantidad]);

        if ($stmt->rowCount() === 0) {
            throw new \RuntimeException("No hay stock suficiente del producto $id");
        }
    }
}
<?php
declare(strict_types=1);

// tests/Integration/ProductoRepositoryTest.php
namespace App\Tests\Integration;

use App\Repository\ProductoRepository;
use PDO;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(ProductoRepository::class)]
final class ProductoRepositoryTest extends TestCase
{
    private PDO $pdo;
    private ProductoRepository $repo;

    protected function setUp(): void
    {
        // Una base de datos nueva y vacía para CADA test
        $this->pdo = new PDO('sqlite::memory:', options: [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $this->pdo->exec('CREATE TABLE productos (
            id     INTEGER PRIMARY KEY AUTOINCREMENT,
            nombre TEXT NOT NULL,
            precio REAL NOT NULL,
            stock  INTEGER NOT NULL
        )');

        $this->repo = new ProductoRepository($this->pdo);
    }

    public function testCrearYRecuperarUnProducto(): void
    {
        $id = $this->repo->crear('Teclado', 79.90, 10);

        $producto = $this->repo->buscarPorId($id);

        $this->assertNotNull($producto);
        $this->assertSame('Teclado', $producto['nombre']);
        $this->assertEqualsWithDelta(79.90, (float) $producto['precio'], 0.001);
    }

    public function testBuscarUnIdInexistenteDevuelveNull(): void
    {
        $this->assertNull($this->repo->buscarPorId(999));
    }

    public function testListaLosProductosConStockBajoOrdenados(): void
    {
        $this->repo->crear('A', 1, 10);
        $this->repo->crear('B', 1, 3);
        $this->repo->crear('C', 1, 1);

        $nombres = array_column($this->repo->conStockBajo(5), 'nombre');

        $this->assertSame(['C', 'B'], $nombres);
    }

    public function testDescontarStock(): void
    {
        $id = $this->repo->crear('Ratón', 20, 5);

        $this->repo->descontarStock($id, 2);

        $this->assertSame(3, (int) $this->repo->buscarPorId($id)['stock']);
    }

    public function testNoPermiteDejarElStockEnNegativo(): void
    {
        $id = $this->repo->crear('Ratón', 20, 1);

        $this->expectException(\RuntimeException::class);
        $this->repo->descontarStock($id, 5);
    }
}

💡 Consejo: Este test funciona porque el repositorio recibe el PDO (inyección de dependencias). Si la clase creara la conexión por dentro con new PDO('mysql:...'), no podrías sustituirla. Recuerda la limitación: SQLite y MySQL no tienen exactamente el mismo SQL. Para consultas muy específicas de MySQL, haz tests de integración contra un MySQL real (por ejemplo, en un contenedor Docker).


11. Cobertura de código

📘 Concepto: La cobertura (code coverage) mide qué porcentaje de las líneas (y ramas) de tu código se ejecuta durante los tests. Te ayuda a descubrir zonas sin probar.

Necesitas un driver de cobertura: Xdebug (que quizá ya instalaste en el módulo 1 para depurar) o PCOV (más rápido, solo hace cobertura).

# Con Xdebug: activar el modo coverage solo para este comando
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html var/coverage

# Con PCOV instalado y activado, no hace falta nada más
vendor/bin/phpunit --coverage-html var/coverage

En PowerShell:

$env:XDEBUG_MODE = "coverage"; vendor\bin\phpunit --coverage-html var\coverage

Salida de --coverage-text:

Code Coverage Report:
  2026-10-03 10:42:17

 Summary:
  Classes: 80.00% (4/5)
  Methods: 91.67% (11/12)
  Lines:   94.12% (64/68)

App\Carrito
  Methods: 100.00% ( 5/ 5)   Lines: 100.00% ( 14/ 14)
App\Pedidos\ServicioPedidos
  Methods: 100.00% ( 2/ 2)   Lines: 100.00% ( 10/ 10)
App\Repository\ProductoRepository
  Methods:  80.00% ( 4/ 5)   Lines:  85.71% ( 18/ 21)

El informe HTML (var/coverage/index.html) muestra cada fichero con las líneas en verde (ejecutadas) y en rojo (nunca ejecutadas).

⚠️ Importante: Una cobertura del 100 % no significa que el código no tenga errores: solo que cada línea se ha ejecutado alguna vez, no que se hayan comprobado todos los casos. Úsala como linterna para encontrar código sin probar, no como objetivo en sí. Un 70-90 % en la lógica de negocio es un buen nivel.


12. TDD paso a paso

📘 Concepto: TDD (Test-Driven Development, desarrollo guiado por pruebas) consiste en escribir el test antes que el código, en ciclos muy cortos de tres pasos:

  1. 🔴 Rojo: escribe un test para un comportamiento nuevo. Ejecútalo: debe fallar.
  2. 🟢 Verde: escribe el código mínimo para que pase.
  3. 🔵 Refactor: mejora el código (y los tests) sin cambiar el comportamiento, con los tests en verde.
flowchart LR
    R["🔴 Rojo<br/>test que falla"] --> G["🟢 Verde<br/>código mínimo"]
    G --> F["🔵 Refactor<br/>mejorar sin romper"]
    F --> R

Vamos a desarrollar con TDD una clase Slugger que convierte títulos en slugs para URLs: "¡Hola, Mundo PHP!" → "hola-mundo-php".

Ciclo 1: minúsculas

🔴 Primero el test:

<?php
declare(strict_types=1);

namespace App\Tests\Unit;

use App\Slugger;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Slugger::class)]
final class SluggerTest extends TestCase
{
    public function testConvierteAMinusculas(): void
    {
        $this->assertSame('php', (new Slugger())->slug('PHP'));
    }
}
1) App\Tests\Unit\SluggerTest::testConvierteAMinusculas
Error: Class "App\Slugger" not found

🟢 El código mínimo:

<?php
declare(strict_types=1);

namespace App;

final class Slugger
{
    public function slug(string $texto): string
    {
        return strtolower($texto);
    }
}
OK (1 test, 1 assertion)

Ciclo 2: espacios por guiones

🔴

<?php
    public function testCambiaLosEspaciosPorGuiones(): void
    {
        $this->assertSame('hola-mundo', (new Slugger())->slug('Hola Mundo'));
    }
Failed asserting that two strings are identical.
-'hola-mundo'
+'hola mundo'

🟢

<?php
    public function slug(string $texto): string
    {
        return str_replace(' ', '-', strtolower($texto));
    }

Ciclo 3: tildes y eñes

🔴

<?php
    public function testEliminaTildesYEnes(): void
    {
        $this->assertSame('cancion-del-nino', (new Slugger())->slug('Canción del Niño'));
    }
-'cancion-del-nino'
+'canción-del-niño'

Además, strtolower() solo convierte letras ASCII: con un título como "CANCIÓN" dejaría la Ó en mayúscula. Necesitamos mb_strtolower().

🟢

<?php
final class Slugger
{
    private const array TILDES = ['á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u', 'ü' => 'u', 'ñ' => 'n'];

    public function slug(string $texto): string
    {
        return str_replace(' ', '-', strtr(mb_strtolower($texto), self::TILDES));
    }
}

Ciclo 4: signos de puntuación y espacios repetidos

🔴 Ahora tenemos varios casos: buen momento para un data provider.

<?php
    public static function casos(): array
    {
        return [
            'puntuación'          => ['¡Hola, Mundo!', 'hola-mundo'],
            'espacios repetidos'  => ['PHP    8.5', 'php-8-5'],
            'espacios en bordes'  => ['  Curso de PHP  ', 'curso-de-php'],
            'guiones existentes'  => ['MVC - Parte 1', 'mvc-parte-1'],
            'solo símbolos'       => ['¿¡!?', ''],
        ];
    }

    #[DataProvider('casos')]
    public function testCasosVariados(string $entrada, string $esperado): void
    {
        $this->assertSame($esperado, (new Slugger())->slug($entrada));
    }

🟢

<?php
    public function slug(string $texto): string
    {
        $texto = strtr(mb_strtolower(mb_trim($texto)), self::TILDES);
        $texto = preg_replace('/[^a-z0-9]+/', '-', $texto);
        return trim($texto, '-');
    }
OK (8 tests, 8 assertions)

Refactor: el operador pipe de PHP 8.5

🔵 Con todos los tests en verde, podemos reescribir la función de forma más expresiva con el operador |> (módulo 7). Si nos equivocamos, los tests nos avisarán:

<?php
declare(strict_types=1);

namespace App;

final class Slugger
{
    private const array TILDES = ['á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u', 'ü' => 'u', 'ñ' => 'n'];

    public function slug(string $texto): string
    {
        return $texto
            |> mb_trim(...)
            |> mb_strtolower(...)
            |> (fn(string $s): string => strtr($s, self::TILDES))
            |> (fn(string $s): string => preg_replace('/[^a-z0-9]+/', '-', $s))
            |> (fn(string $s): string => trim($s, '-'));
    }
}
OK (8 tests, 8 assertions)

💡 Consejo: La gran ventaja de TDD no es “tener tests”, sino que te obliga a pensar primero en cómo se usa tu código y en los casos límite (cadena vacía, solo símbolos…). Y el paso de refactor es seguro porque la red de tests ya está tendida.


13. Pest: una alternativa

Pest es un framework de testing construido sobre PHPUnit con una sintaxis más compacta, inspirada en Jest (JavaScript). Es muy popular en el ecosistema Laravel. Como usa PHPUnit por debajo, puedes mezclar tests de ambos estilos en el mismo proyecto.

composer require pestphp/pest --dev --with-all-dependencies
vendor/bin/pest --init
<?php
// tests/Unit/CarritoPestTest.php
use App\Carrito;

beforeEach(function () {
    $this->carrito = new Carrito();
    $this->carrito->agregar('TECLADO', 79.90);
});

test('calcula el total', function () {
    expect($this->carrito->total())->toBe(79.90);
});

it('aplica descuentos', function () {
    expect($this->carrito->total(10))->toBe(71.91);
});

it('rechaza cantidades no positivas', function () {
    $this->carrito->agregar('RATON', 10, 0);
})->throws(InvalidArgumentException::class);

test('valida NIFs', function (string $nif, bool $esperado) {
    expect(App\ValidadorNif::esValido($nif))->toBe($esperado);
})->with([
    ['12345678Z', true],
    ['12345678A', false],
]);
  PHPUnit Pest
Estilo Clases y métodos Funciones test() / it() y expect()
Base Propio Construido sobre PHPUnit
Curva de aprendizaje La estándar del ecosistema Muy rápida si vienes de JavaScript
Usado en Symfony, librerías, la mayoría de proyectos Muy extendido en Laravel

💡 Consejo: Aprende primero PHPUnit: es el estándar, lo usan Symfony y casi todas las librerías, y entenderlo te permite usar Pest sin problemas.


14. Tests en GitHub Actions

La integración continua (CI) ejecuta tus tests automáticamente cada vez que subes código o abres un pull request. Si alguien rompe algo, se entera en minutos.

Crea el fichero .github/workflows/tests.yml en tu repositorio:

name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  phpunit:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        php: ['8.4', '8.5']

    name: PHP ${{ matrix.php }}

    steps:
      - name: Descargar el código
        uses: actions/checkout@v6

      - name: Instalar PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: pdo_sqlite, mbstring
          coverage: pcov

      - name: Validar composer.json
        run: composer validate --strict

      - name: Instalar dependencias
        run: composer install --no-progress --prefer-dist

      - name: Auditar dependencias
        run: composer audit

      - name: Ejecutar tests
        run: vendor/bin/phpunit --coverage-text
Parte Qué hace
on Cuándo se ejecuta: al hacer push a main y en cada pull request
matrix.php Ejecuta todo con varias versiones de PHP en paralelo
actions/checkout Descarga el repositorio en la máquina virtual
shivammathur/setup-php Instala la versión de PHP, extensiones y el driver de cobertura
composer audit Falla si alguna dependencia tiene vulnerabilidades conocidas (módulo 16)

💡 Consejo: En GitHub, en Settings → Branches, puedes configurar una regla para que no se pueda fusionar un pull request si los tests fallan. Así la rama principal siempre está en verde.


15. Buenas prácticas y errores comunes

Práctica Por qué
Un test, un comportamiento Cuando falla, sabes exactamente qué se ha roto
Nombres que describen el comportamiento testNoPermiteStockNegativo se entiende sin leer el código
Tests rápidos Si tardan mucho, nadie los ejecuta
Tests deterministas Nada de fechas actuales, aleatoriedad o red sin controlar: un test que falla “a veces” es peor que no tenerlo
Probar comportamiento público, no detalles internos Puedes refactorizar sin reescribir los tests
Escribir un test por cada bug arreglado El bug no volverá
Error común Solución
Class "App\Tests\..." not found Revisa autoload-dev y ejecuta composer dump-autoload
expectException() después del código Ponlo antes de la línea que lanza la excepción
El data provider no es static Los providers deben ser public static
Usar anotaciones @test / @dataProvider En PHPUnit 12+ usa los atributos #[Test], #[DataProvider]
Test que depende de otro Prepara todo en setUp()
Test con la hora actual (new DateTime()) Inyecta la fecha o un “reloj” (por ejemplo Psr\Clock\ClockInterface)
“This test did not perform any assertions” Añade aserciones; un test sin ellas no comprueba nada

16. Ejercicios

Ejercicio 1: Primeros tests

Instala PHPUnit 13 en un proyecto nuevo con Composer, crea el phpunit.xml y escribe tests para una clase Temperatura con los métodos estáticos celsiusAFahrenheit(), fahrenheitACelsius() y celsiusAKelvin(). Usa assertEqualsWithDelta() y haz que celsiusAKelvin() lance DomainException por debajo del cero absoluto (-273,15 °C), probándolo con expectException().

Ejercicio 2: Data providers para un validador de contraseñas

Escribe una clase PoliticaContrasenas con un método errores(string $clave): array que devuelva la lista de reglas incumplidas (mínimo 12 caracteres, al menos una letra, al menos un número, no estar en una lista de contraseñas comunes). Escribe un data provider con al menos 8 casos con nombre que compruebe los errores esperados de cada uno.

Ejercicio 3: Probar el carrito a fondo

Amplía Carrito con: cambiarCantidad(string $sku, int $cantidad) (si es 0, se elimina la línea), cupones de descuento (aplicarCupon(Cupon $c), donde Cupon puede ser porcentual o de importe fijo) y un total que nunca sea negativo. Escribe los tests antes del código, siguiendo TDD, y alcanza el 100 % de cobertura de la clase.

Ejercicio 4: Stubs y mocks

Crea una clase ServicioRegistro que dependa de RepositorioUsuarios (interfaz con existeEmail() y guardar()) y de Notificador. Al registrar: si el email existe, lanza EmailYaRegistrado y no guarda ni notifica; si no existe, guarda el usuario con la contraseña hasheada y envía un email de bienvenida. Escribe al menos cuatro tests usando createStub() y createMock() con expects(), with() y never(). Comprueba con willReturnCallback() que la contraseña guardada no es la original y que password_verify() la acepta.

Ejercicio 5: Test de integración con SQLite

Toma el TareaRepository del módulo 17 y escribe un test de integración con SQLite en memoria que cubra: crear, buscar, listar con paginación (crea 25 tareas y comprueba la página 3 de 10), filtrar por completada, actualizar y borrar.

Ejercicio 6: TDD de una calculadora de envíos

Siguiendo estrictamente el ciclo rojo-verde-refactor (escribe en un fichero de notas cada ciclo), desarrolla una clase CalculadoraEnvio con estas reglas: envío gratis a partir de 50 €; 4,95 € para pedidos de hasta 2 kg; 6,95 € hasta 10 kg; a partir de ahí, 6,95 € + 0,50 € por kg adicional (o fracción); Canarias, Ceuta y Melilla suman 10 €; y el envío gratis no se aplica a pedidos de más de 30 kg.

Ejercicio 7: Integración continua

Sube a GitHub uno de los proyectos anteriores y añade el workflow de la sección 14. Provoca a propósito un test que falle en una rama nueva, abre un pull request y observa cómo GitHub lo marca en rojo. Corrígelo y comprueba que pasa a verde. Añade una insignia (badge) de estado del workflow en el README.md del repositorio.


Resumen

Concepto Sintaxis / Clave
Instalar composer require --dev phpunit/phpunit:^13
Ejecutar vendor/bin/phpunit, --filter, --testsuite, --testdox
Clase de test final class XTest extends TestCase
Método de test public function testAlgo(): void o #[Test]
Qué clase cubre #[CoversClass(X::class)]
Comparar assertSame(), assertEquals(), assertEqualsWithDelta()
Excepciones $this->expectException(X::class) antes del código
Data provider public static function casos(): array + #[DataProvider('casos')]
Casos en línea #[TestWith([...])]
Preparar / limpiar setUp(), tearDown()
Stub $s = $this->createStub(I::class); $s->method('m')->willReturn($v);
Mock $m = $this->createMock(I::class); $m->expects($this->once())->method('m')->with(...);
BD en tests new PDO('sqlite::memory:')
Cobertura XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html var/coverage o PCOV
TDD 🔴 Rojo → 🟢 Verde → 🔵 Refactor
Alternativa Pest: test('...', fn() => expect($x)->toBe($y))
CI .github/workflows/tests.yml con shivammathur/setup-php