---
title: "Browser tests that read like the journey"
description: "Our newest browser tests are written as the journey a person takes, in Given, When and Then steps that use their words. Quick swap needs two waiting clients reaching for one time, so every journey takes a seeded set of its own and can run again. Here is how they wait for hydration, ignore React's hidden streaming copy, pass three times in a row with no retries, and the four app bugs they found."
url: "https://bookatu.com/engineering/browser-tests-that-read-like-the-journey"
language: "en"
section: "Engineering"
category: "Engineering"
author: "The Bookatu engineering team"
published: "2026-10-29"
updated: "2026-10-29"
tags: ["testing", "playwright", "bdd", "react", "engineering"]
---

# Browser tests that read like the journey

Our newest browser tests are written as the journey a person takes, in Given, When and Then steps that use their words. Quick swap needs two waiting clients reaching for one time, so every journey takes a seeded set of its own and can run again. Here is how they wait for hydration, ignore React's hidden streaming copy, pass three times in a row with no retries, and the four app bugs they found.

> **The short version**
>
> - Six Playwright specs with 38 journeys cover our newest features: Quick swap, the Security page, Remove and sign out, passkeys, the blog's own-AI panel and several services in one visit.
> - Each journey is a run of test.step calls named Given, When and Then, in the words of the person on the page. A failure names the step where that person would have got stuck.
> - Seeds write each scenario into a local in-process Postgres as a pool of independent sets. A journey claims a set nobody has used, with an atomic mkdir, so one seed survives repeats.
> - Two helpers do most of the waiting. One waits until React has hydrated the button about to be tapped. The other reads only visible text, because React keeps a hidden copy of content while a page streams in.
> - Every spec passed three times in a row with retries off, and so did the merged run of all six on one server: 0 failed, 0 flaky.
> - The run found four app bugs: a claim tapped before the scripts load, a Remove button that did nothing, Continue racing tap-to-advance, and a phone number matching the wrong client. All four are fixed in this release.

Most of our tests run in vitest against a real Postgres, in process. They are fast, and they are where the rules are pinned. They cannot tell us what a person sees: whether the button is there, whether a tap does anything, whether the words are the ones the code meant. For our newest features we added browser journeys that do, written to read like the journey they cover.

## Quick swap, as the people in it live it

Quick swap is a good test of the idea, because it is a story with several people in it. A client inside the late-cancel window asks to swap out instead of paying the fee. Two clients on the waitlist get a one-tap link to that time. The first to take it is booked in, and the asking client is released with no fee. The second is told the time has gone.

The engine underneath has its own integration tests, including two claims racing inside one Promise.all. The browser journey covers the same moment the way the two waiting clients live it: each in a browser of their own, both with the offer open, one tapping a moment before the other.

```ts
test("NEGATIVE: a second waiting client is too late once the first has taken the time", async ({ page, visitor }) => {
  const set = takeSet("on");

  const bPage = await visitor();
  const cPage = await visitor();
  await test.step(`Given ${set.a.firstName} asked to swap out, and ${set.b.firstName} and ${set.c.firstName} both have the offer open`, async () => {
    expectBeforeCutoff();
    await openMyBooking(page, set.a.bookingPath, set.a.firstName);
    await askToSwapOut(page);
    await openOffer(bPage, set.b.claimPath);
    await expectSpotOffered(bPage, set.b.firstName);
    await openOffer(cPage, set.c.claimPath);
    await expectSpotOffered(cPage, set.c.firstName);
  });

  await test.step(`When ${set.b.firstName} takes the spot first`, async () => {
    await bPage.getByRole("button", { name: "Take this spot" }).click();
    await expect(bPage.getByRole("heading", { name: "You're booked in" })).toBeVisible();
    // ...
  });

  await test.step(`And ${set.c.firstName} then taps Take this spot on the page they still had open`, async () => {
    await cPage.getByRole("button", { name: "Take this spot" }).click();
  });

  await test.step(`Then ${set.c.firstName} is told the spot is taken, that they are still on the waitlist, and is not booked`, async () => {
    await expectSpotGone(cPage);
    await expect(cPage.getByRole("heading", { name: "You're booked in" })).toHaveCount(0);
  });
  // ...
});
```

visitor is a small fixture that opens another browser context, as a second person on another phone would have. The business is one more, signed in to the admin. Locators mostly use roles and the English copy, so a renamed button is noticed.

## Given, When and Then are just steps

There is no Gherkin file and no step registry. A step is a test.step with a sentence for its name, and the helpers inside it are named for what people do: openMyBooking, askToSwapOut, openOffer, expectSpotGone. Playwright records each step in its report and trace, so a failure is pinned to the sentence it happened in. The takeover journey, with the seeded names filled in, starts like this:

```text
Given Aroha asked to swap out of their late Gel Manicure, and Bianca Lowe is on the waitlist for it
And the time is emailed to Bianca and Chloe, the two clients waiting for it, and to nobody else
When Bianca opens the offer link from their email
Then they see the spot up for grabs: what, when, and until when
When they take the spot
Then they are booked in, with a reference
And Aroha's booking page says the time was taken and they are released with no fee, deposit refunded
```

A journey also says what must not happen. Those are marked NEGATIVE in the title: the second claimer, a tampered link, a link opened on another business's site, an offer after the asking client withdrew, and a business with Quick swap off, where a late cancel is the plain cancel it always was.

## Each journey takes its own seeded set

A journey changes the data it runs on. Once Bianca has the time, nobody can take it again. So scripts/seed-bdd-quick-swap.ts makes a pool: 45 independent sets at one business, each with a team member of its own, client A booked with them about six hours out with a paid deposit, and clients B and C on the waitlist for that service, person and day. With a person each, one set's offer reaches only its own B and C.

A journey claims a set nobody has used yet by making a directory. mkdir is atomic, so two workers can never take the same set, and the directories live under the seed's id, so a new seed starts with every set free:

```ts
function takeSet(kind: "on" | "off" | "credit"): { n: number } {
  const s = scenario!;
  const dir = path.join(BDD_DIR, "quick-swap.taken", s.seedId);
  fs.mkdirSync(dir, { recursive: true });
  const sets: Array<{ n: number }> = s[kind].sets;
  for (const set of sets) {
    try {
      fs.mkdirSync(path.join(dir, `${kind}-${set.n}`));
      return set;
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
    }
  }
  throw new Error(`every seeded "${kind}" Quick swap booking is used: rerun tools/bdd-serve.sh to reseed`);
}
```

Running out is loud, and so is a stale seed. The bookings start about six hours after seeding and offers close two hours before the start, so each journey begins by checking there are at least fifteen minutes before offers close. If there are not, the Given step fails and says to reseed. It does not skip, because a skipped journey looks too much like a passing one.

The seeds only ever write to the local rig. Each exits if a real database variable is set or the PGlite directory is not, and imports the database code only after that check. The specs run only when E2E_URL is plain http on this machine. The first version compared a string prefix, which a reviewer noticed would accept http://localhost.example.com, so the URL is parsed now:

```ts
/** Only ever the local rig: a plain http URL whose host is this machine (no tricks like localhost.example.com or user@host). */
const LOCAL_RIG = (() => {
  try {
    const u = new URL(process.env.E2E_URL ?? "");
    return u.protocol === "http:" && ["localhost", "127.0.0.1"].includes(u.hostname) && !u.username && !u.password;
  } catch {
    return false;
  }
})();
```

## A seed must not do the app's job

The seed signs B's and C's links with the app's own token helper, exactly what the offer signs when A asks. That made the journeys easy to write, and a reviewer found the cost: the takeover journey would pass even if the offer reached nobody, because the links the journey opened never came from the offer. With no email key, the local dev server logs every email it would send, one line each. So the journey now reads that log:

```ts
await expect.poll(() => emailsTo("quick_swap_offer", s.orgId, set.b.email), { timeout: 30_000 }).toBe(1);
expect(emailsTo("quick_swap_offer", s.orgId, set.c.email)).toBe(1);
expect(emailsTo("quick_swap_offer", s.orgId, set.a.email), "never to the client who asked").toBe(0);
expect(emailLines("quick_swap_offer", s.orgId).length - offersBefore, "offer emails sent for this ask").toBe(2);
```

It also checks the business's activity log says the time was offered to two waiting clients. The same review found other promises that were only checked as copy. "Released with no fee" was checked through a sentence the page prints for every swapped booking, so the journey now reads the deposit status and the money line in the activity log too.

Expected texts are not typed into the spec either. The seed computes dates, times and amounts with the app's own formatters and writes them into the scenario file, so a journey that reads a booking from Europe/London expects exactly what the app prints there.

## Waiting for hydration before tapping

Server rendering means the page arrives before it works. A button drawn by the server is visible and enabled, and a tap on it does nothing until React has hydrated it, or, for a form with a server action, posts the plain form instead. Playwright's auto-waiting checks that an element is visible, stable and enabled. It cannot know whether React is listening yet.

Where a page has its own sign of being live, the journey waits for that. The manage-booking page adds an "On your clock:" line for a reader in another time zone only once it runs in the browser, so the Quick swap journeys run in Europe/London and wait for that line before any tap. The claim page has no such sign, so a helper asks React directly:

```ts
/** A page's buttons answer a tap only once React has hydrated it. */
async function whenLive(target: Locator): Promise<void> {
  await expect
    .poll(() => target.evaluate((el) => Object.keys(el).some((k) => k.startsWith("__reactProps$"))), { timeout: 30_000 })
    .toBe(true);
}
```

That key is a React internal, fine in a test helper and nowhere else, and if React renames it the helper times out loudly. It earned its place. A reviewer noticed the passing journeys tapped Take this spot without it, which is exactly how a person on a slow phone meets the first bug below.

## React's hidden streaming copy

While a page streams in, React briefly keeps content it is about to show in a hidden container, a div with an id like S:0, then swaps it into place. For a moment the same sentence is in the DOM twice. Playwright locators are strict: a getByText that matches two elements fails rather than guess, and our first runs hit that on exactly the pages that stream. Every text check now goes through one helper that counts only what a person can see:

```ts
/**
 * Text a person can see. While a page streams in, React briefly keeps a hidden
 * copy of what it is about to show; that copy is nobody's to read.
 */
function shown(page: Page, text: string | RegExp, opts: { exact?: boolean } = {}) {
  return page.getByText(text, opts).filter({ visible: true });
}
```

The tempting fix is .first(), which makes the clash go away by picking one of the two. Nothing says it picks the one on screen. Filtering on visibility says what we mean.

## Three times, with no retries

Our smoke tests against the live site allow two retries, because a long run over the internet has hiccups that are not bugs. These journeys run on a local rig, where a retry would only hide a race. So every spec had to pass three times in a row with retries off:

```bash
E2E_URL=http://localhost:3600 E2E_ADMIN_SLUG=verdo-parnell \
  npx playwright test --project=bdd --workers=1 --retries=0 --repeat-each=3 tests/e2e/bdd/quick-swap.spec.ts
```

--repeat-each=3 is where the pools earn their keep: three runs of a journey take three sets. Three builders wrote the specs, each on a server of their own, and two reviewers read them against the features' promises. After the review fixes, all six specs ran together on one server seeded by all four scripts. The 35 journeys that could pass did, three times each, in 10.5 minutes, with 0 failures and 0 flaky runs. The other three recorded app bugs.

We also broke the code on purpose. Removing the preventDefault from the blog panel's Enter handler made the Enter journey fail, and switching off the never-with refusal in the visit engine made the combination journey fail. A green run is only worth something if the journey can go red.

Time is the one thing a journey cannot always fake. page.clock holds the browser's timers, and one bug below needed exactly that. Auto sign-out could not use it, because its warning asks the server how long is left, and the server counts in real time. So that seed pre-signs the session's last-activity stamp every five seconds over three hours, and the journey picks the one from 13 minutes 55 seconds ago.

## What the run found

Three journeys could not pass, because the app was wrong. Each was written to assert the correct behaviour and marked test.fixme, which keeps the suite green and keeps the bug as the test that has to pass once it is fixed. The fourth bug turned up in the test data. All four are fixed in this release, and the three journeys are ordinary journeys again.

### A claim tapped before the scripts load

The claim button is a progressively enhanced form, so without JavaScript it still posts and the server action still runs. A waiting client on a slow phone who tapped before the scripts arrived was booked in, and the asking client released. But the page sent back was built from the link's state, which now read as taken, so it told the person who had just won the time that it was gone and that they were still on the waitlist. The journey drops the page's script chunks until the tap:

```ts
// B is on a patchy connection: the page arrives, but its scripts do not
// (until after the tap), so the button works as a plain form.
const bPage = await visitor();
let tapped = false;
await bPage.context().route(/\/_next\/static\/chunks\//, (route) => (tapped ? route.continue() : route.abort()));
// ...
tapped = true;
await bPage.getByRole("button", { name: "Take this spot" }).click();
```

The fix recognises the client whose link took the time and shows them that they are booked in, with their reference, both straight after the plain post and when they open the link again. Everyone else still sees that the spot has gone.

### A Remove button that did nothing

On the team page, an admin saw a plain Remove on an owner's row. The server refused, correctly, because only an owner can remove an owner. But the action returned nothing and the button ignored the outcome, so the click did nothing and said nothing. The new Remove and sign out button beside it already followed the right rule. Now the plain Remove follows it too, and a refusal from the server is shown to the person instead of swallowed.

### Continue racing tap-to-advance

On the booking page, tapping a service moves the page on by itself 260 ms later. Continue moved on at once and did not cancel the move already waiting. Tap a service, then Continue within that beat, as Playwright and people both do, and the page moved on twice: past Date & time to Your details, with no time chosen. Add another service was never shown, and Confirm booking at the end did nothing. The journey holds the browser's timers so the pending move is still waiting when Continue is tapped:

```ts
await page.clock.install();
await page.goto(`/${s.on.slug}/book`);
// ...
// Hold the page's timers still, so the tap-to-advance is pending when Continue is tapped.
await page.clock.pauseAt(Date.now() + 1_000);
// ... tap the service ...
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(step(page, "Date & time")).toHaveAttribute("aria-current", "step");
// The page's own beat (260 ms) passes.
await page.clock.runFor(1_000);
await expect(step(page, "Date & time")).toHaveAttribute("aria-current", "step");
```

Now any explicit move cancels a pending one, and Confirm booking with no time chosen no longer fails silently.

### A phone number that matched the wrong client

This one came from the test data. An early version of the visit journeys reused a phone number across new clients, and a confirmation email went to a different test client from the one who booked. Online bookings were matched by email, then by phone, so a new person with a new email who typed a phone number already on file was booked under the earlier client's record, and the earlier client got the email. The specs now give every client a fresh number, and the app no longer uses a phone match when the booker gave an email and the record found by phone has a different one. That booking gets a client of its own, which the business can merge if it was the same person.

## What we take from it

- Write the journey in the person's words. A failing step should read like the place they got stuck.
- Give every run its own data. A pool of seeded sets and an atomic claim make repeats cheap and retries unnecessary.
- Do not let the seed do the app's job. If the seed makes the link, check that the app sent it.
- Wait for what the person waits for. Visible is not the same as live, and the DOM can hold a copy nobody sees.
- Turn retries off on a local rig, and break the code on purpose. A journey that cannot go red proves nothing.
