Las versiones modernas de PHPUnit han cambiado radicalmente su estilo. La generacion actual, PHPUnit 10 y posteriores, apuesta por atributos nativos de PHP para reemplazar las antiguas anotaciones en comentarios. El resultado son tests mas cortos, mejor analisis estatico y mayor alineacion con el resto del ecosistema de PHP 8.
Esta leccion profundiza en los recursos que te permiten escribir una suite de pruebas expresiva y mantenible: atributos, data providers, fixtures, assertions estrictas y buenas practicas de organizacion.
Tests con atributos
En PHPUnit 10, un test es un metodo publico de una clase que extiende TestCase y lleva el atributo #[Test]. Atras quedan las anotaciones @test en docblock o el convenio de prefijar el nombre con test.
<?php
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
final class CalculadoraTest extends TestCase
{
#[Test]
public function suma_dos_enteros_positivos(): void
{
$this->assertSame(5, 2 + 3);
}
#[Test]
public function resta_devuelve_cero_cuando_operandos_iguales(): void
{
$this->assertSame(0, 7 - 7);
}
}
El nombre del metodo se convierte en parte de la salida del runner, por lo que conviene que describa claramente que hace y en que condicion. El estilo metodo_escenario_resultado funciona muy bien en espanol.
El atributo #[Test] convive con otros que matizan el comportamiento. Group etiqueta tests para ejecutarlos por categoria, Depends indica dependencia entre tests, CoversClass declara sobre que clase de produccion se calcula cobertura.
<?php
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use App\Domain\Calculadora;
#[CoversClass(Calculadora::class)]
#[Group("aritmetica")]
final class CalculadoraTest extends TestCase
{
// ...
}
La combinacion #[CoversClass] es especialmente util con el nuevo parametro requireCoverageMetadata de phpunit.xml, que detecta tests sin metadata de cobertura.
Data providers
Un data provider es un metodo que devuelve conjuntos de datos para parametrizar un test. En lugar de repetir casi el mismo test con distintos valores, escribes uno solo y el provider alimenta cada caso.
<?php
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
final class SumaTest extends TestCase
{
public static function casosDeSuma(): array
{
return [
"dos positivos" => [2, 3, 5],
"positivo y negativo" => [10, -4, 6],
"dos negativos" => [-1, -2, -3],
"con cero" => [0, 7, 7],
];
}
#[Test]
#[DataProvider("casosDeSuma")]
public function suma_correctamente(int $a, int $b, int $esperado): void
{
$this->assertSame($esperado, $a + $b);
}
}
Las claves del array (como "dos positivos") aparecen en el informe del runner, lo que facilita localizar que caso concreto ha fallado. El metodo del provider debe ser public static, y puede devolver un array o un Generator.
<?php
public static function casosValidosDeEmail(): \Generator
{
yield "con guion" => ["u-suario@example.com"];
yield "con subdominio" => ["admin@mail.empresa.es"];
yield "con numeros" => ["user123@example.org"];
}
El uso de Generator ahorra memoria cuando los datos son numerosos y permite calcularlos en lugar de enumerarlos.
Un test puede combinar varios providers con distintos conjuntos de datos.
<?php
#[Test]
#[DataProvider("casosValidosDeEmail")]
#[DataProvider("casosValidosDeEmailInternacional")]
public function email_se_acepta(string $email): void
{
$this->assertTrue($this->validarEmail($email));
}
Fixtures con setUp y tearDown
Muchas pruebas requieren preparar un estado comun antes de ejecutarse. Los metodos setUp y tearDown se ejecutan antes y despues de cada test, y setUpBeforeClass y tearDownAfterClass una vez para toda la clase.
<?php
final class RepositorioUsuarioTest extends TestCase
{
private RepositorioUsuario $repo;
protected function setUp(): void
{
$this->repo = new RepositorioUsuario(new MemoriaAdaptador());
}
#[Test]
public function guardar_almacena_y_recupera(): void
{
$this->repo->guardar(new Usuario(1, "a@a.es"));
$this->assertNotNull($this->repo->buscarPorId(1));
}
}
El estado compartido en propiedades de la clase se resetea entre tests, lo que garantiza que cada uno parte de cero. Si necesitas estado que sobrevive, setUpBeforeClass se ejecuta una vez y se usa para recursos costosos como un fichero grande o una conexion de solo lectura.
Los tests deben ser aislados. Dos tests no deberian compartir estado mutable. Si un test altera estado global, arriesgas ordenes de ejecucion fragiles y fallos intermitentes.
Assertions estrictas
PHPUnit expone una familia extensa de assertions. Algunas son mas estrictas que otras, y en codigo nuevo conviene favorecer las estrictas.
- assertSame comprueba igualdad con
===, tipo incluido. Es la assertion por defecto para valores escalares. - assertEquals comprueba con
==, mas permisiva. Se reserva para casos donde la igualdad estructural es suficiente. - assertInstanceOf verifica que un valor es instancia de una clase o interfaz.
- assertContains revisa pertenencia en arrays o iterables.
- assertCount valida el numero de elementos en una coleccion.
- assertThrows (PHPUnit 11+) captura excepciones lanzadas en un callable.
<?php
#[Test]
public function usuario_se_crea_con_valores_correctos(): void
{
$u = new Usuario(1, "a@a.es");
$this->assertSame(1, $u->id);
$this->assertSame("a@a.es", $u->email);
$this->assertInstanceOf(Usuario::class, $u);
}
La combinacion con funciones especificas del dominio mantiene los tests legibles. No abuses de assertions personalizadas cuando las estandar son suficientes.
Esperar excepciones
Las excepciones se comprueban con expectException y sus variantes, declaradas antes de la accion que las produce.
<?php
#[Test]
public function importe_negativo_lanza_excepcion(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Importe debe ser positivo");
new Cuenta(-100);
}
Es importante que la llamada que lanza aparezca despues de las expect, para que PHPUnit sepa que captura. Si la excepcion no salta, el test falla con un mensaje claro.
Configuracion de phpunit.xml
El archivo phpunit.xml en la raiz del proyecto declara las rutas de tests, la politica de cobertura y otros ajustes. La version 10 introdujo una seccion source que define que codigo se cubre, sustituyendo al antiguo filter.
<?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"
requireCoverageMetadata="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>
</phpunit>
Ejecutar solo la suite unitaria es cuestion de un argumento en linea de comandos.
vendor/bin/phpunit --testsuite unit
Organizar la suite
Los proyectos serios separan tests/Unit para pruebas rapidas y aisladas, tests/Integration para las que tocan infraestructura real (bases de datos, ficheros, servicios) y tests/Feature o tests/E2E para pruebas extremo a extremo. El diagrama ilustra esta estratificacion.
flowchart TB
A[Tests unitarios] -->|Rapidos y aislados| B[Codigo de dominio]
C[Tests de integracion] -->|Lentos, con DB real| D[Repositorios e infra]
E[Tests de feature] -->|End to end| F[Aplicacion completa]
B --> G[CI rapida en cada PR]
D --> H[CI extendida nocturna]
F --> I[Release y smoke tests]
El CI ejecuta las unitarias en cada commit, y las de integracion y feature en ejecuciones mas pesadas. Esta tactica mantiene el feedback rapido donde mas importa.
Buenas practicas
Un test util es rapido, determinista y legible. Rompe una de estas tres caracteristicas y la suite pierde valor con rapidez.
- 1 - Un assert por concepto. Comprueba varias facetas de un comportamiento, pero no mezcles escenarios distintos en el mismo test.
- 2 - Nombres que expliquen. El nombre del metodo debe decir que comprueba, sin necesidad de leer el cuerpo.
- 3 - Evita logica condicional. Si necesitas un
ifdentro del test, probablemente necesitas un segundo test, no una rama. - 4 - Minimiza dependencias externas. Un test que toca la red es un test fragil.
- 5 - Aprovecha data providers. Dos tests casi identicos apuntan a un provider.
En el codigo de produccion buscas flexibilidad y polimorfismo. En los tests buscas lo contrario: explicitud, ausencia de ramas y un flujo lineal. Son dos registros distintos.
La combinacion de atributos, data providers y fixtures con una configuracion adecuada de phpunit.xml convierte una suite de pruebas en un activo del equipo, no en un lastre. Mantenerla rapida y clara es lo que marca la diferencia entre un proyecto que se refactoriza con seguridad y uno que acumula miedo al cambio.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, PHP es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de PHP
Explora más contenido relacionado con PHP y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Escribir tests PHPUnit con los atributos Test, DataProvider, Group y CoversClass, parametrizar casos con providers, usar setUp y tearDown, y aplicar assertions concisas.