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.

A checkout journey decomposed into its parts The journey breaks into a promo validity decision, a discount calculation, a payment API call, form validation, and the wiring between screens — the first four have cheaper homes and only the wiring needs the end-to-end tier. "apply a promo code at checkout" is the code valid? unit what discount? unit form shows the error component payment API contract integration the pieces are connected one end-to-end test
Four of the five parts have a cheaper home; the fifth is what the end-to-end test is for.

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();
});
A decidable rule for choosing the tier If the behaviour is a decision about values it goes to the unit tier, if it crosses a boundary it goes to integration, if it is about what the user sees it goes to the component tier, and only connectedness goes to end-to-end. If the behaviour is… then the tier is a decision about values, with no I/O unit a request or response crossing a boundary integration, boundary stubbed what the user sees, reads or focuses component only that the parts are connected end-to-end, one test
Applied to each row of the decomposition, this rule gives the same answer whoever applies it.

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.

What a good mapping looks like when something changes Changing the discount cap fails one unit test with a precise message, while in a journey-only suite the same change fails the end-to-end test with a message about the wrong total on a confirmation page. mapped suite 1 unit test fails "expected 2000, got 2500" diagnosed in seconds journey-only suite the browser test fails "total was £75.00" diagnosed by reading a trace
The mapping's payoff is not only speed — it is that a failure names the thing that broke.

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.