---
title: Monitor a checkout flow with Playwright, step by step
description: Your checkout page can return 200 while nobody can buy anything. Here's a Playwright test that signs in, adds a product, checks out with test data and asserts the order confirmation, why each line is written the way it is, how to avoid real orders and leaked passwords, and how to run it every few minutes from more than one place.
canonical: https://watchfor.io/blog/monitor-checkout-flow-with-playwright
---

# Monitor a checkout flow with Playwright, step by step

2026-10-03 · WatchFor Team · Monitoring · synthetic-monitoring, playwright, web

Your checkout page can return 200 while nobody can buy anything. Here's a Playwright test that signs in, adds a product, checks out with test data and asserts the order confirmation, why each line is written the way it is, how to avoid real orders and leaked passwords, and how to run it every few minutes from more than one place.

An uptime check on `/checkout` tells you the server answered. It doesn't
tell you whether a customer can buy something. Checkouts break in ways a
status code never shows: a renamed button after a deploy, a payment script
that fails to load, a shipping form that rejects every postcode, a cart that
empties itself on the way to the payment step. The page still returns
`200`. Revenue goes to zero.

The only check that catches those is one that does what a customer does:
sign in, put something in the cart, fill in the details, place the order and
look for the confirmation. That is a short Playwright test. This post builds
one you can run right now, explains each choice in it, and then shows how to
run it on a schedule so you hear about a broken checkout before your
customers tell you.

## What we're building

We'll test [Swag Labs](https://www.saucedemo.com/), a demo shop that Sauce
Labs runs for exactly this kind of practice. It prints its test accounts on
the login page (`standard_user`, password `secret_sauce`) and its orders are
not real, so you can run the whole flow, including the final button, as
often as you like.

The test has five steps, each named the way you'd describe it to a person:

1. **Sign in**
2. **Add a product to the cart**
3. **Cart shows the product**
4. **Enter delivery details**
5. **Place the order**, then check the confirmation

Naming steps matters more for monitoring than for testing. At 3 a.m. "Failed
at *Enter delivery details*" tells you where to look before you've opened a
single log.

## Set up

You need a current Node.js (Playwright's [system requirements](https://playwright.dev/docs/intro) list 22, 24 or 26). In an empty folder:

```bash
npm init -y
npm install -D @playwright/test
npx playwright install chromium
```

Save the test below as `tests/checkout.spec.ts` and run it:

```bash
npx playwright test
```

## The test

```ts

// Swag Labs puts a data-test attribute on every control. Telling
// Playwright about it makes getByTestId() read that attribute.
test.use({ testIdAttribute: 'data-test' });

// The demo shop prints these on its login page. On your own shop, set them
// as environment variables and keep the password in a secret store.
const shopUrl = process.env.TARGET_URL ?? 'https://www.saucedemo.com/';
const username = process.env.SHOP_USERNAME ?? 'standard_user';
const password = process.env.SHOP_PASSWORD ?? 'secret_sauce';

test('checkout', async ({ page }) => {
  await test.step('Sign in', async () => {
    await page.goto(shopUrl);
    await page.getByTestId('username').fill(username);
    await page.getByTestId('password').fill(password);
    await page.getByTestId('login-button').click();
    await expect(page.getByTestId('title')).toHaveText('Products');
  });

  await test.step('Add a product to the cart', async () => {
    await page.getByTestId('add-to-cart-sauce-labs-backpack').click();
    await expect(page.getByTestId('shopping-cart-badge')).toHaveText('1');
  });

  await test.step('Cart shows the product', async () => {
    await page.getByTestId('shopping-cart-link').click();
    await expect(page.getByTestId('inventory-item-name')).toHaveText('Sauce Labs Backpack');
  });

  await test.step('Enter delivery details', async () => {
    await page.getByTestId('checkout').click();
    await page.getByTestId('firstName').fill('Test');
    await page.getByTestId('lastName').fill('Monitor');
    await page.getByTestId('postalCode').fill('10001');
    await page.getByTestId('continue').click();
    await expect(page.getByTestId('total-label')).toContainText('Total: $');
  });

  await test.step('Place the order', async () => {
    await page.getByTestId('finish').click();
    await expect(page.getByTestId('complete-header')).toHaveText('Thank you for your order!');
  });
});
```

We ran this file as written: five times in a row on Playwright 1.57, and on
WatchFor's own runner (Playwright 1.63) from Vilnius, where the whole journey
took about five seconds. It's 40 lines, and almost every one of them is a
decision. Here's the reasoning.

## Stable selectors: find things the way they won't change

The most common reason a checkout monitor fails is not a broken checkout.
It's a selector that stopped matching after a harmless redesign. Pick
selectors in roughly this order:

1. **A role and an accessible name**, like
   `getByRole('button', { name: 'Place order' })`. It's how a person finds the
   button, and it survives CSS changes. Playwright's own
   [best practices](https://playwright.dev/docs/best-practices) say: "we
   recommend prioritizing user-facing attributes and explicit contracts."
2. **A test id** that developers add on purpose. Swag Labs ships
   `data-test` attributes, so the test uses those. Playwright's default is
   `data-testid`; `test.use({ testIdAttribute: 'data-test' })` points
   `getByTestId()` at a different attribute
   ([locators docs](https://playwright.dev/docs/locators)).
3. **CSS classes and XPath, only as a last resort.** `.btn-primary` or
   `div:nth-child(3) > button` breaks the next time someone touches the
   stylesheet.

Test ids are the "explicit contract" part: a developer who sees
`data-testid="place-order"` knows a test depends on it. If your shop has
none, asking the team to add them to the six or seven controls in the
checkout is the cheapest reliability work you'll do this year.

Two more habits that keep selectors honest:

- **Pick the product by something stable.** "The first product on the page"
  changes whenever merchandising reorders the grid. A named product that's
  always in stock doesn't. On a real shop, a gift card is often the best
  choice: always available, no shipping.
- **Don't assert on prices.** Prices change; that's not an outage. The test
  checks that a total exists (`Total: $`), not what it is.

## No sleeps: wait for the result, not the clock

You'll be tempted to add `await page.waitForTimeout(2000)` when a step is
sometimes too fast for the page. Don't. Playwright's own
[API reference](https://playwright.dev/docs/api/class-page) is blunt about
it: "Never wait for timeout in production. Tests that wait for time are
inherently flaky."

Instead, every step in the test ends with an `expect` that proves the step
worked: the page title says *Products* after signing in, the cart badge says
*1* after adding the product. Those are web-first assertions: Playwright
retries them until they pass or time out (5 seconds by default), so they wait
exactly as long as the page needs and no longer. Actions wait on their own
too: before a `click()`, Playwright checks that the element is visible,
stable (not animating), not covered by something else and enabled
([actionability](https://playwright.dev/docs/actionability)).

The assertion at the end of each step does a second job: it makes failures
land in the right place. Without it, a sign-in that silently failed would
surface as "couldn't find the add-to-cart button" one step later, and you'd
start debugging the wrong page.

Also avoid `waitForLoadState('networkidle')` as a general fix. Shops with
chat widgets, analytics beacons and live inventory polling never go quiet,
so it either waits for nothing or times out.

## Test accounts and test data

- **Use an account that exists only for the monitor.** Give it the least
  access you can, exclude it from analytics and marketing emails, and never
  point a monitor at a real customer's or employee's account.
- **Expect leftovers.** On many real shops the cart lives on the server and
  follows the account. If yesterday's run failed after adding a product,
  today's run starts with one already in the cart, and a strict
  `toHaveText('1')` fails for the wrong reason. Either empty the cart in the
  first step, or assert that the product is *in* the cart rather than
  counting items.
- **Give each location its own account** if your shop limits concurrent
  sessions or flags logins from new countries. Two monitors signing in to the
  same account from Frankfurt and Singapore at the same moment can look a lot
  like account takeover to a fraud system.
- **Use obviously fake delivery details.** "Test Monitor" is easy to
  recognise in an order list and impossible to mistake for a customer.

## Never place a real order

Swag Labs' **Finish** button creates nothing. Yours does. You have three
honest options on a real shop:

1. **Stop just before payment.** Assert on the order summary and end the
   test there. This is what WatchFor's built-in *Checkout* example does, and
   it catches the large majority of checkout breakage: cart, pricing,
   shipping, the forms, the summary.
2. **Test payment on staging, in your provider's test mode.** Stripe, for
   example, gives you test cards such as `4242 4242 4242 4242` that work only
   with test keys, and is explicit that "The Stripe Services Agreement
   prohibits testing in live mode using real payment method details"
   ([Stripe testing docs](https://docs.stripe.com/testing)). A staging
   monitor proves the payment integration works; a production monitor that
   stops before payment proves the shop works. Run both.
3. **Place real orders only with a plan for them.** If the business insists
   on a true end-to-end order in production, make sure fulfilment, invoicing
   and stock all recognise the monitor's account and drop its orders, and
   cancel them automatically. Most teams that try this go back to option 1.

Payment fields usually sit inside an iframe from the payment provider. The
[iframes and Stripe recipe](/docs/monitors/browser-checks/recipes#iframes)
shows how to reach them with `contentFrame()`.

## Keep the password out of the script

The test reads `SHOP_USERNAME` and `SHOP_PASSWORD` from the environment and
only falls back to the demo values when they're missing. On your own shop,
drop the fallbacks and pass real values in:

```bash
SHOP_USERNAME=monitor@yourshop.example SHOP_PASSWORD='…' npx playwright test
```

In CI, those come from the secret store, never from the repository. And
watch the side doors, because a password can leak without ever being in the
code:

- **Traces.** A Playwright trace records every action with its arguments. We
  checked one from this test: the password is right there in the `fill`
  call. Treat trace files like credentials, and don't attach them to public
  bug reports.
- **Screenshots** of a settings page, an account page or an error that
  echoes form input.
- **Logs.** A `console.log` left in while debugging.

## Run it every few minutes, from more than one place

A test on your laptop proves the checkout worked once. A monitor proves it
keeps working, at 3 a.m., after the Friday deploy, from the countries your
customers are in. Here's that last mile in WatchFor, where this exact file
runs unchanged as a
[browser check](/docs/monitors/browser-checks).

**Paste the script and test it before you save.** Start a new browser check
and choose **Import a script**: it takes a `.spec.ts` file,
`npx playwright codegen` output or a Chrome DevTools Recorder export. **Test run** runs it once from one of our
browser locations while you watch each step tick past. Nothing is saved or
scheduled yet.

![The WatchFor browser check editor with the checkout test pasted in: name Checkout, URL saucedemo.com, and a test run that passed in 5.8 seconds with all five steps and their timings; the variables strip shows TARGET_URL plus SHOP_USERNAME and SHOP_PASSWORD with fallbacks](/blog/monitor-checkout-flow-with-playwright-editor.webp)

Two details in that screenshot are worth knowing:

- **`TARGET_URL` is built in.** It's always the check's own URL, so the same
  script can watch staging and production as two checks.
- **The variables strip reads your script.** `SHOP_USERNAME` and
  `SHOP_PASSWORD` show as *fallback* because the script has its own default.
  Add them under **Settings → Variables & secrets**, the password as a
  **secret**: secrets are encrypted at rest, never shown again (not even to
  owners), and masked as `••••••` in logs, error messages, step titles,
  trace text and screenshots of every run. The
  [variables docs](/docs/monitors/browser-checks/variables) also cover what
  masking can't see, such as a token a site returns after sign-in.

**Choose an interval and locations.** How often depends on your plan, and
all your checks share a monthly runs budget worked out from their intervals
and locations when you save:

| Plan | Browser checks | Shortest interval | Runs a month |
| --- | --- | --- | --- |
| Free | 1, from one location | 1 hour | 750 |
| Starter | 2 | 10 minutes | 9,000 |
| Pro | 10 | 5 minutes | 45,000 |
| Business | 25 | 1 minute | 110,000 |

On Pro, 45,000 runs is five checks every five minutes. A run also has to fit
its interval: twice the timeout (the run and its one retry) plus 30 seconds
of start-up must be shorter than the interval. A check every minute can
therefore have a timeout of at most 14 seconds; at five minutes, the plan's
longest run (120 seconds on Pro) is the limit instead. The editor only offers
timeouts that fit.
[Pricing](/pricing) always has the current numbers.

**Watch the steps, not just the result.** Every run records each step's
duration, so a checkout that's slowly getting slower shows up long before it
breaks. This is Acme's *Checkout* check in the demo organization, built from the
built-in example that stops at the order summary, after a week of runs every
five minutes:

![The Checkout browser check's overview in WatchFor: 100% uptime over 24 hours, last run passed in 4.7 seconds from Vilnius, a 4.5-second median journey time over 7 days, Web Vitals from the last run, and a 7-day timing strip for each of the four steps](/blog/monitor-checkout-flow-with-playwright-steps.webp)

**When it fails, you see where and why.** A failed test is retried once
inside the same run. If it still fails, a confirmation run follows about a
minute later, from another location when the check has more than one. Only
two failing runs in a row open an incident; a pass on the retry is recorded
as *flaky* and pages nobody. The failed run shows which step broke, the error
in one line (what was expected, what the page showed), the code frame, the
page at the moment it failed and, when the error is a known kind, a hint on
how to fix it.

![A failed browser check run in WatchFor: Failed at "Signed-in page is shown" on attempt 2 of 2 from Vilnius, the step waterfall with the failing step in red, the expected and actual text, a hint explaining the element shows different text, the code frame and the screenshot of the page at the failure](/blog/monitor-checkout-flow-with-playwright-failure.webp)

That example is a different check (a login journey on another practice
site), but the layout is the same for every browser check. **Compare with
last passing run** puts each step's change in duration and the two
screenshots side by side.

**The full Playwright trace is one click away.** **Open trace** opens the
Playwright trace viewer, hosted by WatchFor: DOM snapshots before and after
each action, network requests, console output and a film strip of the whole
run. The trace never goes to a third party, and secrets are masked in its
text.

![The Playwright trace viewer opened from a failed WatchFor run: the film strip across the top, the action list with the failing toContainText assertion highlighted, the page snapshot at that moment and the call details below](/blog/monitor-checkout-flow-with-playwright-trace.webp)

Failed runs keep their screenshots and trace for 30 days. Passing runs keep
nothing, except the first run after a script change, **Run now**, and one run
a day, so there's always a recent passing run to compare with.

**Alerts for more than "it broke".** **Journey failed** is always on. On the
check's **Alerting** tab you can add budgets: **Journey slow** (the whole
run), **Step budgets** (per step), **Console errors**, **Failed requests**,
**Slow page (LCP)** and **Flaky journey**. Like every WatchFor monitor, a
browser check alerts through the channels its contact groups are attached to:
email, Slack, PagerDuty, a webhook and the rest.

![The Alerting tab of the Checkout browser check: optional rules for Journey slow, Console errors, Failed requests, Slow page (LCP) and Flaky journey, and the confirmation setting of 2 consecutive failed runs about a minute apart](/blog/monitor-checkout-flow-with-playwright-alerting.webp)

**Let it through your bot protection.** Each run uses a fresh, sandboxed
browser that can only reach the public internet, and its user agent ends in
`WatchFor-BrowserCheck/1.0 (+https://watchfor.io/bot)`. If a WAF or bot-protection
rule blocks it, the check fails at the first step. Allow that user agent
through, and filter it out of your analytics so monitor traffic doesn't
inflate your conversion funnel.

## What a checkout monitor won't tell you

Be clear about the limits, so nobody treats a green check as more than it
is:

- **It tests one path.** One product, one country, one payment method. A
  bug that only hits PayPal users in Germany needs its own check, or real
  user data.
- **It doesn't process a real payment in production** (and shouldn't). The
  staging check covers that integration.
- **It's not your customers' experience.** It's a clean browser on a fast
  network. Slow phones and flaky mobile networks are what real user
  monitoring is for; [synthetic vs real user monitoring](/blog/synthetic-vs-real-user-monitoring)
  explains how the two fit together.

## A checklist before you switch it on

- Every step ends with an assertion that proves it worked.
- No `waitForTimeout`, no `networkidle`.
- Selectors use roles, labels or test ids, not CSS paths.
- A product that's always in stock, picked by name.
- A dedicated test account, with the password in a secret store.
- The test stops before payment in production, or runs against staging
  in test mode.
- The monitor's user agent is allowed through bot protection and
  excluded from analytics.
- An alert goes to someone who can act on it.

If your sign-in also asks for a one-time code, the
[2FA login with Playwright](/blog/monitor-a-2fa-login-with-playwright) guide
adds that step to the same pattern. The
[checkout recipe](/docs/monitors/browser-checks/recipes#checkout) and the
[troubleshooting guide](/docs/monitors/browser-checks/troubleshooting) cover
the variations and errors you'll meet on a real shop.

---

Canonical page: https://watchfor.io/blog/monitor-checkout-flow-with-playwright · All articles: https://watchfor.io/blog/all.md · Site guide: https://watchfor.io/llms.txt
