PushBackLog

Unit vs Integration vs E2E Testing

Soft enforcement Complete by PushBackLog team
Topic: testing Topic: quality Skillset: any Technology: generic Stage: execution Stage: review

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

TypeWhat it testsDependenciesSpeedFailure precision
UnitOne function, class, or module in complete isolationNone (all dependencies are mocked or stubbed)MillisecondsExact — one function, one input
IntegrationMultiple components working togetherReal or containerised (DB, queue, downstream service)SecondsGood — narrows to a specific boundary
E2EA complete user journey through the entire running systemFull stack (browser, API, DB, external services)MinutesCoarse — the journey failed, reason TBD

Decision guide: which type to write

ScenarioRight test type
Business logic: discount calculation, validation rules, algorithmUnit
Database query returns the right data with the right filtersIntegration
Service method correctly calls the payment gateway APIIntegration
Email is sent when an event firesIntegration
A user can register, log in, and complete their first actionE2E
Payment flow succeeds end-to-endE2E
Error message is shown when API returns 500E2E 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.



Part of the PushBackLog Best Practices Library. Suggest improvements →