↓Skip to main content
  1. Testing & Quality/

The Testing Pyramid: Unit, Integration, and End-to-End Tests

·1982 words·10 mins·
Testing Software Quality Unit Testing Integration Testing
Ifarra
Author
Ifarra
Disturbing the peace!!
Table of Contents

Tests are most useful when they answer a clear question: did this behavior work, and if it breaks, will the test show us where? The testing pyramid is a way to balance that feedback. It is a guide to test scope, not a rule that every project must follow in exact percentages.

The testing pyramid: many focused unit tests, fewer integration tests, and a small number of end-to-end tests
Tests grow broader and slower as they move up the pyramid.

Unit tests: check a small decision quickly
#

A unit test checks a small piece of logic while keeping unrelated systems out of the way. A price calculator, parser, or date conversion is a good fit. These tests should run quickly and identify failures close to the cause.

export function applyDiscount(price, percentage) {
  if (price < 0 || percentage < 0 || percentage > 100) {
    throw new RangeError("Invalid price or discount");
  }
  return price * (1 - percentage / 100);
}

Useful unit tests cover typical values, boundary values, and invalid input. They should not need a live database or network connection.

Integration tests: check components working together
#

An integration test checks whether a boundary between components behaves as expected. Examples include a repository saving data to a test database, an API route validating a request, or a UI component coordinating with its form state. Integration tests catch configuration and wiring mistakes that isolated unit tests cannot.

Use real collaborators when they are inexpensive and deterministic, such as an in-memory or disposable test database. Replace slow or unpredictable external services at their boundaries.

End-to-end tests: check a user journey
#

An end-to-end test drives the application through a browser or public API, such as creating an account and seeing the welcome page. It exercises more of the system and provides strong confidence that important parts work together, but it is slower and can fail because of setup, network, or timing problems.

Keep this layer focused on a few high-value journeys. Do not copy every unit-level edge case into a browser test.

A balanced suite
#

Most suites benefit from many fast, focused tests, a smaller group of integration tests, and a compact set of end-to-end checks. When a test is flaky, first ask whether it crosses more boundaries than necessary. A test that only checks a pure calculation should not require a browser; a test of a critical checkout journey should not mock every component it is meant to verify.

Choose a level by the question
#

  • “Does this function calculate the right result?” Use a unit test.
  • “Does this route validate and persist the request correctly?” Use an integration test.
  • “Can a customer complete this key journey?” Use an end-to-end test.

Start with observable behavior and choose the narrowest level that can prove it. Then add broader tests where failures across boundaries would be costly.

Choose a level by the risk you need to reduce
#

Start by naming the production mistake, then find the smallest boundary that contains it. If a pricing rule can be wrong while every service is healthy, a unit test gives the fastest and clearest signal. If the risk is that a route maps a request into a database record incorrectly, the route and database need to cooperate, so an integration test is the honest boundary. If the risk is that a customer cannot complete a purchase because several screens and services disagree, test that journey end to end.

This framing avoids treating the number of tests as a proxy for confidence. A thousand tests can still leave a critical boundary untested, while a few well-chosen checks can cover a complete workflow. Write down the failure you want to catch before choosing a framework or level. That also tells you what may safely be replaced with a fake: replace an external payment provider when testing your own retry logic, but do not fake the retry logic itself.

A running example: placing an order
#

Imagine a service that accepts an order, checks stock, calculates the total, charges a payment provider, and records the result. Divide the behavior by the parts that can fail independently. A unit test can verify that line items, discounts, tax, and shipping produce the expected total for boundary values. Another unit test can check that the state machine rejects a transition from cancelled back to paid. These tests use ordinary in-memory values and avoid network setup.

An integration test can start the application with a disposable database and verify that an accepted request creates one order, reserves inventory, and persists the calculated amount. It should use the real schema and transaction behavior because those are part of the contract. A separate check can prove that repeating a client request does not create a second order. This catches constraint, transaction, and serialization mistakes that a mock repository would hide.

The end-to-end layer can exercise one high-value journey: submit an order through the UI, see a confirmation, and verify that the user can find the order in history. Use a test payment account or a controlled sandbox. Avoid testing every invalid discount through the browser; those cases belong at the unit boundary, where failures are cheaper to diagnose. The browser test should prove the wiring and visible result.

Choose collaborators deliberately
#

Use a real collaborator when it is local, deterministic, and inexpensive. A temporary SQLite database may suit a small repository layer, but it is a poor substitute if production depends on PostgreSQL-specific constraints, JSON operators, or locking. An in-memory queue is useful for testing how code creates a job, but it does not prove that the production broker acknowledges messages correctly. Pick a substitute based on the behavior under test, not convenience alone.

When replacing a boundary, keep the fake narrow. A payment fake might record an amount, return a configured approval, and simulate a declined card. It should not reimplement every provider feature. A fake that becomes a second implementation can agree with an application bug. For critical integrations, add a contract check that validates request and response examples against the provider’s schema or sandbox.

Avoid verifying private call sequences unless order is part of the contract. A test that expects five internal helper calls will fail during a harmless refactor. Prefer observable outputs: saved order state, emitted event, or returned error. Interaction assertions are useful for a meaningful side effect, such as ensuring a failed payment never triggers shipment, but should explain the business rule rather than mirror the source code.

Keep the suite fast and independent
#

Test duration is a feedback design problem. A focused unit suite should be quick enough to run on every edit. Integration tests can be grouped by boundary and run in parallel when each test owns its database state. Browser tests should cover a compact set of important journeys and use stable selectors based on accessible names or explicit test IDs. If the whole suite takes several minutes, developers will run it less often and failures will arrive later, when their cause is harder to identify.

Make tests independent. Each test should create the records it needs, avoid relying on execution order, and clean up through transactions, temporary schemas, or disposable containers. Do not use fixed sleeps to wait for background work; wait for an observable state with a clear timeout. For browser tests, wait for a visible result or network response instead of an arbitrary number of milliseconds. These choices reduce flakes without hiding timing problems.

Measure the slowest tests rather than guessing. Record suite duration in CI, split slow integration groups, and inspect retries. A test that passes only on retry is still a failure signal. Assign an owner and a short repair deadline to flaky tests; otherwise retries turn noise into accepted behavior. If a system is eventually consistent, assert the documented consistency window and observable state instead of widening every timeout.

Review tests for signal
#

Before adding a test, ask what production mistake it would catch and what output would reveal that mistake. Check that the assertion would fail if the behavior were removed or reversed. Then check whether the test crosses more boundaries than it needs to. A test proving a calculation should not require a browser; a test proving checkout should not stub every component in the journey.

Look for hidden nondeterminism: wall-clock time, random identifiers, shared fixtures, real network calls, environment-specific locale, and uncontrolled concurrency. Inject a clock or random seed where those values affect a decision. Prefer explicit fixtures with meaningful fields over giant factories with invisible defaults. When a test fails, its diff should make it clear whether the problem is the input, expected behavior, or environment.

Revisit the suite when the product changes. Remove checks for deleted behavior, move checks down a layer when their user-facing value no longer justifies their cost, and add a browser test when a critical path spans newly connected components. The pyramid is healthy when each level has a clear job and the whole suite gives fast, trustworthy feedback—not when its chart has a perfect shape.

A worked plan for an order feature
#

For an order feature, write a behavior map before writing tests. “A valid cart produces the correct total” belongs in fast unit tests across normal, boundary, and invalid values. “The API persists one order and reserves stock” belongs in an integration test using the real repository and database constraints. “A signed-in customer can submit an order and find it in history” belongs in a small number of browser tests. This map shows gaps without treating every line as equally important.

Define the failure signal for each check. The unit test should show the exact input and wrong total. The integration test should prove both the saved state and response, including rollback when inventory is insufficient. The browser test should assert visible confirmation and the resulting history entry. If a test only confirms a button can be clicked, it does not prove the journey worked.

Choose test data and isolation. Give each order a unique customer and product, use a disposable database or transaction, and seed only the needed stock. A test that depends on a shared global customer can fail in parallel. For the browser layer, use a dedicated account with resettable state and wait for the confirmation region rather than sleeping. Keep payment calls inside a sandbox or fake at the provider boundary; ordinary CI should not depend on a live card network.

Decide what runs where. Developers can run unit checks on every save and integration tests before pushing. Pull requests run the complete required groups. A scheduled job can run broader browser coverage, dependency audits, and compatibility matrices. Scheduling should not hide important feedback: anything that could ship a broken checkout needs a required check before release.

Warning signs and responses
#

If a suite has browser tests asserting small formatting details, move those checks down to component or unit tests and reserve browser coverage for journeys. If integration tests duplicate every unit edge case, keep representative boundary checks at the integration layer and avoid multiplying runtime. If unit tests mock every collaborator, add integration checks where wiring, serialization, or transaction behavior matters.

When a test flakes, identify which boundary introduces uncertainty. A network dependency may need a controlled fake; a race may need synchronization; shared state may need isolation; a time assertion may need an injected clock. Do not simply increase a timeout or rerun until green. Track the flaky test and retain its original failure so the suite’s confidence stays honest.

Coverage reports guide exploration but are not a target to maximize blindly. A line may execute while its assertion is ineffective. Mutation testing, when practical, checks whether important behavior changes make a test fail. Review high-risk rules such as authorization, money calculations, and irreversible transitions manually even when coverage is high. The pyramid measures evidence about risk, not an attractive percentage.

Next: Unit testing JavaScript with Vitest and Flask API integration tests.

Related

Mocks, Stubs, and Fakes: Choosing Test Doubles
·350 words·2 mins
Testing Mocks Software Design Unit Testing
Compare stubs, mocks, fakes, and spies, then apply the smallest useful substitute in a test.
Unit Testing JavaScript with Vitest
·407 words·2 mins
JavaScript Vitest Testing Unit Testing
Set up Vitest and use Arrange–Act–Assert to test pure functions without depending on a browser or network.
pytest Basics: Tests, Fixtures, and Clear Assertions
·1984 words·10 mins
Python pytest Testing Unit Testing
Write discoverable pytest functions, organize reusable state with fixtures, and make failures easy to understand.