Test Reality.
Control the Rest.
Playwright tests that hit your real server through a real browser. Your routes, middleware and business logic run for real; Scenarist scripts only the third-party APIs they call, per test.
So you can finally test the declined card, the expired Auth0 session and the 503—not just the happy path.
Browser tests also need @scenarist/playwright-helpers and @playwright/test as dev dependencies. Full install steps
Using an AI assistant? Start with /llms.txt or the AI Assistants guide.
Built for
A real browser. Your real server. Only the outside world is scripted.
Playwright clicks through your actual UI. Every request lands on your running app, where your code does the work. Scenarist sits on the server’s way out, answering third-party calls with the scenario each test chose.
- Test
Playwright drives a real browser
Pages, forms, cookies and redirects, exactly as your users get them.
click('Pay') - Runs for real
Your server does the work
Nothing in your app is mocked: no
next/headers, no fake sessions.- Server Components & routes
- Middleware & sessions
- Validation & business logic
- Scenarist
- Scripted per test
Third-party APIs
StripeAuth0SendGrid402 · card_declined
Never actually called.
Parallel by design. Each test sends its own x-scenarist-test-id, so dozens of tests run different scenarios against one server, with no restarts.
On the server, not in the browser. Scenarist scripts calls your backend makes. For calls the browser makes directly, such as Stripe.js, pair it with Playwright’s page.route(). How it works →
Declare a scenario. Switch to it in any test.
Scenario IDs are typed, so switchScenario autocompletes every one you’ve defined. Each test gets its own, even when they run in parallel.
export const scenarios = {
default: happyPath,
paymentDeclined: {
id: 'paymentDeclined',
name: 'Card declined',
description: 'Stripe rejects the charge',
mocks: [{
method: 'POST',
url: 'https://api.stripe.com/v1/charges',
response: {
status: 402,
body: { error: { code: 'card_declined' } },
},
}],
},
} as const satisfies ScenaristScenarios;test('declined card shows a friendly error', async ({
page, switchScenario,
}) => {
await switchScenario(page, 'paymentDeclined');
await page.goto('/checkout');
await page.getByLabel('Email').fill('ada@acme.test');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Payment failed')).toBeVisible();
});Your happy path is tested.
Now test everything else.
The bugs that reach production live in the scenarios real services won’t produce on cue. With Scenarist, each one is a few lines of declarative config—and your real backend handles it exactly as it would in production.
- Auth0409 · user_exists
switchScenario(page, 'emailAlreadyRegistered')Your signup route offers “Sign in instead” rather than a 500.
- Auth0401 · token_expired
switchScenario(page, 'sessionExpired')Your middleware redirects to login and keeps the cart intact.
- Stripe402 · card_declined
switchScenario(page, 'paymentDeclined')Your checkout shows a friendly retry, not a stack trace.
- Shipping API503 · unavailable
switchScenario(page, 'shippingServiceDown')Your UI disables Pay instead of charging for an order you can’t ship.
- Inventoryin stock → sold out
switchScenario(page, 'sellsOutDuringCheckout')A response sequence proves your code catches the race mid-checkout.
- Any async jobpending → pending → done
switchScenario(page, 'pollingUntilReady')Your poller waits, retries and finishes—no sleeps, no flake.
Dynamic scenarios.
Real backend logic.
Most mocks return one canned response. Scenarist mocks capture data, remember what happened, play out timelines and answer each request differently—all as declarative config, while your real server does the work. Sign-up flows, verification gates, launch-day races: the tests most teams never manage to automate.
Capture from the request. Echo it back.
Every test signs up with its own unique email. Scenarist captures it from the Auth0 Management API call and returns it in later responses—no hardcoded fixtures, and no orphaned users in your tenant.
{
method: 'POST',
url: 'https://acme.auth0.com/api/v2/users',
captureState: { email: 'body.email' },
response: {
status: 201,
body: {
user_id: 'auth0|e2e-user',
email: '{{state.email}}',
},
},
},
{
method: 'GET',
url: 'https://acme.auth0.com/api/v2/users/:id',
response: {
status: 200,
body: { email: '{{state.email}}' },
},
},How it plays out
- Playwrighttest · browser
fill('Email', 'e2e-7f3a@acme.test'), click Sign up
- Your signup routereal code
Validates the form, calls POST /api/v2/users
- Auth0 · scriptedscripted
201 · { email: 'e2e-7f3a@acme.test' }
state email = 'e2e-7f3a@acme.test'
- Your session logicreal code
Creates the session, loads the profile, redirects to /welcome
- Playwrighttest · browser
sees 'Welcome, e2e-7f3a@acme.test'
Mocks that react to what already happened.
One endpoint, different answers depending on the workflow so far. Script an unverified user, let the flow verify them, and watch your real middleware change its mind.
{
method: 'GET',
url: 'https://acme.auth0.com/userinfo',
stateResponse: {
default: { status: 200, body: { email_verified: false } },
conditions: [{
when: { verified: true },
then: { status: 200, body: { email_verified: true } },
}],
},
},
{
method: 'POST',
url: 'https://acme.auth0.com/api/v2/tickets/email-verification',
response: { status: 201, body: { ticket: 'https://…' } },
afterResponse: { setState: { verified: true } },
},How it plays out
- Playwrighttest · browser
goto('/dashboard')
- Your middlewarereal code
Checks the session, calls GET /userinfo
- Auth0 · scriptedscripted
200 · { email_verified: false }
- Your middlewarereal code
Blocks the dashboard, redirects to /verify
- Your verify routereal code
User clicks Verify, calls Auth0's tickets API
- Auth0 · scriptedscripted
201 · ticket issued
state verified = true
- Your middlewarereal code
/userinfo now says verified, lets them in
- Playwrighttest · browser
sees the dashboard
Script a timeline, not just a response.
The first call says in stock, the next says sold out. Reproduce the race that only ever happens on launch day—every run, in milliseconds.
{
method: 'GET',
url: 'https://inventory.acme.dev/stock/hoodie',
sequence: {
responses: [
{ status: 200, body: { quantity: 15 } },
{ status: 200, body: { quantity: 0 } },
],
repeat: 'last', // or 'cycle' | 'none'
},
},How it plays out
- Playwrighttest · browser
goto('/'), add the hoodie to the cart
- Your product pagereal code
Server Component calls GET /stock/hoodie
- Inventory · call 1scripted
200 · { quantity: 15 }
- Playwrighttest · browser
goes to checkout, clicks Pay
- Your checkout actionreal code
Re-checks GET /stock/hoodie before charging
- Inventory · call 2scripted
200 · { quantity: 0 }
- Your checkout actionreal code
Refuses to charge the card, returns sold out
- Playwrighttest · browser
sees 'Sold out during checkout'
Same endpoint. The right answer for each request.
Match on body, headers or query. A blocked user gets a 403 from Auth0 while every other login in the same scenario gets a token, against the same running server.
{
method: 'POST',
url: 'https://acme.auth0.com/oauth/token',
match: { body: { username: 'blocked@acme.test' } },
response: {
status: 403,
body: { error: 'unauthorized', error_description: 'user is blocked' },
},
},
{
method: 'POST',
url: 'https://acme.auth0.com/oauth/token',
response: {
status: 200,
body: { access_token: 'eyJhbGciOi…', expires_in: 86400 },
},
},How it plays out
- Playwrighttest · browser
logs in as 'blocked@acme.test'
- Your login routereal code
Calls POST /oauth/token
- Auth0 · scriptedscripted
403 · user is blocked (matched on body.username)
- Your login routereal code
Maps the error, sets no session, shows the support link
- Playwrighttest · browser
sees 'Your account is locked'
Mix them freely: capture state inside a sequence, match on the request and switch on state—see combining features. Every example above runs in parallel with every other test, each on its own isolated state.
Stop choosing between real code and real coverage
Unit tests can fake any scenario, but your server never runs. End-to-end tests run your server, but only on the happy path. Scenarist gives you both.
Your real backend code runs
- Scenarist
- Yes
- Unit tests + mocks
- No
- E2E against live services
- Yes
Any external scenario, on demand
- Scenarist
- Yes
- Unit tests + mocks
- Yes
- E2E against live services
- No
No mocking next/headers or req.session
- Scenarist
- Yes
- Unit tests + mocks
- No
- E2E against live services
- Yes
Deterministic, no shared sandbox state
- Scenarist
- Yes
- Unit tests + mocks
- Yes
- E2E against live services
- No
Parallel tests with isolated state
- Scenarist
- Yes
- Unit tests + mocks
- Yes
- E2E against live services
- Partly
No third-party credentials in CI
- Scenarist
- Yes
- Unit tests + mocks
- Yes
- E2E against live services
- No
Scripts calls made from the browser (e.g. Stripe.js)
- Scenarist
- No
- Unit tests + mocks
- Yes
- E2E against live services
- No
| Capability | Unit tests + mocks | E2E against live services | Scenarist |
|---|---|---|---|
| Your real backend code runs | No | Yes | Yes |
| Any external scenario, on demand | Yes | No | Yes |
| No mocking next/headers or req.session | No | Yes | Yes |
| Deterministic, no shared sandbox state | Yes | No | Yes |
| Parallel tests with isolated state | Yes | Partly | Yes |
| No third-party credentials in CI | Yes | No | Yes |
| Scripts calls made from the browser (e.g. Stripe.js) | Yes | No | No |
Comparing with MSW, Playwright route mocks, WireMock, Nock or Testcontainers? See the detailed comparisons →
Fits the stack you already have
- Next.js
App Router and Pages Router. Server Components, Server Actions and route handlers run for real.
Docs - Express
Routes, middleware and error handlers run for real, with the same scenarios and fixtures.
Docs - Playwright fixtures
switchScenario with typed scenario IDs, and a unique test ID per test for parallel runs.
Docs - 0 kB in production
Conditional exports swap in an empty module, so no test code ships to your users.
Docs
Real apps. Real auth. Real scenarios.
Scenario-Driven Auth0 Mocking in Playwright with Scenarist
Keeping the real UI, routes and server logic under test while scripting the Auth0 Management API—so test runs stop creating orphaned users in the tenant, and unique test data still flows through via captured state.
What the article covers, in the docs
Written about Scenarist? Share it with us on GitHub →