Mapping User Journeys to Test Layers
“Test the checkout journey” is not a plan; it is a wish with a name. A journey is made of a dozen distinguishable things — a pricing decision, a permission check, an API call, a piece of form validation, a redirect — and each has a different cheapest home. Assigning them all to an end-to-end test is the expensive default; assigning them all to unit tests leaves the wiring unverified. This guide covers decomposing a journey into its parts, choosing a tier for each part with a decidable rule, and ending up with one end-to-end test that covers what only it can. It sits under unit vs integration vs E2E mapping.
Root Cause Analysis
Journeys attract end-to-end tests because the journey is how the work was described. A ticket says “a customer can apply a promo code at checkout”, so a test is written that drives a browser through exactly that sentence — and in doing so re-verifies the discount arithmetic, the form validation, the API contract and the rendering, all at the slowest and most fragile tier available.
The result is a test that is slow, that fails for a dozen unrelated reasons, and whose failure message tells you only that the journey broke. Worse, it crowds out the cheaper tests: because the journey is “covered”, nobody writes the unit test for the boundary condition, and the boundary condition is where the bug actually is.
The fix is not to write fewer journey tests but to decompose before choosing. A journey is a sequence of decisions and integrations; each can be verified in isolation far more cheaply than in sequence, and what remains for the end-to-end tier is the one thing isolation cannot check — that the pieces are connected.
Reproducible Setup
Write the journey out as steps before choosing any tier, in the user’s words rather than the system’s.
<!-- docs/journeys/checkout-promo.md -->
1. A signed-in customer with items in their basket opens checkout.
2. They enter a promo code.
3. An invalid code shows an inline error and no discount.
4. A valid code shows the discount and a reduced total.
5. They pay, and the order is created with the discount recorded.
// the shape of the journey in code, for reference
applyPromo(code) → validatePromo(code) → calculateDiscount(basket, promo) → POST /api/orders
Implementation
Step 1 — List what each step actually depends on. Separate decisions (pure logic), integrations (crossing a boundary) and wiring (one part calling another).
| Step | Decision | Integration | Wiring |
| --- | --- | --- | --- |
| 3 invalid code | expiry, usage limit, eligibility | — | error to the form |
| 4 valid code | discount amount, rounding, caps | — | total re-rendered |
| 5 pay | — | payment provider, order API | basket → order |
Step 2 — Send every decision to the unit tier. Decisions are where the bugs are and where tests are cheapest, so this is where the majority of the cases belong — including all the boundary and error cases.
// src/domain/promo.test.ts — the cases nobody would write at the browser tier
import { describe, test, expect } from 'vitest';
import { validatePromo, calculateDiscount } from './promo';
import { aPromo, aBasket } from '../../test/builders';
describe('validatePromo', () => {
test.each([
['expired yesterday', aPromo({ expiresAt: new Date('2026-09-17') }), 'expired'],
['usage limit reached', aPromo({ uses: 5, maxUses: 5 }), 'exhausted'],
['not eligible for sale items', aPromo({ excludesSale: true }), 'ineligible'],
])('rejects a promo that is %s', (_label, promo, reason) => {
expect(validatePromo(promo, aBasket({ hasSaleItems: true }))).toEqual({ ok: false, reason });
});
});
test('caps the discount at the configured maximum', () => {
const result = calculateDiscount(aBasket({ totalPence: 100_000 }), aPromo({ percent: 50, capPence: 2_000 }));
expect(result.discountPence).toBe(2_000);
});
Step 3 — Send integrations to the integration tier, with the boundary stubbed. What you are checking is that your code sends and interprets the right thing, not that the provider works.
// src/api/orders.integration.test.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.post('https://payments.example/charges', async ({ request }) => {
const body = await request.json();
expect(body).toMatchObject({ amount: 8000, currency: 'GBP' }); // discount applied
return HttpResponse.json({ id: 'ch_1', status: 'succeeded' });
}),
);
test('charges the discounted total and records the promo on the order', async () => {
const order = await placeOrder({ basketId: 'b1', promoCode: 'SAVE20' });
expect(order).toMatchObject({ totalPence: 8000, promoCode: 'SAVE20' });
});
Step 4 — Send presentation to the component tier. Whether an error appears in the right place, is announced to assistive technology, and clears when corrected is a component concern and far cheaper to test there.
// src/features/checkout/PromoField.test.tsx
test('shows an inline error for an invalid code and clears it on correction', async () => {
render(<PromoField onApply={async () => ({ ok: false, reason: 'expired' })} />);
await userEvent.type(screen.getByLabelText('Promo code'), 'OLD20');
await userEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(await screen.findByRole('alert')).toHaveTextContent('This code has expired');
await userEvent.clear(screen.getByLabelText('Promo code'));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
Step 5 — Write the one end-to-end test for the happy path only. It asserts that a customer can complete the journey, and nothing about the arithmetic, the error messages or the payload — all of which are covered more cheaply above.
// e2e/journeys/checkout/promo.spec.ts
import { test, expect } from '../../harness/fixtures';
test('a customer can apply a promo code and complete checkout', async ({ page, basket }) => {
await page.goto('/checkout');
await page.getByLabel('Promo code').fill('SAVE20');
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByTestId('order-total')).not.toHaveText(basket.originalTotal);
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Step 6 — Record the mapping next to the journey. The document is what stops the next person re-adding an end-to-end test for the expiry case.
Verification
Verify the coverage is complete across tiers rather than within one. Walk the decomposition table and confirm each row has a test somewhere; a row with nothing against it is a genuine gap, and a row with three is duplication.
# every rule in the decomposition should map to a named test
grep -rn "expired\|exhausted\|ineligible" src/domain/promo.test.ts | wc -l
# 3 ← matches the three rejection reasons in the table
Then verify the end-to-end test is not quietly re-testing the lower tiers. A grep for numeric assertions in the journey spec is a crude but effective check.
grep -nE "toHaveText\('£|toBe\([0-9]" e2e/journeys/checkout/promo.spec.ts
# (no output — the journey asserts on outcomes, not on values)
Finally, verify the mapping survives a change. Alter the discount cap and confirm exactly one test fails — the unit test — rather than the journey, the component test and the integration test all going red for one arithmetic change.
Troubleshooting
Symptom: the decomposition produces twenty rows and feels like overhead. Diagnosis: it is being done at too fine a grain, listing implementation steps rather than behaviours. Fix: decompose by what could be wrong from the user’s point of view, not by function call; five to eight rows is typical for a substantial journey.
Symptom: a behaviour seems to need the browser but is really a decision. Diagnosis: the logic is embedded in a component, so it cannot be tested without rendering. Fix: extract it — the difficulty of testing it cheaply is telling you about the design, which is the most useful signal this exercise produces.
Symptom: the end-to-end test passes while the journey is broken for users. Diagnosis: the happy path is covered and the realistic path is not — the customer who applies an expired code, corrects it, and continues. Fix: add that as a second journey test only if the recovery path is genuinely business-critical; more often the component test for error recovery is the right home.
Symptom: teams disagree about the tier for a given row. Diagnosis: the row mixes two things — a decision and its presentation. Fix: split the row. Most disagreements about tier dissolve once the behaviour is stated precisely enough to have one answer.
FAQ
How many end-to-end tests should a journey have?
Usually one, occasionally two when there is a genuinely distinct path — a first-time user and a returning one, say. The count is a ceiling to defend rather than a target to reach, and the arithmetic behind that is in how to calculate ROI for E2E tests in React apps.
Does this leave the journey under-tested?
It leaves it tested in more places, with better failure messages, and faster. What is genuinely lost is the guarantee that this exact sequence of screens works in this exact order — which is what the one end-to-end test restores. The parts are verified separately and the connection is verified once.
Where do accessibility and visual checks fit?
At the component tier for the specific behaviours, and in a separate visual suite for rendering. Neither belongs in the journey test, which should not fail because a colour changed — that is what visual regression testing is for.
What about journeys that span services?
The decomposition is the same; what changes is that some integrations are between your services rather than to a third party. Those are the strongest candidates for contract tests, which verify both sides of a boundary without running either system end to end — see contract testing.
Related
- Back to Unit vs Integration vs E2E Mapping
- Choosing a tier for API route tests — the same decision for a server route.
- Testing business rules without the UI — extracting the decisions this mapping relies on.
- End-to-End Test Architecture — building the one test the mapping leaves you with.