Unit vs Integration vs End-to-End Tests

Use unit tests to exercise decisions, integration tests to verify real boundaries, and end-to-end tests to protect critical journeys through the deployed system. The categories describe what a test exercises, not the tool that runs it. A test written with a unit-test framework can connect to a real database; a browser test can isolate one component rather than exercise the whole application.

A useful test portfolio balances defect detection, feedback time, diagnosis, and maintenance. Start with the failures that would hurt users and place each check at the narrowest level that can detect it faithfully. Keep a small set of broad checks for problems that narrower tests cannot see. There is no universal percentage of each test type that proves a system is safe.

TL;DR

Quick Example

Save this as test_orders.py and run python -m unittest -v test_orders. The first test exercises a calculation; the second crosses a real SQLite boundary. No third-party packages are required.

This deliberately small example has no end-to-end test because it has no deployed application entry point. For a checkout application, add a journey that submits an order through the real UI or public API and verifies the resulting order. Also test the production database engine separately: SQLite cannot validate PostgreSQL-specific SQL or locking behavior. Python's unittest documentation defines fixtures, test cases, and runners without requiring every test to have the same scope.

Core Concepts

Unit tests protect behavior with tight feedback

A unit might be a function, class, or a small collaboration whose boundaries make sense to the application. It does not have to mean one method tested in isolation from every other method. Use representative inputs, boundary values, and failure cases. For order calculations, test rounding policy, invalid quantities, and discount interactions rather than asserting only the most convenient successful example.

Keep time, randomness, and external responses controllable when they affect a decision. A fake clock can make an expiry rule deterministic. Prefer assertions on observable output or state; verifying a long sequence of internal method calls can make a harmless refactor look like a functional regression.

Integration tests verify assumptions between components

A database integration test can reveal a missing migration, an incompatible query, a rejected constraint, or an unexpected transaction boundary. An HTTP integration test can verify authentication middleware and request serialization. State clearly which collaborators are real and which are replaced. The label alone does not describe the confidence the test provides.

Use a disposable instance of the production dependency where behavior matters. That does not mean every integration test must contact a vendor's public service. A local protocol stub can verify request shape; a separate sandbox check can verify the vendor contract. Keep credentials and irreversible actions out of ordinary test fixtures.

End-to-end has an explicit system boundary

An end-to-end test starts through an entry point and verifies a complete outcome across the relevant system. For a web checkout, that might include the browser, API, database, and worker. For a service without a UI, it may begin at the public API. If payments use a sandbox or email uses a test inbox, record those boundaries; a passing test does not claim to verify real money settlement or public email delivery.

The Practical Test Pyramid discusses unit, integration, contract, UI, and end-to-end tests as related techniques. Its value is the reasoning about scope and feedback cost, not a mandated numerical ratio.

Head-to-Head

Speed is a measurement, not a definition. A well-designed integration suite can be fast; a unit test with costly setup can be slow. Track actual execution and diagnosis time instead of assigning arbitrary durations to categories.

Build the Portfolio Around Risks

For an order workflow, separate four questions. Can the pricing rule calculate a total? Can the repository persist an order and enforce its constraints? Does the API reject an unauthorized caller? Can an authenticated user complete checkout and later find the same order? These questions overlap in subject but protect different failure mechanisms.

Place most combinations of pricing inputs in the narrow calculation tests. Exercise database-specific behavior at the database boundary. Verify authorization where the policy is applied, including negative cases. Keep a few broad journeys that detect broken routing, missing configuration, or workers that never process a submitted order.

Do not copy every narrow test into the browser suite. That multiplies execution and maintenance while adding little new evidence. Conversely, do not eliminate every broad test because service-level tests pass. A production build can reference the wrong API URL even when the isolated backend is correct.

Contract tests can help where independently released services exchange messages. Verify that the consumer's expectations and provider's behavior agree. A contract check does not replace testing business outcomes or deployment connectivity, but it can identify compatibility failures earlier and more precisely.

Reliable Fixtures and Useful Failure Evidence

Isolate data and clean up after failures

Give tests independent users, identifiers, and records. Do not make test B depend on test A running first. Use transactions or disposable environments when they fit the dependency, and ensure cleanup runs even after an assertion fails. pytest's fixture guidance is useful for understanding setup and teardown responsibilities.

Parallel execution exposes hidden sharing. A fixed email address, shared shopping cart, or globally mutated clock can make tests interfere. Partition data and resource names deliberately; a larger timeout will not repair shared mutable state.

Wait for the outcome, not a guessed delay

In browser checks, use stable user-facing locators and assertions that wait for the expected state. Playwright's best practices emphasize testing visible behavior, isolation, and resilient locators. For asynchronous APIs, poll a specific operation with a bounded deadline and retain the final response on failure. Avoid unconditional sleeps that are slow on healthy runs and too short on overloaded ones.

Retain diagnostic context without exposing secrets

Attach the failing assertion, relevant request ID, sanitized response, and deployment version. Browser traces and screenshots can help, but restrict access when they contain customer data. Differentiate a failed product assertion from an unavailable test environment. Both need an owner; neither should silently become a successful check.

Best Practices

Run the cheapest meaningful checks early

Run deterministic behavior checks before provisioning expensive environments. Follow with boundary checks and critical journeys. Use historical failures and release risk to decide which suites block a change and which need separate scheduled execution; record any reduced coverage explicitly.

Assert outcomes rather than successful navigation

A checkout test should verify that the correct order exists, not merely that a success-colored element appeared. A denied-access test should verify that data did not change. Choose assertions that would fail for the defect you intend to detect.

Repair flaky tests as defects in the feedback system

Track intermittently failing tests with an owner and a reason. If temporary quarantine is necessary, keep the missing coverage visible and set a repair condition. Retries can collect diagnostic evidence; a pass on the third attempt does not prove the underlying problem is harmless.

Common Mistakes

Calling every test in a unit-test runner a unit test

Bad: assume a test is isolated because it uses Jest, pytest, or unittest.

Correct: describe the exercised boundary and real dependencies. Classify and schedule the test using its behavior and setup requirements.

Mocking away the risk

Bad: mock the database to return success, then claim that migrations and constraints are covered.

Correct: keep fast service logic tests and add a real database check for persistence behavior. Test against the actual engine when its semantics matter.

Treating one passing journey as complete coverage

Bad: rely on a happy-path browser checkout to prove authorization, retries, calculations, and recovery.

Correct: use the broad journey to protect wiring, and add focused negative and boundary cases at the levels that can diagnose them clearly.

FAQ

What is the difference between unit and integration testing?

A unit test focuses on a small behavior with controlled dependencies. An integration test verifies collaboration across a meaningful boundary, often using a real dependency. Define that boundary in the test suite rather than relying on a framework name.

Are end-to-end tests integration tests?

In a broad sense they integrate components, but teams usually distinguish them because they exercise a complete journey through a larger system boundary. Agree on terminology locally and document which components and substitutes each suite includes.

What percentage of tests should be end-to-end?

There is no universal percentage. Choose cases using the system's risks, release process, and the cost of detecting and diagnosing failures. A large count of easy unit tests does not compensate for missing critical integration checks.

Can I use SQLite to test a PostgreSQL application?

It can support tests that intentionally do not depend on PostgreSQL semantics. It cannot prove compatibility with PostgreSQL queries, extensions, constraints, or concurrency. Keep production-engine integration checks for those risks.

Related Topics

References