The Testing Pyramid — Balancing Unit, Integration, and End-to-End Tests

Understand the testing pyramid as a strategy for allocating testing effort, be able to define and distinguish unit, integration, feature, API, end-to-end, regression, smoke, and contract tests, know when each type earns its cost, and recognize the inverted-pyramid anti-pattern that makes a test suite slow, flaky, and expensive to maintain.

Before writing a single PHPUnit assertion, it's worth answering a strategic question: given limited time, what mix of tests actually gives a team confidence that their PHP application works, without the test suite itself becoming a maintenance burden that slows everyone down? The testing pyramid is the classic answer to that question — a shape, not a rulebook, that says: write many fast, cheap, focused tests at the bottom, and progressively fewer slow, expensive, broad tests as you move up. This lesson lays out the pyramid, defines every test type you'll meet in a PHP codebase — unit, integration, feature, API, end-to-end (E2E), regression, smoke, and contract tests — and explains when each one earns its keep. Every other lesson in this section (PHPUnit itself, test doubles, integration tests, and API/E2E testing) fills in one layer of this shape in detail; this lesson is the map you'll keep referring back to.

Why shape matters: the cost curve of different test types

Every kind of automated test buys you the same basic thing — confidence that code behaves as expected — but at wildly different prices. A unit test that calls a pure function with a few inputs and checks the output runs in microseconds, needs no database, no network, and no external service, and when it fails it points at almost exactly the broken line. An end-to-end test that spins up a real browser, clicks through a checkout flow, and waits for a real page to render might take ten or twenty seconds, depends on dozens of moving parts (the web server, the database, the JavaScript bundle, network timing), and when it fails, the failure could be caused by nearly anything in that chain — the actual bug, a timing race, a stale test fixture, or the test environment itself being flaky.

The testing pyramid is a response to that cost curve: because unit tests are cheap and precise, you write a lot of them; because E2E tests are expensive and comparatively imprecise about where the fault lies, you write only as many as you need to cover the handful of user journeys that truly must work end to end. A healthy PHP test suite might have thousands of unit tests, hundreds of integration tests, and a few dozen E2E tests — not because E2E tests are less important, but because that ratio is what keeps the whole suite fast enough to run on every commit and reliable enough that a red build actually means something is broken.

• Lower layers (unit) = fast, cheap, isolated, pinpoint failures precisely — write many. • Higher layers (E2E) = slow, expensive, realistic, but failures are harder to localize — write few, covering only critical user journeys. • The pyramid is about ratio and total cost, not about any layer being 'more correct' than another — each layer catches bugs the others structurally cannot. • A fast suite gets run constantly (every save, every commit); a slow suite gets run rarely, which is itself a risk — bugs sit undetected longer.

The pyramid's core layers: unit, integration, feature/API, E2E

A unit test exercises the smallest testable piece of code — typically one method or one class — in complete isolation from its real collaborators (the database, the filesystem, other services), often using test doubles (covered in the next lesson) to stand in for anything the unit depends on. A unit test for a Money class's add() method, or a pure validation function that checks an email format, is a classic example: no I/O, no framework bootstrapping, sub-millisecond execution, and a failure points at exactly one unit of behavior.

An integration test checks that two or more real pieces work correctly together — your repository class actually talking to a real (test) database, your HTTP client actually parsing a real API response shape, your cache adapter actually reading back what it wrote. Integration tests trade some speed and isolation for confidence that the 'seams' between components — which is exactly where unit tests, by design, cannot catch bugs, since they mock those seams away — actually work. A feature test (sometimes called an application test) sits a level higher still: it exercises a complete feature or use case through your application's own entry points — calling a service layer method that spans several classes, or hitting a Laravel/Symfony route with a simulated request — without necessarily going through a real network socket or browser. An API test is a specific, common flavor of feature test: it sends real HTTP requests to your running application and asserts on the JSON response body and status code, verifying the contract your API actually exposes to its consumers.

• Unit test — one class/method, real collaborators replaced with test doubles, no I/O, milliseconds to run. • Integration test — two or more real components together (real test DB, real cache, real HTTP client), verifies the seams unit tests mock away. • Feature test — a full use case through the application's own entry points (service layer, routes), broader than a unit but narrower than a full browser session. • API test — HTTP requests against your running application, asserting on status codes and JSON — a common, concrete form of feature testing for web APIs.

End-to-end tests: the tip of the pyramid

An end-to-end (E2E) test drives the application the way a real user would — typically through a real browser (via tools like Playwright or PHP's Panther) — clicking buttons, filling in forms, and asserting on what actually renders, exercising the full stack from the browser down through the web server, application code, database, and back. This is the only layer that genuinely proves 'a user can complete this journey,' because it's the only layer where nothing is faked: the real frontend JavaScript runs, real HTTP requests cross a real network boundary, and a real database persists real rows.

That realism is exactly why E2E tests sit at the pyramid's tip rather than its base: they're slow (seconds per test, not milliseconds), they're the most prone to flakiness (a slightly slow page load or an animation can cause an intermittent, non-deterministic failure that has nothing to do with an actual bug), and a failure tells you comparatively little about where in the stack the problem lives. The right call is to reserve E2E tests for the small number of journeys where nothing less than full realism will do — user registration and login, the checkout flow, anything where a broken integration between frontend and backend would be a serious, customer-visible incident — and to push everything else down into faster, more precise layers.

Cross-cutting test categories: regression, smoke, and contract tests

Regression, smoke, and contract tests aren't a fourth layer stacked on top of the pyramid — they're categories defined by purpose and timing that cut across every layer above. A regression test is any test (usually a unit or integration test) written specifically after a real bug was found and fixed, to prove that exact bug can never silently come back; the test encodes the once-broken scenario directly, often reproducing the original bug report almost verbatim, and its entire value is in staying in the suite forever as a tripwire.

A smoke test is a small, fast subset of tests — often a handful of feature or E2E tests — run first, before the full suite, to answer one coarse question quickly: 'is the build so broken that running the rest of the suite would be a waste of time?' (does the app even boot, does the homepage return a 200, can a user log in at all). Smoke tests trade thoroughness for speed, deliberately, and are common right after a deployment to catch a catastrophic failure in seconds rather than waiting for a full suite to finish. A contract test verifies that two systems — typically a service provider and its consumer, or your PHP application and a third-party API it depends on — agree on the shape of the data crossing the boundary between them (required fields, types, status codes), independent of testing either system's internal behavior; it catches the specific failure mode where two systems that individually pass all their own tests are nonetheless incompatible with each other.

• Regression test — written after a real bug, to prove it never silently returns; lives forever in the suite as a tripwire for that exact defect. • Smoke test — a small, fast subset run first (after deploy, before the full suite) to catch catastrophic breakage quickly, not to be thorough. • Contract test — verifies the data shape agreed between a provider and consumer (fields, types, status codes) at the boundary between two systems. • These categories apply at any pyramid layer — a smoke test can be a handful of unit tests or a handful of E2E tests, depending on what 'is the build alive' means for your app.

Naming the pyramid layers in a PHPUnit test suite

The directory structure itself communicates the pyramid — it's immediately visible which layer a given test belongs to, and how many tests live at each layer. phpunit.xml can define a separate <testsuite> per directory, so phpunit --testsuite unit runs only the fast layer on every save, while the full suite runs in CI. A near-empty E2E/ directory next to a large Unit/ directory is a good sign, not a gap — it means the pyramid's shape is healthy.

tests/
  Unit/
    Domain/MoneyTest.php          // pure logic, no I/O, fastest layer — the pyramid's base
    Validation/EmailValidatorTest.php
  Integration/
    Repository/OrderRepositoryTest.php   // real test database, rolled back per test
    Cache/RedisCacheAdapterTest.php      // real cache connection
  Feature/
    CheckoutFlowTest.php           // exercises the service layer across several classes
  Api/
    OrdersApiTest.php              // real HTTP requests, asserts on JSON + status codes
  E2E/
    UserRegistrationE2ETest.php    // real browser via Panther, the pyramid's tip — very few of these

Code Examples

Each layer verifies the same underlying feature — order total calculation — but proves a different thing: the unit test proves the arithmetic is right in isolation, the integration test proves the database mapping preserves that arithmetic across a save/reload cycle, and the API test proves the whole stack, including HTTP routing and JSON serialization, produces the right externally visible result. None of the three is redundant with the others. Notice the layers get progressively slower and touch progressively more infrastructure — the unit test needs nothing but the Order and LineItem classes, the integration test needs a real database connection, and the API test needs the whole application booted and listening for requests.

$this->assertSame(2200, $order->totalInCents());

declare(strict_types=1);

// --- Unit layer: pure logic, no collaborators, milliseconds ---
// tests/Unit/Domain/OrderTotalTest.php
final class OrderTotalTest extends \PHPUnit\Framework\TestCase
{
    public function testTotalSumsLineItemPrices(): void
    {
        $order = new Order([
            new LineItem('Widget', 2, 500),   // 2 x $5.00
            new LineItem('Gadget', 1, 1200),  // 1 x $12.00
        ]);

        $this->assertSame(2200, $order->totalInCents()); // $22.00 — pure arithmetic, no DB
    }
}

// --- Integration layer: real (test) database round trip ---
// tests/Integration/OrderRepositoryTest.php
final class OrderRepositoryTest extends \PHPUnit\Framework\TestCase
{
    use \Tests\RefreshesDatabase; // rolls back a transaction after each test

    public function testSavedOrderCanBeReloadedWithItsTotal(): void
    {
        $repository = new OrderRepository($this->testPdo);
        $order = new Order([new LineItem('Widget', 2, 500)]);

        $id = $repository->save($order);
        $reloaded = $repository->find($id);

        $this->assertSame(1000, $reloaded->totalInCents()); // proves the DB mapping round-trips correctly
    }
}

// --- API layer: real HT
…

The testing pyramid

Width = how many tests you write at that layer; height = how expensive and realistic that layer is.

/\
                   /  \      E2E (few)
                  /----\     slow, realistic, whole stack, browser-driven
                 /      \
                / Feature \   Feature / API (some)
               /   / API   \  service layer or HTTP, no browser
              /----------\
             /            \
            /  Integration  \  Integration (more)
           /------------------\ real DB/cache/HTTP client, one seam at a time
          /                    \
         /        Unit          \  Unit (most)
        /--------------------------\ pure logic, isolated, fast, precise

  regression / smoke / contract tests cut ACROSS every layer above —
  they describe WHEN and WHY a test runs, not WHERE it sits in the pyramid.

Important Rules

Default to a unit test whenever the behavior can be verified without real I/O — it's the cheapest, fastest, most precise option.

Reserve integration tests for the seams between your code and a real dependency (database, cache, HTTP client, filesystem).

Reserve E2E tests for the small number of journeys where full realism (real browser, real network) genuinely matters — not for every feature.

Write a regression test immediately after fixing any real bug, encoding the exact scenario that broke.

Keep a small, fast smoke suite that runs first after every deploy, separate from the full suite.

How this plays out on a real PHP project

A typical Laravel or Symfony application keeps thousands of fast PHPUnit unit tests that run on every save in a developer's editor, a few hundred integration tests against a test database that run in a couple of minutes in CI, a smaller set of feature/API tests asserting on JSON responses for every documented endpoint, and a deliberately short list of Panther or Playwright E2E tests — often fewer than twenty — covering only registration, login, checkout, and one or two other journeys the business considers unacceptable to ever silently break. A lightweight smoke suite (does the homepage load, can a test user log in) runs immediately after every production deploy, well before the full suite, specifically to catch a catastrophic failure within seconds.

// A team writes 5 unit tests and 200 Playwright/Panther E2E tests that click through the full UI for every feature, including simple validation rules.

// The same validation rules get 40 fast unit tests; only the 3-4 truly critical user journeys (signup, checkout, password reset) get E2E coverage.

This is the inverted pyramid: a suite dominated by slow, flaky E2E tests takes many minutes (or hours) to run, so it gets run rarely and CI feedback arrives too late to be useful, and a single failing E2E test gives almost no information about which of dozens of moving parts actually broke — whereas the same validation logic tested at the unit layer runs in milliseconds and points at the exact broken rule.

What does the testing pyramid primarily recommend? A. Write only unit tests; integration and E2E tests are a waste of time. B. Write many fast, isolated unit tests, fewer integration/feature tests, and only a small number of slow, realistic E2E tests. C. Write an equal number of tests at every layer, regardless of speed or cost. D. Write mostly E2E tests since they're the most realistic and therefore most trustworthy.

B. Write many fast, isolated unit tests, fewer integration/feature tests, and only a small number of slow, realistic E2E tests. — The pyramid balances confidence against cost and speed: lower layers are cheap and precise so you write many of them, while higher layers are realistic but slow and expensive to maintain, so you write only as many as the critical journeys require.

What specifically distinguishes an integration test from a unit test? A. Integration tests are always written in a different programming language. B. Integration tests verify a real seam between your code and a real dependency (database, cache, HTTP client), while unit tests isolate one unit with test doubles standing in for its collaborators. C. Integration tests run faster than unit tests because they touch fewer files. D. There is no real distinction; the terms are interchangeable in practice.

B. Integration tests verify a real seam between your code and a real dependency (database, cache, HTTP client), while unit tests isolate one unit with test doubles standing in for its collaborators. — A unit test deliberately replaces real collaborators with test doubles to isolate one unit of behavior; an integration test deliberately keeps a real collaborator (a real test database, a real cache) in place to verify that the connection between the two actually works.

What is a smoke test, and when is it typically run? A. A test that intentionally triggers an exception to verify error handling. B. A small, fast subset of tests run first (often right after a deploy) to catch catastrophic breakage quickly, before running the full suite. C. A test that only runs in a Docker container. D. A synonym for an end-to-end test.

B. A small, fast subset of tests run first (often right after a deploy) to catch catastrophic breakage quickly, before running the full suite. — Smoke tests deliberately trade thoroughness for speed — they answer 'is the build alive at all?' in seconds, so a catastrophic failure (the app doesn't boot, a critical page 500s) is caught immediately rather than after waiting for a full, slower suite to finish.

A team's test suite has 20 unit tests and 300 Playwright E2E tests that take 45 minutes to run, and is run only once a day because of how slow it is. What does this describe? A. A healthy, well-balanced test pyramid. B. An inverted pyramid — too many slow, expensive, hard-to-localize E2E tests and too few fast, precise unit tests, which slows feedback and hides bugs longer. C. A contract-testing anti-pattern. D. This is fine as long as all 300 E2E tests pass.

B. An inverted pyramid — too many slow, expensive, hard-to-localize E2E tests and too few fast, precise unit tests, which slows feedback and hides bugs longer. — This is the classic inverted pyramid: the suite is dominated by the slowest, most expensive, least precise layer, so it runs rarely and a failure gives little indication of where the actual bug lives — the fix is pushing most of that coverage down into fast, isolated unit tests and keeping E2E tests reserved for the few journeys that truly need full realism.

For each of the following, name which pyramid layer (unit, integration, feature, API, or E2E) is the best fit, and justify it in one sentence: (1) checking that a discount-percentage calculation never returns a negative number, (2) verifying that saving a User to the real database and reloading it preserves its email address exactly, (3) verifying that a full 'forgot password' flow — request reset, receive email link, set new password, log in with it — works end to end through a real browser.

(1) Unit test — pure arithmetic, no I/O, should be one of many fast, isolated tests. (2) Integration test — verifies the real seam between your code and a real (test) database. (3) E2E test — spans multiple systems (web app, email delivery, browser session) in a way only a full, realistic run can actually prove works; this is exactly the kind of critical, infrequent journey the tip of the pyramid is for.

Ask what the test needs to touch: pure logic needs nothing; a database round-trip needs one real dependency; a multi-step user journey across systems (email delivery included) needs full realism.