TDD en Laravel: escribir tests que realmente importan

Más allá del testing ritualista hacia validación que importa
25.11.2025 — Liquid Team — 8 min read

La paradoja de la cobertura

El ecosistema Laravel promueve el testing mediante PHPUnit y Pest, pero muchos proyectos muestran una paradoja: alta cobertura de tests con baja confianza en la corrección del sistema. Esto surge de testear detalles de implementación en lugar de comportamiento, centrándose en getters y setters triviales, y descuidando puntos críticos de integración.

Los tests efectivos deben validar tres dominios: invariantes de negocio (reglas que deben mantenerse independientemente de la implementación), límites de integración (puntos donde interactúan sistemas externos), y casos extremos (escenarios donde la lógica se ramifica o falla).

Lógica de negocio: más allá del CRUD

La mayoría de aplicaciones Laravel testean operaciones CRUD exhaustivamente mientras ignoran reglas de dominio. Considera un sistema de inventario e-commerce donde la invariante crítica es la consistencia del stock.

En lugar de testear que un producto tiene un atributo de stock, testea la regla de negocio:

test('no se puede realizar pedido cuando stock insuficiente', function () {
    $product = Product::factory()->create(['stock' => 2]);
    
    expect(fn() => Order::create([
        'product_id' => $product->id,
        'quantity' => 5
    ]))->toThrow(InsufficientStockException::class);
    
    expect($product->fresh()->stock)->toBe(2); // Sin deducción parcial
});

test('deducción de stock es atómica con pedidos concurrentes', function () {
    $product = Product::factory()->create(['stock' => 10]);
    
    DB::transaction(function () use ($product) {
        Order::create(['product_id' => $product->id, 'quantity' => 6]);
        Order::create(['product_id' => $product->id, 'quantity' => 6]);
    });
    
    // Uno debería fallar, el stock nunca debe ser negativo
    expect($product->fresh()->stock)->toBeGreaterThanOrEqual(0);
});

Estos tests aseguran que las reglas de negocio se mantienen bajo condiciones normales y concurrentes, validando corrección del sistema en lugar de artefactos de implementación.

Límites de integración: testear dependencias externas

Servicios externos como pasarelas de pago, motores de búsqueda y APIs representan puntos de fallo. En lugar de mockear indiscriminadamente, testea el cumplimiento de contratos en estos límites:

test('servicio de búsqueda devuelve productos con SKU exacto', function () {
    $product = Product::factory()->create([
        'sku' => 'WINE-RIOJA-2019',
        'name' => 'Rioja Reserve'
    ]);
    
    $product->searchable();
    
    $results = Product::search('WINE-RIOJA-2019')->get();
    
    expect($results)->toHaveCount(1)
        ->and($results->first()->id)->toBe($product->id);
});

test('fallo de pasarela de pago revierte creación de pedido', function () {
    Queue::fake();
    Http::fake([
        'payment-gateway.com/*' => Http::response(['error' => 'Card declined'], 402)
    ]);
    
    expect(fn() => Order::createWithPayment([
        'amount' => 100,
        'card_token' => 'tok_invalid'
    ]))->toThrow(PaymentFailedException::class);
    
    expect(Order::count())->toBe(0);
    Queue::assertNothingPushed();
});

Estos tests validan comportamiento en límites del sistema donde los fallos son más probables y costosos, asegurando integridad transaccional a través de dependencias externas.

Feature tests: flujos de usuario sobre detalles HTTP

Los feature tests deben validar flujos de usuario completos, no solo códigos de estado HTTP. En lugar de testear que un endpoint devuelve 200, testea todo el flujo de negocio:

test('usuario invitado completa compra y recibe confirmación', function () {
    $product = Product::factory()->create(['price' => 5000]);
    
    Mail::fake();
    
    $this->post('/checkout', [
        'product_id' => $product->id,
        'email' => 'customer@example.com',
        'payment_method' => 'card'
    ])->assertRedirect('/order/confirmation');
    
    $order = Order::where('email', 'customer@example.com')->first();
    
    expect($order)
        ->not->toBeNull()
        ->and($order->total)->toBe(5000)
        ->and($order->status)->toBe('paid');
    
    Mail::assertSent(OrderConfirmation::class, fn($mail) => 
        $mail->hasTo('customer@example.com')
    );
});

Este test valida todo el workflow: persistencia, procesamiento de pago, envío de email y transiciones de estado—asegurando que el sistema funciona como esperan los usuarios.

Integridad de datos: testear restricciones

Las restricciones de base de datos a menudo quedan sin testear hasta que ocurren fallos en producción. Testea invariantes críticas a nivel de base de datos:

test('SKUs duplicados se rechazan a nivel de base de datos', function () {
    Product::factory()->create(['sku' => 'DUPLICATE-SKU']);
    
    expect(fn() => Product::factory()->create(['sku' => 'DUPLICATE-SKU']))
        ->toThrow(QueryException::class);
});

test('total de pedido coincide con líneas tras actualizaciones', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['price' => 1000, 'quantity' => 2],
        ['price' => 1500, 'quantity' => 1],
    ]);
    
    $order->recalculateTotal();
    expect($order->total)->toBe(3500);
    
    $order->items()->first()->update(['quantity' => 3]);
    $order->recalculateTotal();
    
    expect($order->fresh()->total)->toBe(4500);
});

Estos tests aseguran que las reglas de integridad se aplican consistentemente, capturando errores antes de que lleguen a producción.

Casos extremos: condiciones límite

Los fallos críticos ocurren en límites. Testéalos explícitamente para capturar escenarios que causan incidentes en producción:

test('paginación maneja resultados vacíos', function () {
    $this->get('/products?page=999')
        ->assertOk()
        ->assertJson(['data' => []]);
});

test('operaciones masivas manejan fallos parciales correctamente', function () {
    $validProduct = Product::factory()->create(['stock' => 10]);
    $invalidProduct = Product::factory()->create(['stock' => 0]);
    
    $results = ProductService::bulkOrder([
        ['product_id' => $validProduct->id, 'quantity' => 5],
        ['product_id' => $invalidProduct->id, 'quantity' => 1],
    ]);
    
    expect($results['successful'])->toHaveCount(1)
        ->and($results['failed'])->toHaveCount(1)
        ->and($validProduct->fresh()->stock)->toBe(5);
});

Testear casos extremos como resultados vacíos, fallos parciales y límites de zona horaria previene comportamientos inesperados en producción.

Qué no testear

Evita testear funcionalidad del framework, internos de paquetes third-party, accessors triviales y métodos privados. En lugar de testear que un producto tiene un accessor de nombre, testea cómo se usa ese nombre en lógica de negocio:

// No testees esto
test('producto tiene accessor de nombre', function () {
    $product = new Product(['name' => 'Wine']);
    expect($product->name)->toBe('Wine');
});

// Testea esto en su lugar
test('búsqueda de producto indexa nombre para queries full-text', function () {
    $product = Product::factory()->create(['name' => 'Rioja Reserve Wine']);
    $product->searchable();
    
    expect(Product::search('Rioja')->get())->toContain($product);
});

El segundo test valida comportamiento real del sistema del que dependen los usuarios, mientras el primero simplemente confirma funcionalidad básica de Laravel.

El objetivo no es el porcentaje de cobertura sino la cobertura de caminos críticos—los escenarios donde el fallo impone el mayor coste en usuarios y lógica de negocio.
Liquid Team
Equipo de Desarrollo

Conclusión

TDD efectivo en Laravel requiere distinguir entre tests que verifican artefactos de implementación y aquellos que validan comportamiento del sistema. Al enfocarse en invariantes de negocio, límites de integración y casos extremos, los desarrolladores construyen suites de tests que proporcionan confianza genuina mientras permanecen resilientes al refactoring.

Este enfoque transforma el testing de un ritual de alcanzar métricas de cobertura a una práctica estratégica que protege funcionalidad crítica y experiencia de usuario. Los tests que importan son aquellos que capturan fallos reales antes de que lleguen a producción.

💡 ¿Cuánto cuesta desarrollar una app para tu negocio? Ver precios