TDD significa Test-Driven Development, y la clave está en el orden: primero escribes el test, luego el código que lo satisface. No al revés. Escribir tests después de programar es testing normal, útil también, pero no TDD.
Mucha gente descarta TDD pensando que duplica el tiempo de trabajo. En realidad lo que hace es desplazarlo: dedicas más tiempo al diseño inicial y mucho menos a depurar después. La mayoría de los bugs que encuentras a las tres de la tarde los habrías evitado con un test escrito por la mañana.
El ciclo tiene tres fases:
- Rojo: escribes un test que describe el comportamiento que quieres. Lo ejecutas y falla porque todavía no has escrito el código que lo satisface.
- Verde: escribes el código mínimo necesario para que ese test pase. Solo lo mínimo, sin anticipar nada.
- Refactor: limpias el código sin romper el test. Aquí mejoras el diseño, eliminas duplicaciones y dejas todo legible.
Luego empiezas otra vez con el siguiente requisito. Cada iteración del ciclo añade un comportamiento nuevo al sistema.
Herramientas en Laravel
Laravel incluye PHPUnit por defecto, así que no necesitas instalar nada extra para empezar. Hay dos formas de escribir tests: con la sintaxis clásica de PHPUnit basada en clases, o con Pest, que ofrece una sintaxis funcional más limpia que corre encima del propio PHPUnit.
Para ejecutar los tests tienes el comando de Artisan:
php artisan test
Y para crear una clase de test nueva:
php artisan make:test DiscountCalculatorTest
Por defecto esto crea un test de feature. Si quieres un test unitario puro, añade la opción --unit:
php artisan make:test DiscountCalculatorTest --unit
Ejemplo práctico: clase de descuento
Vamos a construir una clase DiscountCalculator que aplique descuentos según el importe de una compra. Con TDD, antes de crear la clase escribimos el test que la describe.
Paso 1: Rojo
Creamos el test y lo ejecutamos sabiendo que va a fallar:
<?php
namespace TestsUnit;
use AppServicesDiscountCalculator;
use PHPUnitFrameworkTestCase;
class DiscountCalculatorTest extends TestCase
{
public function test_aplica_descuento_del_10_por_pedidos_superiores_a_100(): void
{
$calculator = new DiscountCalculator();
$resultado = $calculator->calculate(150);
$this->assertEquals(135.0, $resultado);
}
}
Al ejecutar php artisan test, obtienes un error en rojo: la clase DiscountCalculator no existe. Eso es exactamente lo que querías.
Paso 2: Verde
Ahora creas la clase con la implementación mínima para que el test pase:
<?php
namespace AppServices;
class DiscountCalculator
{
public function calculate(float $amount): float
{
if ($amount > 100) {
return $amount * 0.9;
}
return $amount;
}
}
Ejecutas los tests de nuevo y pasan. Verde. No has añadido nada que el test no te haya pedido.
Paso 3: Refactor
El código es tan sencillo que apenas hay nada que limpiar, pero en casos más complejos aquí es donde extraes métodos, renombras variables o simplificas condiciones. Lo importante es que después del refactor los tests siguen pasando.
Segunda iteración: nueva regla de negocio
El cliente pide que los pedidos de más de 500 euros tengan un 20% de descuento. Antes de tocar el código, escribes otro test:
public function test_aplica_descuento_del_20_por_pedidos_superiores_a_500(): void
{
$calculator = new DiscountCalculator();
$resultado = $calculator->calculate(600);
$this->assertEquals(480.0, $resultado);
}
Falla. Implementas la nueva regla en calculate(), vuelves a pasar todos los tests y el anterior sigue en verde. Ahí está la seguridad que da TDD: al añadir código nuevo sabes de inmediato si has roto algo que ya funcionaba.
Tests de feature en Laravel
Los tests unitarios comprueban clases aisladas, pero los tests de feature verifican flujos completos: peticiones HTTP, base de datos, autenticación. Laravel hace esto muy cómodo.
El trait RefreshDatabase resetea la base de datos antes de cada test, así no hay interferencias entre pruebas:
<?php
namespace TestsFeature;
use AppModelsUser;
use IlluminateFoundationTestingRefreshDatabase;
use TestsTestCase;
class PedidoTest extends TestCase
{
use RefreshDatabase;
public function test_usuario_autenticado_puede_crear_pedido(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->post('/pedidos', [
'producto_id' => 1,
'cantidad' => 3,
]);
$response->assertStatus(201)
->assertJson([
'message' => 'Pedido creado correctamente',
]);
$this->assertDatabaseHas('pedidos', [
'user_id' => $user->id,
'cantidad' => 3,
]);
}
}
Con actingAs($user) simulas un usuario autenticado sin tener que pasar por el proceso de login. assertStatus(201) verifica el código HTTP y assertJson() comprueba que la respuesta contiene los datos esperados.
Mocks y fakes en Laravel
Cuando tu código envía emails, despacha jobs o lanza eventos, no quieres que esas acciones ocurran de verdad durante los tests. Laravel trae fakes para todos esos casos:
use IlluminateSupportFacadesMail;
use AppMailOrderConfirmation;
public function test_crear_pedido_envia_confirmacion(): void
{
Mail::fake();
$user = User::factory()->create();
$this->actingAs($user)
->post('/pedidos', ['producto_id' => 1, 'cantidad' => 1]);
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
Tienes el mismo patrón para colas, eventos y notificaciones:
Queue::fake()para verificar que un job se despachó sin ejecutarloEvent::fake()para comprobar que un evento se lanzóNotification::fake()para notificacionesHttp::fake()para simular respuestas de APIs externas
La ventaja frente a los mocks manuales de PHPUnit es la legibilidad: la sintaxis de los fakes de Laravel es mucho más directa y el código de los tests queda limpio.
Para APIs externas, Http::fake() es especialmente útil. Puedes simular respuestas concretas sin hacer ninguna petición real:
use IlluminateSupportFacadesHttp;
Http::fake([
'api.pagos.com/*' => Http::response(['status' => 'ok'], 200),
]);
// A partir de aquí, cualquier llamada a api.pagos.com devuelve esa respuesta
TDD con Pest
Pest es una alternativa a la sintaxis de clases de PHPUnit. Corre sobre él, así que es totalmente compatible con tu suite existente. La diferencia es estética y de ergonomía:
it('calcula el descuento del 10% para pedidos superiores a 100', function () {
$calculator = new DiscountCalculator();
expect($calculator->calculate(150))->toBe(135.0);
});
it('aplica el 20% para pedidos superiores a 500', function () {
$calculator = new DiscountCalculator();
expect($calculator->calculate(600))->toBe(480.0);
});
Para probar el mismo comportamiento con varios valores de entrada, Pest tiene dataset():
it('aplica el descuento correcto según el importe', function (float $importe, float $esperado) {
$calculator = new DiscountCalculator();
expect($calculator->calculate($importe))->toBe($esperado);
})->with([
[50.0, 50.0],
[150.0, 135.0],
[600.0, 480.0],
]);
En lugar de repetir el mismo test tres veces, defines los datos y Pest los pasa uno a uno. Si falla alguno, te indica exactamente qué valor de entrada lo provocó.
Para setup compartido entre varios tests, beforeEach() funciona como el método setUp() de PHPUnit pero sin la clase:
beforeEach(function () {
$this->calculator = new DiscountCalculator();
});
it('no aplica descuento para importes bajos', function () {
expect($this->calculator->calculate(50))->toBe(50.0);
});
it('aplica el 10% para importes altos', function () {
expect($this->calculator->calculate(150))->toBe(135.0);
});
Cuándo TDD no es la respuesta
TDD no es una religión. Hay situaciones donde aplicarlo a rajatabla hace más daño que bien.
Los prototipos son el caso más claro. Si estás explorando si una idea funciona y el diseño va a cambiar radicalmente en los próximos días, escribir tests antes puede ser un lastre. Cuando el diseño se estabilice, añades los tests.
El código de UI puro también es difícil de atacar con TDD. Las interacciones visuales, los estados de componentes o la disposición de elementos cambian mucho y los tests de ese tipo son frágiles y caros de mantener.
Los scripts de un solo uso tampoco merecen la inversión. Un script que procesa un CSV una vez y no vas a tocar más no necesita suite de tests.
Y luego está la trampa del 100% de cobertura. La cobertura mide que el código se ejecuta durante los tests, no que funciona correctamente. Puedes tener un 100% de cobertura con tests que no comprueban nada útil. Un 70% de cobertura con tests bien escritos vale más que un 100% con aserciones vacías.
TDD tiene más sentido cuanto más tiempo va a vivir el código y cuanto más crítica sea su lógica de negocio. Una arquitectura en Laravel donde TDD encaja mejor es aquella con capas bien separadas: servicios, repositorios y controladores delgados. Si tu lógica de negocio vive en el controlador, testear con TDD es más complicado porque tienes que levantar toda la capa HTTP para cada prueba.
Si quieres sacar más partido a los tests, vale la pena conocer las funciones PHP modernas útiles en el contexto de tests: algunas hacen el código más expresivo y los tests más fáciles de leer.
En resumen: usa TDD donde tenga sentido, combínalo con testing tradicional donde no lo tenga y no conviertas la cobertura en una métrica de vanidad.
Imagen: Pexels / Pixabay
