TDD in Laravel: writing tests that actually matter

Moving beyond ritualistic testing to validation that matters
25.11.2025 — Liquid Team — 7 min read

The coverage paradox

The Laravel ecosystem promotes testing through PHPUnit and Pest, yet many codebases exhibit a paradox: high test coverage with low confidence in system correctness. This stems from testing implementation details rather than behavior, focusing on trivial getters and setters, and neglecting critical integration points.

Effective tests should validate three domains: business invariants (rules that must hold regardless of implementation), integration boundaries (points where external systems interact), and edge cases (scenarios where logic branches or fails).

Business logic: beyond CRUD

Most Laravel applications test CRUD operations exhaustively while ignoring domain rules. Consider an e-commerce inventory system where the critical invariant is stock consistency.

Instead of testing that a product has a stock attribute, test the business rule:

test('order cannot be placed when stock insufficient', 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); // No partial deduction
});

test('stock deduction is atomic across concurrent orders', 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]);
    });
    
    // One should fail, stock should never go negative
    expect($product->fresh()->stock)->toBeGreaterThanOrEqual(0);
});

These tests ensure business rules hold under normal and concurrent conditions, validating system correctness rather than implementation artifacts.

Integration boundaries: testing external dependencies

External services like payment gateways, search engines, and APIs represent failure points. Rather than mocking indiscriminately, test contract compliance at these boundaries:

test('search service returns products matching exact SKU', 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('payment gateway failure rolls back order creation', 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();
});

These tests validate behavior at system boundaries where failures are most likely and most costly, ensuring transactional integrity across external dependencies.

Feature tests: user workflows over HTTP details

Feature tests should validate complete user workflows, not just HTTP status codes. Instead of testing that an endpoint returns 200, test the entire business flow:

test('guest user completes purchase and receives confirmation', 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')
    );
});

This test validates the entire workflow: persistence, payment processing, email dispatch, and business state transitions—ensuring the system works as users expect.

Data integrity: testing constraints

Database constraints often go untested until production failures occur. Test critical invariants at the database level:

test('duplicate SKUs are rejected at database level', function () {
    Product::factory()->create(['sku' => 'DUPLICATE-SKU']);
    
    expect(fn() => Product::factory()->create(['sku' => 'DUPLICATE-SKU']))
        ->toThrow(QueryException::class);
});

test('order total matches line items after updates', 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);
});

These tests ensure data integrity rules are enforced consistently, catching errors before they reach production.

Edge cases: boundary conditions

Critical failures occur at boundaries. Test them explicitly to catch scenarios that cause production incidents:

test('pagination handles empty results', function () {
    $this->get('/products?page=999')
        ->assertOk()
        ->assertJson(['data' => []]);
});

test('bulk operations handle partial failures gracefully', 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);
});

Testing edge cases like empty results, partial failures, and timezone boundaries prevents unexpected behavior in production environments.

What not to test

Avoid testing framework functionality, third-party package internals, trivial accessors, and private methods. Instead of testing that a product has a name accessor, test how that name is used in business logic:

// Don't test this
test('product has name accessor', function () {
    $product = new Product(['name' => 'Wine']);
    expect($product->name)->toBe('Wine');
});

// Test this instead
test('product search indexes name for full-text queries', function () {
    $product = Product::factory()->create(['name' => 'Rioja Reserve Wine']);
    $product->searchable();
    
    expect(Product::search('Rioja')->get())->toContain($product);
});

The second test validates actual system behavior that users depend on, while the first merely confirms Laravel's basic functionality.

The goal is not coverage percentage but coverage of critical paths—the scenarios where failure imposes the highest cost on users and business logic.
Liquid Team
Development Team

Conclusion

Effective TDD in Laravel requires distinguishing between tests that verify implementation artifacts and those that validate system behavior. By focusing on business invariants, integration boundaries, and edge cases, developers build test suites that provide genuine confidence while remaining resilient to refactoring.

This approach transforms testing from a ritual of achieving coverage metrics into a strategic practice that protects critical functionality and user experience. The tests that matter are those that catch real failures before they reach production.

💡 How much does it cost to develop an app for your business? Discover the prices