Skip to content
Chapter 90Lesson 1

The money-path filter

Keep Playwright end-to-end tests off by default on a Next.js SaaS, and write one only for paths where failure moves money, breaks identity, or loses unrecoverable data.

A feature ships, and at the bottom of the pull request there’s a new file under tests/e2e/. Nobody chose it; it went in by reflex, the test pyramid absorbed from a hundred blog posts: new flow, so write an end-to-end test that clicks through it.

Six months later the suite has eighty end-to-end tests, CI takes twenty-five minutes, and every Tuesday a third go red for no reason anyone can reproduce. The team learns the workaround: re-run until it’s green. The bug that actually shipped, the one that cost a weekend and an apology to customers, was covered by none of the eighty. And the lost minutes aren’t the real cost. Retry-until-green teaches the team to stop trusting a red build, so the safety net becomes the thing everyone routes around.

The fix is to invert the reflex. For a Next.js 16 SaaS in 2026, the end-to-end suite is off by default. You don’t write a Playwright test because a flow exists; you write one only when the flow crosses a single gate, the money-path filter. For everything outside it, you don’t write the test, or you delete the one already there.

This is “trigger before tool” again, the default-until-a-named-trigger shape from TanStack Query, Zustand, and React Testing Library, now aimed at the thin top of the suite:

Playwright is a money-path tool, not a coverage tool.

You’ll write no Playwright code here. You’ll leave with a five-second gate you run before you create a test file, one whose answer is almost always no.

The filter is one question you ask of a candidate path: does failing on it mean one of exactly three things?

One: money moves wrong. A charge fires twice. A plan downgrade silently fails and the customer keeps paying for a tier they cancelled. A refund never reaches the card. A user pays and doesn’t get the plan, or gets the plan without paying. Each one is a line item on someone’s statement and a support ticket with your name on it.

Two: identity breaks. Sign-in goes down in production, and every paying customer is locked out of the product they pay for. A subtler version: a session that doesn’t survive a redirect, so the user signs in, gets bounced through a third party, and lands back logged out.

Three: unrecoverable data is lost or exposed. An invitation grants access to the wrong organization, so in a multi-tenant app one customer reads another’s data. An export silently omits records the user is relying on. A delete the user can’t undo fires on the wrong row.

Money, identity, unrecoverable data. Everything else stays at the seam with an integration test, at the component with React Testing Library, or off the test menu entirely.

The filter’s job is subtractive: it isn’t a checklist for finding more paths to test, it keeps paths out of the end-to-end suite. Almost everything you point it at fails it, and that is the normal, correct outcome.

Why an E2E test costs a thousand times more

Section titled “Why an E2E test costs a thousand times more”

“Off by default” falls out of arithmetic. Two numbers drive it: what a test costs to run, and how often it flakes. Start with cost.

A unit test over a /lib helper runs in about five milliseconds. An integration test that hits a real Postgres and rolls the transaction back runs in twenty to eighty. A component test, booting jsdom and rendering a tree, runs in a hundred to three hundred. A Playwright test drives a real browser through real pages over a real network, so it runs in two to ten seconds, often more under CI’s thinner resources. That is three orders of magnitude over the unit test: a thousand times slower.

The bars below are not to scale, since a true unit-test bar would be a single pixel beside the E2E one, so each prints its real runtime.

Unit a /lib helper
~5 ms
Integration real Postgres, rolled back
20–80 ms
Component rendered in jsdom
100–300 ms
End-to-end a real browser, via Playwright
2–10 s ≈1000× the unit test
The cost of a test by tier, with the runtime printed on each bar (the bars are not to scale). An end-to-end test is the most expensive test you can write, by orders of magnitude.

One slow test does no damage; the suite does. A thirty-test Playwright suite run in parallel costs roughly two minutes in CI, worth paying on every pull request. A two-hundred-test suite is a twenty-minute pole the team starts skipping to ship, and a suite you skip is no suite at all. So the discipline lives in the count, not in any one test. The same way React Testing Library disciplined your component count one rung down, the money-path filter disciplines your end-to-end count here, and because each test costs a hundred times more, the ceiling on the count is a hundred times lower.

Cost is only half of why the bar is so high. The other half is flake. A flaky test passes on one run and fails on the next with nothing changed.

End-to-end tests flake more than any tier below them. A unit test is one function and its inputs, deterministic by construction. Each rung up the ladder adds a source of nondeterminism you don’t control: a real database, a network round-trip, a third-party UI like Stripe’s hosted checkout, real browser timing. At the top they compound.

This is where the retries: 3 anti-pattern is born: bumping the retry count to make CI green. A test that only passes on the third attempt is telling you the truth, that there’s a race where the result depends on which thing finishes first. Retrying doesn’t close that window, it mutes the signal, and your users will hit the race you muted.

The rule is one line: flake gets a structural fix, not a retry bump. That means better locators that find an element the way a user would instead of by a brittle CSS path, assertions that wait for the right state instead of a fixed sleep, and seed data that’s deterministic run to run. The next lesson shows how to do each, with the retry and trace config.

What only E2E can catch, and what it can’t

Section titled “What only E2E can catch, and what it can’t”

End-to-end does one thing nothing cheaper can, and the skill is holding both halves at once: the narrow job it owns, and the wide set of jobs people wrongly reach for it to do.

Every cheaper test sees one piece of your system in isolation: a function, a Server Action against the database, a component in a fake DOM. An end-to-end test sees the whole thing composed, routing into middleware into a Server Action into the database into the third party, then the HTML coming back, the JavaScript hydrating it, the cookie surviving the next navigation. Some bugs live in none of those pieces but in the seams between them, where only a browser driving the assembled app can reach.

So the positive rule is sharp: composition is the only justification for an end-to-end test. If the bug isn’t a composition bug, a cheaper test already owns it, faster and more reliably.

The trap is the mirror image: reaching for end-to-end to catch a bug that isn’t about composition. That is how the slow, flaky suite gets built, one well-intentioned test at a time. The line isn’t a matter of taste but of what each kind of test can physically reach.

Composition bugs. No isolated test reaches them, because the bug is in how the pieces fit together:

  • A redirect loop: sign-in sends you to the dashboard, which sends you back to sign-in.
  • A session that doesn’t survive the round-trip out to Stripe Checkout and back.
  • A webhook that flips the plan in the database just after the UI re-fetched, so the user sees the old plan.
  • A sign-in cookie that silently fails to carry across a cross-page navigation.
The bug is in how the full stack composes, so only a real browser driving the assembled app can see it.

Off by default does not mean never. The bar is high, but a real app clears it a knowable number of times, and that number is worth calibrating against.

A mid-stage SaaS has under thirty money paths total. Treat thirty as a ceiling you’d be surprised to reach, not a target to fill.

The first ten or so are universal, the paths every app on this stack shares: sign-in and sign-out, the checkout redirect to Stripe, the return and plan flip the user sees, invitation acceptance with its seat grant, password reset, and the one primary path that creates the thing customers pay for. The next ten are app-specific: the CSV export a customer upgraded to get, the report an auditor needs, whatever else sits directly on revenue.

Past thirty, you’ve drifted into coverage-chasing, testing flows because they exist rather than because failure costs money.

The next lesson walks four of these in detail. Here, just hold the shape: a short list, capped low, every entry on money.

A small team shipping fast on this stack is correct to ship its entire first year with zero Playwright tests, given the disciplined integration suite from last chapter and the production observability you’ll add later. Not behind, not cutting corners. Correct.

The risk is still covered, just not by end-to-end tests. The integration suite catches bugs at the seams, where they cluster. Production observability — error tracking and alerting — catches the unknown-unknowns nobody thought to test for. A human clicking through before a release catches the obvious rest. That stack covers a young app’s risk at a runtime cost the team can afford while it’s still finding product-market fit.

The trigger language tells the team when to start, not that they’re already behind. The day Stripe checkout ships, or sign-in becomes the only door to a paid product, a money-path trigger fires, and then you reach for Playwright, for that path, with intent.

The trajectory is year-one zero, year-two a small handful — sign-in and Stripe checkout first, the two highest-stakes paths — adding others as the team outgrows verifying them by hand each release. So the usual note on a first Playwright pull request is fewer tests, better chosen, not more.

When you reach for Playwright, it must drive a production build of your app, never the dev server.

next dev runs different code than the app your users get. Dev mode skips static optimization, injects dev-only error overlays, behaves differently in middleware, and serves unminified, un-bundled hydration. None of that exists in production. A test that passes against next dev asserts on output your users never see, and that gap is exactly where the bug hides.

So from your first end-to-end test, Playwright builds, starts, and drives the production app. One field in Playwright’s config wires it up:

playwright.config.ts — the one line that matters here
webServer: {
command: 'pnpm build && pnpm start', // production build, never `next dev`
url: 'http://localhost:3000',
},

That command runs the same two commands you’d run to deploy.

The four checks form one ordered procedure you run before writing the test. Ask the cheap disqualifiers first, so most candidates stop on question one or two and never reach the expensive judgment call.

  1. Is this a money path under the filter? Failure means money moves wrong, identity breaks, or unrecoverable data is lost or exposed. Usually the answer is no; stop there.
  2. Do an integration test and a component test already compose to catch the same bug? Composition is the only justification: if the cheaper layers cover it between them, the browser adds cost and flake, not coverage. Stop if yes.
  3. Can Playwright actually drive the third party this path crosses? Think Stripe Checkout in test mode, or an OAuth provider with a test account. If no, write the seam test to assert the contract instead.
  4. Will the test be deterministic without sleeps? If no, fix the app or the test first: a test that needs a sleep is hiding a race.

Walk the gate below for a real candidate. Answer each question and watch where it drops you.

Before you write this end-to-end test

You rarely reach the last node, and that is the gate working as intended: its job is to turn candidates away.

Sort the paths: which earn a Playwright test?

Section titled “Sort the paths: which earn a Playwright test?”

Reading the filter is one skill; applying it under review is the one that matters. Some paths below are clean money paths that need the whole stack aligned, and some are tempting picks a cheaper layer already covers.

Each item is a path in a typical 2026 SaaS. Does it cross the money-path filter and need the full composition — or does a cheaper layer already own it? Drag each item into the bucket it belongs to, then press Check.

Write a Playwright test A money path, catchable only in the full composition
Don't — a cheaper layer owns it No money-path trigger, or a cheaper test already covers it
Sign-in to a paid dashboard with email and password
The Stripe Checkout redirect and the plan flip the user sees on return
Accepting an org invitation and landing in the right org with the right role
Create an invoice, the recipient pays, the invoice flips to paid in the UI
A Zod validation branch — the error that shows on an empty email field
A Server Action writing a new row to Postgres
Webhook signature verification rejecting an unsigned payload
The settings page rendering the signed-in user’s name
The marketing landing page rendering for a logged-out visitor
A button exposing the right accessible name to a screen reader

If a “skip” item tempted you, look at where the bug would live. A validation branch, a database write, a signature check, an accessible name: each sits inside one layer, with a faster, steadier test already watching it. The four “reach” items need every layer aligned for the customer to get what they paid for. Those are the money paths.

When does a Playwright test earn its cost?

Section titled “When does a Playwright test earn its cost?”

Each claim is about when a Playwright test earns its runtime cost on a 2026 Next.js SaaS. Mark each statement True or False.

For a 2026 Next.js SaaS, end-to-end tests are on by default, and you delete the ones that don’t earn their weight.

Inverted. They’re off by default — you add one only when the money-path filter fires, not subtract from a default-on suite.

A small SaaS with disciplined integration tests and production observability can correctly ship its first year with zero Playwright tests.

Year-one zero is the honest default. The integration suite covers the seam, observability covers the unknowns, and the trigger language tells the team when to start — not that they’re behind.

Running end-to-end tests against next dev is fine, and it’s faster than building the production app.

Dev mode runs different code paths. A pass against next dev can be a fail against the real build — the mismatch is exactly where the bug ships. Playwright must drive a production build.

When an end-to-end test flakes intermittently, bumping retries to 3 to keep CI green is the right fix.

It hides a real race. The flaky test is telling you something true about your app; a retry bump mutes the alarm. Flake gets a structural fix — better locators, auto-waiting assertions, deterministic data.

The only thing that justifies an end-to-end test over cheaper tests is a bug that lives in the composition of the full stack.

If an integration test and a component test compose to catch it, the browser adds cost and flake, not coverage. Composition is the one thing only a browser-driven test can see.

Playwright’s own best-practices guide is the reference for the rest of this chapter; read it before the mechanics arrive next lesson. The other two sharpen this lesson’s claim: a test strategy that puts end-to-end at the thin top, aimed only at the few paths that matter.