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
TestCasey los atributos#[Test],#[DataProvider]y#[CoversClass] - Conocer las aserciones principales y probar excepciones
- Usar data providers,
setUp()ytearDown() - Crear dobles de prueba con
createStub()ycreateMock() - 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()aassertEquals()con valores escalares: es más estricta y detecta errores de tipo (por ejemplo, un"10"que debería ser10). UsaassertEquals()para comparar objetos por su contenido.
💡 Consejo: PHPUnit 13 añade aserciones específicas para arrays, como
assertArraysAreIdentical()oassertArraysHaveIdenticalValuesIgnoringOrder(), 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, ycreateMock()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 comparadorany(): eligeonce(),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 connew 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:
- 🔴 Rojo: escribe un test para un comportamiento nuevo. Ejecútalo: debe fallar.
- 🟢 Verde: escribe el código mínimo para que pase.
- 🔵 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 |