Test-Driven Development (TDD)
debt(d7/e5/b5/t5)
Closest to 'only careful code review or runtime testing' (d7). The detection_hints indicate no automated detection ('automated: no') and the code_pattern requires a human reviewer to notice that test files were written after implementation or that no TDD workflow was followed. Tools like phpunit/pest/phpstan can confirm tests exist but cannot determine whether the red-green-refactor cycle was followed — only careful code review or retrospective analysis of commit history could reveal TDD abandonment.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix describes a workflow change (write failing test first, then minimum code, then refactor) rather than a single-line patch. Correcting a codebase where TDD was abandoned means retrofitting tests across multiple files to restore coverage and design intent — this goes beyond a simple parameterised fix but falls short of a full cross-cutting architectural rework.
Closest to 'persistent productivity tax' (b5). TDD applies broadly across web, cli, and queue-worker contexts (per applies_to). When TDD is misapplied or abandoned, the resulting tight coupling and bolted-on tests slow down many future work streams — every feature addition or refactor is more expensive. However, it doesn't rise to b7 because TDD adoption or abandonment doesn't architecturally constrain every change in the way a foundational framework choice would.
Closest to 'notable trap (a documented gotcha most devs eventually learn)' (t5). The misconception field directly identifies the canonical wrong belief: that TDD means mechanically writing tests before every line of code forever. The common_mistakes reinforce this — developers conflate TDD with waterfall test-first, skip refactor steps, or write overly large cycles. This is a well-documented misunderstanding that most developers encounter and must correct, but it doesn't contradict analogous concepts elsewhere in a catastrophic way.
Also Known As
TL;DR
Explanation
TDD follows a tight cycle: (1) write a failing test (Red), (2) write the minimum code to make it pass (Green), (3) refactor for quality while keeping tests green. Writing tests first forces clarity about requirements before implementation, produces naturally testable code (small, focused, with injected dependencies), and creates a comprehensive test suite as a by-product. TDD is a design activity as much as a testing activity — the pain of writing tests signals design problems. Outside-in TDD (starting from integration/acceptance tests) is called ATDD or BDD.
Diagram
flowchart LR
RED[Write failing test<br/>RED] -->|implement code| GREEN[Make test pass<br/>GREEN]
GREEN -->|improve code| REFACTOR[Refactor<br/>BLUE]
REFACTOR -->|write next test| RED
subgraph Benefits
DESIGN[Forces thinking about API<br/>before implementation]
COVERAGE2[100pct coverage by default<br/>only code tests need]
SAFETY[Refactor safely<br/>tests catch regressions]
end
subgraph TDD_vs_Tests_After
AFTER[Tests after - verify it works]
TDD2[TDD - design tool + verification]
end
style RED fill:#f85149,color:#fff
style GREEN fill:#238636,color:#fff
style REFACTOR fill:#1f6feb,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Writing all tests first, then all implementation — TDD is red/green/refactor per small cycle, not a waterfall.
- Skipping the refactor step — without it TDD produces working but messy code.
- Writing tests that are too large — each cycle should test one specific behaviour.
- Abandoning TDD when time pressure hits — this is exactly when having tests matters most.
Code Examples
// Test-last approach — code written first, then retrofitted with tests:
class UserService {
public function create(array $data): User {
// 100 lines of code written without tests
// Now write tests that 'fit' the existing code
// Tests describe implementation, not behaviour
}
}
// TDD: test first → drives minimal implementation → refactor safely
// RED: write a failing test first
public function test_discount_applied_for_vip(): void {
$pricing = new PricingService();
$price = $pricing->calculate(product: $product, customer: $vip);
$this->assertEquals(90.00, $price); // FAILS — method doesn't exist yet
}
// GREEN: write the minimum code to pass
class PricingService {
public function calculate(Product $product, Customer $customer): float {
$price = $product->basePrice;
if ($customer->isVip()) $price *= 0.9;
return $price;
}
}
// REFACTOR: clean up while tests stay green