🧪 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.
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 usasetUpBeforeClass()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]);
}
}
💡
RefreshDatabasereinicia la base de datos entre tests; las factories generan datos realistas conUsuario::factory()->create(). Los tests HTTP devuelven unTestResponsecon asserts comoassertOk,assertJsonStructureoassertSessionHas.
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
- PHPUnit — Documentation: asserts, data providers, fixtures y test doubles en profundidad.
- Pest — Documentation: expect, higher-order tests, plugins y testing con Laravel.
- PHPStan — Rule Levels: qué significa cada nivel de análisis estático.
- Psalm — Documentation: taints, niveles y análisis por defecto.
- PHP The Right Way — Testing: buenas prácticas de testing en el ecosistema PHP.
- Ruta completa: Backend con PHP.