0

TDD workflow — red/green/refactor in PHP

Intermediate5 min read·php-14-010
interviewsolid

Concept

Test-Driven Development (TDD) is a development practice where tests are written BEFORE the implementation. The TDD cycle is: Red → Green → Refactor.

  1. Red: Write a test that describes the desired behavior. It fails (because the code doesn't exist yet). This confirms the test is actually testing something.
  2. Green: Write the minimum code needed to make the test pass. Don't over-engineer — just make it green.
  3. Refactor: Improve the code's design without changing behavior. Tests ensure you didn't break anything.

Why TDD:

  • Forces you to think about the API before implementation — leads to better interfaces.
  • Every line of production code is covered by a test by definition.
  • Tests become a specification — new developers can read tests to understand behavior.
  • Immediate feedback loop — you know the exact moment something breaks.
  • Forces small, focused functions (large functions are hard to test).

Inside-out vs outside-in TDD: Inside-out (classicist/Chicago style): start with low-level units, build up. Outside-in (mockist/London style): start from the highest level (HTTP request) and stub dependencies, working inward.

When TDD is harder: Complex algorithms where you don't know the right answer upfront, exploratory code, UI work. In these cases, spike first (throw-away prototype), then TDD the real implementation.

In Laravel: php artisan make:test OrderServiceTest --unit creates a unit test. php artisan make:test OrderApiTest creates a Feature test. php artisan test runs the suite.

Code Example

php
<?php
// Step 1: RED — write the failing test
// tests/Unit/MoneyTest.php
class MoneyTest extends \PHPUnit\Framework\TestCase
{
    public function test_money_can_be_added(): void
    {
        $a = new Money(100, 'USD');
        $b = new Money(50, 'USD');

        $result = $a->add($b);

        $this->assertSame(150, $result->amount());
        $this->assertSame('USD', $result->currency());
    }

    public function test_cannot_add_different_currencies(): void
    {
        $this->expectException(\CurrencyMismatchException::class);

        $usd = new Money(100, 'USD');
        $eur = new Money(50, 'EUR');
        $usd->add($eur);
    }
}
// Run: vendor/bin/phpunit tests/Unit/MoneyTest.php
// FAIL: Class Money not found

// Step 2: GREEN — minimum implementation
class Money
{
    public function __construct(
        private readonly int $amount,
        private readonly string $currency,
    ) {}

    public function amount(): int { return $this->amount; }
    public function currency(): string { return $this->currency; }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \CurrencyMismatchException("Cannot add {$this->currency} and {$other->currency}");
        }
        return new self($this->amount + $other->amount, $this->currency);
    }
}
// Run: vendor/bin/phpunit → PASS

// Step 3: REFACTOR — improve without breaking tests
// (In this case, the code is already clean — nothing to refactor)

// TDD in Laravel (Feature test — outside-in)
class PlaceOrderTest extends \Illuminate\Foundation\Testing\TestCase
{
    public function test_user_can_place_order(): void
    {
        $user = User::factory()->create();
        $product = Product::factory()->create(['price' => 1000, 'stock' => 10]);

        $response = $this->actingAs($user)
            ->postJson('/api/orders', [
                'items' => [['product_id' => $product->id, 'quantity' => 2]],
            ]);

        $response->assertCreated()
            ->assertJsonStructure(['order_id', 'total']);

        $this->assertDatabaseHas('orders', ['user_id' => $user->id]);
    }
}
// Run red, then implement the route/controller/service, then green