Skip to content

Journey tests

A journey test walks one named journey end to end, in a real browser, asserting what the person can perceive and do — not what the DOM contains.

It is the level that fails when a user would have been stuck, which is why it gets the budget.

Not from imagination. Every part of a journey test already exists upstream:

From Provides
Journey mapping The journey’s name and its steps
Story mapping Which journeys exist, and which are in this release
Example mapping green cards The concrete values — the same ones, not retyped
Example mapping state cards The unhappy journeys, which are usually the ones worth testing

That last row is the one teams skip. The happy path is the journey that gets demoed and therefore the journey that gets exercised by hand every day. The denied, empty, slow and offline paths are the ones no one walks by accident.

Assert what a person perceives. Text they can read, controls they can operate, where focus went, what was announced. Not class names, not internal state, not element counts.

The practical form of this rule is to select elements by their accessible role and name — the same way an assistive technology finds them. A test written that way has a useful property: if the selector breaks, something a real user depends on has changed. A test written against [data-testid="btn-42"] passes happily through a button that has lost its label.

e2e/checkout/redeem-a-voucher.spec.ts
test('an expired voucher is refused, and the reason is announced', async ({ page }) => {
await page.goto('/basket');
await page.getByRole('textbox', { name: 'Voucher code' }).fill('SPRING20');
await page.getByRole('button', { name: 'Apply voucher' }).click();
// What the person perceives — the reason, associated with the field.
const field = page.getByRole('textbox', { name: 'Voucher code' });
await expect(field).toHaveAccessibleDescription(/expired on 4 April/);
await expect(page.getByRole('status')).toContainText('Voucher was not applied');
// And what did not happen.
await expect(page.getByRole('status', { name: 'Basket total' })).toContainText('50.00 CHF');
});

The two assertions at the end are the shape to copy: what the person now knows and what did not change. A journey test that only asserts the first is half a test, because the common failure is an action that reports success and silently does nothing.

A journey test that visits a single page is a component test with a browser attached — all of the cost, none of the coverage.

The value is in the seams: navigation, state carried between steps, the back button, a session that expires halfway, a form re-entered after a failure. Those are the places where components that each behave correctly compose into something nobody can use.

Flakiness is what kills this level, and it kills it in a specific way: the suite is not deleted, it is skipped, one test at a time, each with a good reason.

  • Never wait for time. Wait for a condition — an element, a response, a state. Every sleep is a race that will be lost on a slower machine.
  • Control the data. A journey over shared, mutable, shared-environment data fails for reasons that have nothing to do with the code.
  • Stub the far edges. Third parties that are slow, rate-limited or non-deterministic are not what this test is checking.
  • Keep the set small enough to fix. Ten journeys that always pass are worth more than eighty that mostly do — and eighty is where “mostly” starts becoming a policy.
  • Quarantine loudly. A skipped test with a ticket and a date is honest; a skipped test with a comment is a permanent loss recorded as a temporary one.

Run the accessibility scan in the same pass

Section titled “Run the accessibility scan in the same pass”

The journey is already loaded in a real browser at each meaningful state. Running an accessibility scan at those points costs almost nothing and catches the class of violation that only appears in composition: a heading order that is fine per component and wrong per page, a modal that traps focus correctly and is opened from a control that never gets it back.

It fails at a step, not at a line. That is the trade: it is the only automated level that knows whether the product works, and the least precise about why.

The answer is not to make journey tests more precise. It is to have interaction tests underneath them, so that when a journey fails, the component level has usually already told you where.

The larger limit is different, and no amount of engineering removes it: this level proves a path is passable, never that anyone follows it. A journey test walks confidently past the label nobody understands and the button nobody finds, because it was told exactly where to click. That is what usability testing is for, and the two are worth budgeting as a pair — the automation keeps the path working so the session with a person is spent on comprehension rather than on bugs.