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.
- 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.
- Green: Write the minimum code needed to make the test pass. Don't over-engineer — just make it green.
- 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