Unit vs Integration vs E2E Testing
Status: Complete
Category: Testing
Default enforcement: Soft
Author: PushBackLog team
Tags
- Topic: testing, quality
- Skillset: any
- Technology: generic
- Stage: execution, review
Summary
The three primary categories of automated test differ in scope, speed, and failure precision. Unit tests verify isolated logic; integration tests verify that components work together correctly; end-to-end tests verify that complete user journeys work as expected. Choosing the right type for the right scenario is as important as writing tests at all.
Rationale
The failure you need to understand
Different test types fail in different ways. A unit test failure tells you exactly which function with which input produces the wrong output — diagnosis is immediate. An E2E test failure tells you that a user journey produced unexpected behaviour — diagnosis requires investigation across potentially the entire stack. Using the wrong test type for a scenario means getting the wrong kind of feedback when something breaks.
The cost of getting scope wrong
Over-testing at a high layer costs build time, reliability, and maintenance. Under-testing low layers means bugs reach production that a fast, cheap unit test would have caught in milliseconds. Choosing the right test scope is an economic decision: maximise confidence per second of test runtime.
Guidance
Definitions
| Type | What it tests | Dependencies | Speed | Failure precision |
|---|---|---|---|---|
| Unit | One function, class, or module in complete isolation | None (all dependencies are mocked or stubbed) | Milliseconds | Exact — one function, one input |
| Integration | Multiple components working together | Real or containerised (DB, queue, downstream service) | Seconds | Good — narrows to a specific boundary |
| E2E | A complete user journey through the entire running system | Full stack (browser, API, DB, external services) | Minutes | Coarse — the journey failed, reason TBD |
Decision guide: which type to write
| Scenario | Right test type |
|---|---|
| Business logic: discount calculation, validation rules, algorithm | Unit |
| Database query returns the right data with the right filters | Integration |
| Service method correctly calls the payment gateway API | Integration |
| Email is sent when an event fires | Integration |
| A user can register, log in, and complete their first action | E2E |
| Payment flow succeeds end-to-end | E2E |
| Error message is shown when API returns 500 | E2E or integration (prefer integration) |
Real vs test doubles
At the unit level, all collaborators are replaced with test doubles. At the integration level, the component under test uses its real collaborators, but those collaborators may run in containers (a real PostgreSQL in Docker, for example) rather than production infrastructure. At the E2E level, the full application runs as close to production as possible.
Contract testing as an alternative to integration E2E
For microservice architectures, contract testing (e.g., Pact) is often more appropriate than E2E testing for service-to-service interactions. Contract tests verify that a consumer and provider agree on the API shape without requiring both to run simultaneously.
Examples
The same feature at all three levels
Scenario: When a user uploads an invoice, the system extracts the total amount and stores it.
// Unit test: verify the extraction logic
test('extractTotal parses GBP invoices correctly', () => {
const invoice = { currency: 'GBP', lineItems: [{ amount: 100 }, { amount: 50 }] };
expect(extractTotal(invoice)).toBe(150);
});
// Integration test: verify the service stores the result
test('InvoiceService.process stores extracted total', async () => {
const service = new InvoiceService(testDb);
await service.process(sampleInvoiceFile);
const stored = await testDb.query('SELECT total FROM invoices WHERE ...');
expect(stored.total).toBe(150);
});
// E2E test: verify the full user journey
test('User uploads invoice and sees extracted total', async () => {
await page.goto('/invoices');
await page.setInputFiles('input[type=file]', 'fixtures/sample-invoice.pdf');
await page.click('button[type=submit]');
await expect(page.locator('.invoice-total')).toContainText('150');
});
All three tests add value. The unit test gives instant, precise feedback on the extraction algorithm. The integration test verifies the persistence boundary. The E2E test verifies the upload UX and rendering.
Anti-patterns
1. Testing database logic with unit tests (and mocks)
Mocking a database to unit-test a query is testing that db.query() was called with a string — not that the query does what it should. Database logic belongs at the integration layer with a real database.
2. E2E tests for algorithm edge cases
Testing that discount capping works correctly by driving a browser through checkout is slow, fragile, and imprecise. Algorithm edge cases are unit test territory.
3. No integration tests at all
“We have great unit test coverage” is cold comfort if the wiring between components — the database schema, the event contract, the API response shape — is never tested. A codebase can be 100% unit-tested and completely broken at the boundaries.
4. Manual E2E substituting for automated tests
Manual QA regression cycles before release are an integration test deficit in disguise. Slow, expensive, and error-prone.
Related practices
Part of the PushBackLog Best Practices Library. Suggest improvements →