Test-Driven Development
Write a failing test first, then write the minimum code to make it pass, then refactor. Tests drive the design.
Red — Green — Refactor
The TDD cycle is a tight feedback loop. Each iteration is small and focused, building confidence incrementally.
Rule: Never write production code without a failing test first. Red must come before Green. A test that was never failing gives you no confidence.
Arrange — Act — Assert
Every test has three clear phases. Separating them keeps tests readable and focused on one behavior.
① Arrange
Set up all inputs, dependencies, and the object under test. Create the state the test needs to run.
② Act
Call the single method or operation you want to test. One action per test keeps failures unambiguous.
③ Assert
Verify the outcome matches expectations. One logical assertion per test — check one thing at a time.
OrderService — PHPUnit example/** @test */ public function it_calculates_order_total(): void { // ─── ARRANGE ────────────────────────────────── $order = new Order(); $order->addItem(new Product('Widget', 25.00), qty: 2); $order->addItem(new Product('Gadget', 15.00), qty: 1); // ─── ACT ────────────────────────────────────── $total = $order->total(); // ─── ASSERT ─────────────────────────────────── $this->assertEquals(65.00, $total); }
Rule: If your Arrange section is very long, the class under test has too many dependencies. If your Assert section is long, your method is doing too much.
Test Doubles
Replacements for real dependencies in tests. Each type has a different purpose — picking the right one matters.
Stub
Returns predetermined values. Does not verify how it was called — only controls the response.
$repo->findById(1)->willReturn($user);
Mock
Verifies that specific methods were called with specific arguments. Expectations set upfront.
$mailer->expects(once())->method('send');
Fake
A working simplified implementation. In-memory database, local filesystem, or similar.
new InMemoryOrderRepository();
Spy
Records all calls made to it. Verify interactions after the fact, without pre-setting expectations.
$spy->wasCalled('send', times: 1);
Rule: Prefer Fakes for data stores (they test real logic). Use Mocks only to verify side effects (emails, notifications). Avoid mocking the class under test.
The Test Pyramid
A balanced test suite has many fast unit tests at the base, fewer integration tests in the middle, and a small set of slow E2E tests at the top.
Rule: An inverted pyramid (many E2E, few unit tests) is slow and fragile. A test suite that takes 30 minutes to run stops being run. Aim for >70% unit tests.
TDD Best Practices
Rules that keep your test suite fast, trustworthy, and maintainable over time.
Test behavior, not implementation
- Test what a method does, not how it does it
- If refactoring breaks tests, tests are too coupled to implementation
- Use public interfaces only; avoid testing private methods
One test, one behavior
- Each test verifies exactly one behavior
- Name tests as sentences:
it_rejects_expired_tokens() - A failing test should instantly tell you what broke
Keep tests fast
- No HTTP, no real DB, no filesystem in unit tests
- Use fakes and in-memory implementations
- Slow tests stop being run
FIRST principles
- Fast — runs in milliseconds
- Isolated — no shared state between tests
- Repeatable — same result every time
- Self-validating — pass or fail, no manual check
- Timely — written before the code
What not to test
- Framework internals (Laravel routing, Eloquent)
- Third-party library behavior
- Trivial getters/setters with no logic
- Private methods (test through public API)