Unit vs Integration vs End-to-End Testing: What Should You Actually Write?

A team gets burned by a production bug, someone declares “we need more tests,” and three months later the codebase has a thousand new unit tests, a green coverage badge sitting at 95%, and the exact same category of bug slips through again anyway. This happens constantly, and it’s not because testing doesn’t work. It’s because “more tests” without a clear sense of what kind of test actually catches what kind of bug produces a test suite that’s large, slow, and confidently wrong about how safe the codebase actually is.

Unit, integration, and end-to-end tests aren’t interchangeable, and they’re not simply “more thorough” versions of each other stacked in order. Each one is genuinely good at catching a specific category of problem and genuinely blind to others, and understanding that difference matters more than hitting any particular coverage percentage or following a fixed ratio between the three.

What Unit Tests Actually Catch

A unit test exercises a single function or class in isolation, with everything it depends on, a database, an external API, other parts of the application, replaced by a mock, stub, or fake. This isolation is the entire point: a unit test failure should point at exactly one thing, the specific piece of logic under test, without any noise from unrelated systems being slow, unavailable, or misconfigured at the moment the test happened to run.

This makes unit tests genuinely excellent at catching logic errors within a contained piece of code: an off-by-one error in a loop, a miscalculated discount, a condition that doesn’t handle an edge case like an empty array or a negative number correctly. They run fast, often thousands of them in well under a second, which makes them cheap enough to run constantly during development without breaking a developer’s flow.

What they can’t catch, by design, is anything happening at the boundary between the unit under test and the real systems it was isolated from. A function that correctly calculates a database query’s expected result, tested against a mocked database, provides zero evidence that the actual SQL query it generates is valid, that the real database schema matches what the mock assumed, or that a real network call to an external API is formatted the way that API actually expects. A codebase can have excellent unit test coverage and still ship a bug where two correctly-unit-tested pieces don’t actually work together correctly in reality.

What Integration Tests Actually Catch

An integration test exercises multiple real components together, typically your actual application code talking to a real database (often a dedicated test database, not a mock), or a real, though usually sandboxed, external service. This is where the boundary problems unit tests can’t see actually get tested: does this query run correctly against the real database schema, does this API call format its request the way the real service expects, does data actually flow correctly between these two specific pieces of the system.

Integration tests catch an entire category of real, common bugs that unit tests structurally cannot: a database migration that changed a column type without updating the corresponding query, a foreign key constraint that rejects data the application logic assumed would be valid, a third-party API that changed its response format in a way a mocked version of that API never reflected. These are exactly the bugs that tend to surface in production despite thorough unit testing, because the units were each individually correct against an assumption that turned out not to match reality.

The trade-off is speed and setup complexity. Integration tests run against real dependencies, which means they’re slower than unit tests, often by one or two orders of magnitude, and they require actual infrastructure, a test database that needs seeding and cleanup between tests, credentials or sandboxed access to external services, that unit tests never need at all. A test suite entirely made of integration tests becomes slow enough to genuinely discourage running it frequently during development, which defeats a large part of what makes fast feedback from testing valuable in the first place.

What End-to-End Tests Actually Catch

An end-to-end test drives the application the way a real user would, typically through actual browser automation, clicking buttons, filling in forms, navigating between pages, and asserting on what actually renders, rather than calling functions or API endpoints directly. This is the only layer of the three that verifies the entire system genuinely works together as an assembled whole, including the frontend rendering correctly, routing behaving as expected, and every layer beneath it actually connecting properly in a realistic, deployed-like environment.

End-to-end tests catch problems that are invisible to both unit and integration tests, since neither of those layers touches the actual rendered UI at all: a broken CSS selector that makes a button unclickable, a JavaScript error that only manifests in a real browser’s execution environment, a routing misconfiguration that sends a user to the wrong page after a form submission. For a genuinely critical user flow, checkout on an e-commerce site, the signup and email verification sequence, an end-to-end test is the only layer that actually confirms the whole thing works from a real user’s perspective.

The cost is real and compounds quickly. End-to-end tests are slow, often taking seconds per test where a unit test takes milliseconds, and they’re the most prone to flakiness of the three layers, failing intermittently for reasons unrelated to an actual bug, a slightly slower page load than expected, an animation still finishing when the test tried to click something, a network request that took a bit longer than usual. A large end-to-end suite that takes twenty minutes to run and occasionally fails for no genuine reason erodes trust in the test suite itself, and teams that experience this enough start ignoring failures altogether, which defeats the entire purpose of having the tests.

The Testing Pyramid, and Its Legitimate Critics

The traditional testing pyramid recommends a large base of unit tests, a smaller middle layer of integration tests, and a thin top layer of end-to-end tests, reflecting the reality that unit tests are cheap and fast while end-to-end tests are expensive and slow, and structuring a test suite to have mostly the cheap kind. This remains sound, general advice, and it’s not wrong.

It has a legitimate, well-argued critic worth taking seriously though. Kent C. Dodds, a well-known figure in the JavaScript testing community, popularized what he calls the “testing trophy,” arguing that integration tests, not unit tests, tend to give the best return on investment for a large share of typical application code, since they catch the boundary-crossing bugs unit tests miss while still running considerably faster than full end-to-end tests. His argument isn’t that unit tests are worthless, it’s that a codebase overly weighted toward heavily-mocked unit tests can accumulate a false sense of safety, since a system where every individual piece is unit-tested in isolation can still fail entirely when those pieces are actually wired together, exactly the class of bug integration tests are specifically positioned to catch.

The honest, practical takeaway from this debate isn’t picking one philosophy and applying it rigidly. It’s recognizing that the right balance depends heavily on what your specific application actually does. A library with almost no external dependencies, pure calculation logic with no database or API calls involved, genuinely benefits from being mostly unit tests, since there’s very little meaningful boundary behavior to test beyond the units themselves. A typical CRUD web application, where most of the actual complexity lives in how the application talks to its database and external services, tends to benefit from weighting more heavily toward integration tests than a rigid pyramid ratio would suggest.

A Practical Framework Instead of a Fixed Ratio

Rather than aiming for a specific percentage split between the three layers, a more useful question for any given piece of functionality is: what’s the cheapest layer that would actually catch a realistic bug in this specific code. Pure logic with no external dependencies, a pricing calculation, a validation rule, a data transformation, belongs in a unit test, since a unit test catches everything meaningful here at the lowest possible cost.

Anything where the actual risk lives at a boundary, a database query, an API call, data flowing between two parts of the system that were built or are maintained separately, belongs in an integration test, since a unit test with a mocked boundary provides essentially no real evidence about whether that boundary actually works correctly in reality.

A genuinely critical user-facing flow, the handful of journeys where a failure would be immediately and severely damaging, a checkout process, account creation, a core action the entire product exists to support, deserves end-to-end coverage specifically because it’s the only layer that verifies the complete, assembled system actually works the way a real user experiences it. Everything else, the long tail of secondary features and edge-case flows, usually isn’t worth the ongoing cost and flakiness risk of end-to-end coverage, and is better served by integration or unit tests targeting the specific logic involved instead.

Comparison at a Glance

UnitIntegrationEnd-to-End
What it testsA single function or class, in isolationMultiple real components working togetherThe whole application, as a real user would use it
SpeedVery fast, millisecondsModerate, often 10-100x slower than unitSlow, often seconds per test
CatchesLogic errors within isolated codeBoundary and integration bugs between real systemsUI, routing, and full-stack assembly failures
Flakiness riskVery lowLow to moderateHighest of the three
Best used forPure logic, calculations, validation rulesDatabase queries, API integrations, service boundariesA small number of genuinely critical user journeys

Why Test Flakiness Happens, and How to Reduce It

Most flaky end-to-end tests trace back to timing assumptions that don’t hold consistently. A test that waits a fixed amount of time, sleeping for one second before checking whether a page finished loading, works most of the time and fails unpredictably whenever the real load takes slightly longer than that arbitrary guess. The fix is waiting for an actual, specific condition, a particular element becoming visible, a network request genuinely completing, rather than an arbitrary fixed delay that’s either wastefully long or occasionally too short.

Shared test state between tests is another common source. A test that depends on data left behind by a previous test running first, and passes in the full suite but fails when run alone, has a hidden dependency that will eventually break in unpredictable ways, particularly once tests start running in parallel or in a different order than originally assumed. Each test should set up its own data and clean up after itself, rather than relying on execution order to happen to work out.

Retrying a failed test automatically before marking it as genuinely failed is a pragmatic mitigation many teams adopt, and it’s a reasonable practical compromise, but it’s worth treating retries as damage control rather than a real fix. A test that needs three attempts to pass reliably is telling you something concrete about a timing assumption or a piece of shared state that’s still not solid, and papering over that with automatic retries hides the underlying issue rather than resolving it.

Mocking: When It Helps and When It Lies to You

Mocking a dependency, replacing a real database call, API request, or external service with a fake, controllable substitute, is what makes unit tests fast and isolated in the first place, and it’s a genuinely necessary tool. The failure mode isn’t mocking itself, it’s mocking so thoroughly and for so much of a test suite that the tests stop reflecting how the real system actually behaves.

A mocked API response that hasn’t been updated since the real API changed its response format is a specific, common way this goes wrong: the mock still matches the old contract, the unit test using it passes confidently, and the actual integration with the real API is silently broken in production. This is exactly the scenario integration tests, run against a real or realistically sandboxed version of the dependency, exist to catch, and it’s a strong argument for not relying on unit tests with mocked boundaries as the only line of defense for anything that talks to an external system.

A reasonable rule of thumb: mock dependencies that are slow, expensive, or unreliable to call repeatedly during fast unit tests, but maintain a smaller, deliberate set of integration tests hitting the real thing (or a realistic sandbox of it) specifically to catch the drift between what a mock assumes and what the real dependency actually does over time.

PHP and Laravel Testing Tools

PHPUnit remains the standard, mature testing framework for PHP, handling unit and integration tests alike depending on how a given test is written, whether it mocks dependencies or exercises real ones. Pest has gained real popularity as an alternative testing framework built on top of PHPUnit, offering a more expressive, readable syntax for the same underlying test execution, and it’s become a common choice for newer Laravel projects specifically because of how naturally it reads.

Laravel ships with solid built-in support for integration-style testing against a real (typically in-memory SQLite or a dedicated test database) database, including database transactions that automatically roll back after each test, which handles the shared-state cleanup problem described earlier without requiring manual cleanup code in every single test. Laravel Dusk provides browser-based end-to-end testing specifically for Laravel applications, automating a real browser to click through actual user flows, and it’s the natural choice for the small number of genuinely critical journeys worth that level of coverage in a Laravel application specifically.

Running Tests in CI

Tests that only run when a developer remembers to run them locally provide considerably less real protection than tests that run automatically on every push, blocking a merge if anything fails. This is where a solid CI/CD pipeline earns its keep specifically for testing: unit tests running on every commit for fast feedback, with integration and a small end-to-end suite running at minimum before anything merges to a main branch, catching problems before they ever reach production rather than after. This comparison of CI/CD tools for PHP developers covers the pipeline setup for exactly this kind of automated testing gate.

Test suite duration in CI is worth monitoring deliberately as a codebase grows, since a test suite that takes forty-five minutes to run discourages the exact frequent, fast feedback loop that makes automated testing valuable in the first place. Splitting test runs to execute in parallel, and being deliberate about which tests genuinely need to run on every single push versus which can run on a less frequent schedule, keeps the feedback loop fast enough that developers don’t start treating a slow, ignored test suite as background noise.

Real Scenarios

A student project with a grading deadline

A handful of unit tests covering the trickiest logic (grade calculations, date handling, validation rules) is a reasonable, proportionate investment. Full end-to-end coverage is rarely worth the setup time for a project with this lifespan, though a couple of integration tests confirming the database layer actually works as expected catches real, common mistakes cheaply.

A small team building a real product

A solid base of unit tests for pure logic, a meaningful layer of integration tests around database queries and any external API calls, and end-to-end coverage limited specifically to the two or three most business-critical user flows. This is close to the practical framework described above, applied directly.

A freelancer building and handing off a client project

Integration tests around the core business logic and data flow are particularly valuable here, since they document and protect exactly the behavior a future developer, who won’t have the original context, most needs to understand without breaking when they eventually make changes.

A larger team with a mature, long-lived codebase

A deliberate, monitored balance across all three layers, with real attention paid to test suite speed and flakiness as the suite grows, since an unmaintained, slow, flaky test suite at this scale becomes a liability that erodes trust in testing altogether rather than a genuine safety net.

Common Mistakes

Chasing a specific code coverage percentage as the actual goal, rather than a rough signal, produces exactly the outcome described at the start of this piece: a large number of tests that technically execute every line of code without meaningfully verifying that the code behaves correctly. A test that calls a function and asserts nothing meaningful about its result increases coverage without providing any real protection at all.

Writing end-to-end tests for functionality that a unit or integration test would cover just as well, but slower and more fragile, is a common overcorrection once a team decides end-to-end testing is valuable. Reserve this layer specifically for the genuinely critical, full-stack journeys where nothing else would actually catch the class of bug that matters.

Ignoring flaky tests rather than fixing or removing them is a slow, quiet way to destroy an entire test suite’s credibility. Once a team develops a habit of re-running a failed pipeline “because that test is always flaky,” the test suite has effectively stopped providing real signal, and a genuine failure is just as likely to get dismissed as noise as a false one is.

And testing implementation details rather than actual behavior, asserting on a private internal method’s exact call count rather than the function’s actual observable output, produces tests that break every time the implementation is refactored, even when the refactor doesn’t change behavior at all. This trains developers to see test failures as an annoying obstacle to refactoring rather than a genuine signal something’s wrong, which undermines the entire purpose of having the tests in the first place.

FAQ

What percentage of tests should be unit versus integration versus end-to-end?

There’s no universally correct ratio. It depends heavily on what the application actually does: logic-heavy code with few external dependencies benefits from being mostly unit tests, while a typical database-backed web application often benefits from weighting more heavily toward integration tests than a rigid pyramid ratio suggests.

Is 100% code coverage a reasonable goal?

Generally not as a target in itself. High coverage on meaningful, well-written tests is valuable, but coverage percentage alone doesn’t distinguish between a genuinely protective test and one that executes code without verifying its actual behavior.

Why do my end-to-end tests keep failing randomly?

Almost always a timing assumption that doesn’t hold consistently, a fixed wait instead of waiting for a specific condition, or shared state between tests that only breaks under certain execution orders. Both are worth investigating directly rather than adding automatic retries as the only fix.

Should I mock the database in unit tests?

For pure logic tests, yes, mocking keeps them fast and isolated. But relying only on mocked database interactions without any real integration tests against an actual database risks missing genuine schema or query bugs that a mock, built on assumptions about the real database, may not reflect.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top