Tech Lead School · Session 3

The Test Pyramid
& Writing Unit-Testable Code


Shifting from "we have tests" to "our tests make us better engineers"

Part 1

The Test Pyramid


Most teams have it upside down — and don't know it

Three Layers


E2E / UI Integration Unit Tests Slow, brittle, use sparingly Fast, isolated, design signal lives here Cost per test

What lives where?


Unit

  • Calculate discount price
  • Validate email format
  • Parse API response
  • Business logic rules

Integration

  • API endpoint returns correct data
  • DB query + service layer
  • Auth middleware chain
  • External API contracts

E2E

  • User signup → email → login
  • Purchase flow end-to-end
  • Critical happy paths
  • Cross-service workflows

Cost is only half the story


Every test has a maintenance cost:

  • Unit: milliseconds to run, easy to fix
  • Integration: seconds to run, moderate setup
  • E2E: minutes to run, brittle, hard to debug

But the bigger point: an integration test can pass while your design is still bad. A unit test can't.

Typical execution time

~5ms

per unit test

~500ms

per integration test

~30s+

per E2E test

The Anti-Pattern: Ice Cream Cone


E2E Integration Unit

✓ Pyramid (healthy)

E2E (manual + flaky) Integration Unit

✗ Ice Cream Cone

This is probably your team right now — and it's not your fault

The Questions to Ask Your Team


"What does a unit test actually mean to us?"
  • Do your developers know the difference between a unit and an integration test?
  • If their definition is fuzzy, their TDD will be wrong too
  • Can devs run the full suite locally in under 30 seconds?
  • Does a green build mean good code, or just working plumbing?

Before fixing the pyramid, fix the understanding.

The pyramid tells you where to invest

So why can't most teams get there?

Part 2

The Mentality Shift


From "we test what we built" to "we build so it can be tested"

What isolation really means


Integration test — tells you the plumbing works

public function test_user_is_created(): void
{
    $user = $this->userService
        ->create('alice@example.com');

    $this->assertDatabaseHas('users', [
        'email' => 'alice@example.com'
    ]);
}

Needs a real DB · slow · tells you nothing about design

Unit test — tells you the logic is correct

public function test_discount_is_applied(): void
{
    $price = calculatePrice(
        base: 100,
        discountPct: 20
    );

    $this->assertSame(80.0, $price);
}

No DB · no HTTP · no filesystem · pure logic

What unit-testable code looks like


① Write the test — define the interface

public function test_apply_discount(): void
{
    $price = calculatePrice(
        base: 100,
        discountPct: 20
    );
    $this->assertSame(80.0, $price);
}

calculatePrice doesn't exist yet → test fails ✗

② Write the implementation — minimal, focused

function calculatePrice(float $base, float $discountPct): float
{
    return $base * (1 - $discountPct / 100);
}

Test passes ✓ → now refactor if needed

Notice what this function is not doing: no DB call, no HTTP, no global state. Clear input → clear output. That's it.

The question that changed everything


"How would I unit test this?"

We didn't always know how to write good code. But whenever we got stuck on a design decision, we asked this one question — and it guided us every time.

  • If the answer was "I'd need to spin up a DB" → the class was doing too much
  • If the answer was "I'd need to mock 5 things" → the dependencies were wrong
  • If the answer was "I'd need to change global state" → that's a design smell

The pain of testing is the signal. Don't suppress it — follow it.

Single Responsibility: the north star


A function or class that does one thing:

  • Is trivially unit-testable
  • Has clear inputs and outputs
  • Can be changed without fear
  • Can be understood in isolation

SRP isn't just a clean code principle — it's the prerequisite for unit testability.

THE RULE

If your test setup is painful, your code is doing too much.

Stop trying to write the test. Start asking what needs to be split.

Why This Matters for Tech Leads


Confidence

Your team can change code without fear. New engineers can modify existing code on day one.

Sustainability

Modular, well-tested code stays maintainable as the codebase grows. Less "don't touch that" code.

Velocity

Less debugging, fewer regressions, faster onboarding. The upfront cost pays back many times over.

AI-Readability

Unit-testable code — clear inputs, single responsibility, no hidden state — is exactly what AI agents parse and reason about best. This is how we write code for the future.

Being Pragmatic


TDD shines when...

  • Business logic & domain rules
  • Clear inputs → expected outputs
  • APIs and service interfaces
  • Bug fixes (write test that reproduces, then fix)
  • Refactoring existing code

TDD struggles when...

  • Exploratory / prototyping work
  • UI layout and visual design
  • You don't yet know what to build
  • Heavily integrated external systems
  • One-off scripts & spikes

A good tech lead promotes TDD as a default mindset, not a rigid mandate.

Addressing the Pushback


"It slows me down"

It slows you down now. It speeds up the team next month. Tech leads think in weeks, not hours.

"I'll write the tests after"

After-the-fact tests verify implementation, not behavior. You also lose the design benefit.

"Our code is too coupled for TDD"

That's exactly why you need it. TDD makes coupling visible and painful — which motivates decoupling.

Putting It Together


Pyramid

Where to invest

+

Unit-testable code

SRP · isolation · clear interfaces

=

Clean, maintainable,
AI-ready code

Ship without fear. Grow without rot.

Ask not "do we have tests?" Ask "can we unit test this — and if not, why not?"
Resources

Further Reading


  • Mike CohnSucceeding with Agile (origin of the Test Pyramid)
  • Martin FowlerThe Practical Test Pyramid (martinfowler.com)
  • Kent BeckTest-Driven Development: By Example
  • Ham VockeThe Practical Test Pyramid (martinfowler.com)
  • Ian Cooper"TDD, Where Did It All Go Wrong" (talk)