v0.4.15 Available

Test Reality.
Control the Rest.

A tour of Scenarist: a Playwright suite that only covers the happy path, then Scenarist scripting Auth0, Stripe and shipping API responses per test while the real server code runs. A Playwright test then drives a real browser through checkout: the request hits the real server's middleware, validation and totals, Scenarist answers Stripe with a scripted card decline, and the real error handling shows "Payment failed". Finally, six scenarios run in parallel against one server, and the features that make it work.

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.

Get Started

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.

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.

  1. Test

    Playwright drives a real browser

    Pages, forms, cookies and redirects, exactly as your users get them.

    click('Pay')
  2. 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
  3. Scenarist
  4. Scripted per test

    Third-party APIs

    StripeAuth0SendGrid

    402 · 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.

scenarios.tsPlain data. No functions, no framework mocks.
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;
checkout.spec.tsA real browser against your real server.
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.

scenarios/signup.ts
{
  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

testreal codescripted
  1. Playwrighttest · browser

    fill('Email', 'e2e-7f3a@acme.test'), click Sign up

  2. Your signup routereal code

    Validates the form, calls POST /api/v2/users

  3. Auth0 · scriptedscripted

    201 · { email: 'e2e-7f3a@acme.test' }

    state email = 'e2e-7f3a@acme.test'

  4. Your session logicreal code

    Creates the session, loads the profile, redirects to /welcome

  5. Playwrighttest · browser

    sees 'Welcome, e2e-7f3a@acme.test'

Stateful mocks docs →

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

Comparing with MSW, Playwright route mocks, WireMock, Nock or Testcontainers? See the detailed comparisons →