DEV Community

Lokesh Kumar V
Lokesh Kumar V

Posted on

Scenario-Driven Auth0 Mocking in Playwright with Scenarist

End-to-end tests are most useful when they exercise the application as close to the real user flow as possible.

But there is always a question of where to draw the boundary.

For authentication-heavy applications, calling a real identity provider from every E2E test can quickly become a problem. Tests start depending on external services, shared test tenants accumulate data, user creation needs cleanup, rate limits become relevant, and reproducing edge cases gets harder.

I ran into this while working with Playwright and Auth0.

I still wanted Playwright to exercise the real application: the UI, Next.js routes, server-side logic, and authentication session. What I didn't need was every test creating users, organizations, password-reset tickets, and memberships in a real Auth0 tenant.

Scenarist playwright

The approach I ended up using was Scenarist.

Instead of mocking Auth0 inside the browser, Scenarist runs alongside the application and intercepts the external API calls made by the application itself.

The resulting architecture looks roughly like this:

Playwright
    |
    | select scenario
    v
Next.js application
    |
    | Auth0 Management SDK request
    v
Scenarist
    |
    | mocked response
    v
Application
    |
    v
UI observed by Playwright
Enter fullscreen mode Exit fullscreen mode

This gives the E2E test control over Auth0 responses without replacing the application code that consumes them.

Why not just mock Auth0 from Playwright?

Playwright already has excellent network interception capabilities.

For many applications, something like this is enough:

await page.route('**/api/users', async route => {
  await route.fulfill({
    status: 200,
    body: JSON.stringify(mockUsers),
  })
})
Enter fullscreen mode Exit fullscreen mode

But there is an important limitation.

page.route() operates on requests made by the browser.

That doesn't necessarily help when your architecture looks like this:

Browser
   |
   v
Next.js server
   |
   v
Auth0 Management SDK
   |
   v
Auth0 Management API
Enter fullscreen mode Exit fullscreen mode

The browser never makes the Auth0 Management API request directly.

The server does.

That means I wanted the mocking boundary to sit closer to the server-side integration rather than inside the browser.

That's where Scenarist fit nicely.

Setting up Scenarist in a Next.js application

The first step is creating the Scenarist instance inside the application.

A simplified version looks like this:

// src\lib\scenarist\index.ts
import { createScenarist } from '@scenarist/nextjs-adapter/app'
import 'server-only'

import { scenarios } from './scenarios'

export const scenarist = createScenarist({
  enabled:
    process.env.NODE_ENV !== 'production' &&
    process.env.TEST_MODE === 'true',

  scenarios,
})

if (globalThis.window === undefined && scenarist) {
  scenarist.start()
}
Enter fullscreen mode Exit fullscreen mode

Don't enable your mock infrastructure in production

The mock layer should be explicitly restricted to test environments.

enabled:
  process.env.NODE_ENV !== 'production' &&
  process.env.TEST_MODE === 'true'
Enter fullscreen mode Exit fullscreen mode

In the implementation this article is based on, Scenarist is configured with exactly those conditions and started only on the server.

Keep it server-side

The second guard:

globalThis.window === undefined
Enter fullscreen mode Exit fullscreen mode

ensures that Scenarist starts only on the server.

That matters because the API we're interested in mocking isn't browser traffic. It's traffic originating from the server-side application.


Creating the scenario endpoint

Playwright also needs some way to tell the application:

"For this test, use this scenario."

For that, the application can expose a dedicated endpoint such as:

/api/scenario
Enter fullscreen mode Exit fullscreen mode

The endpoint delegates scenario handling to Scenarist.

Conceptually:

// src\app\api\scenario\route.ts
import { scenarist } from 'lib/scenarist'
const scenarioEndpoint = scenarist?.createScenarioEndpoint()

export const GET = scenarioEndpoint
export const POST = scenarioEndpoint
Enter fullscreen mode Exit fullscreen mode

Playwright can then be configured to use that endpoint.

// playwright.config.ts
export default defineConfig({
  use: {
    scenaristEndpoint: '/api/scenario',
  },
})
Enter fullscreen mode Exit fullscreen mode

One subtle detail is worth checking if your application protects routes through authentication middleware.

The scenario endpoint needs to be reachable by the test runner.

If your middleware automatically redirects every unauthenticated /api/* request into the login flow, /api/scenario may need to be excluded from that matcher.

Otherwise, the mechanism controlling your mocks can itself get blocked by authentication.


Defining an Auth0 scenario

Now we can describe the Auth0 behavior our application expects.

A scenario is essentially a collection of request/response definitions.

For example:

export const scenarios = {
  default: {
    id: 'default',
    name: 'Default',
    description: 'Default Auth0 responses for E2E tests',

    mocks: [
      {
        method: 'POST',
        url: '/oauth/token',

        response: {
          status: 200,
          headers: {
            'content-type': 'application/json',
          },
          body: {
            access_token: 'mock-management-token',
            expires_in: 86400,
            token_type: 'Bearer',
          },
        },
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

When the application requests an Auth0 Management API token, Scenarist can return the response the SDK expects instead of allowing the test to depend on the external service.

The important part isn't returning some JSON.

The mock should reproduce enough of the real API contract for the application to follow its normal code path.

For a token response, that might mean:

{
  "access_token": "mock-management-token",
  "expires_in": 86400,
  "token_type": "Bearer"
}
Enter fullscreen mode Exit fullscreen mode

For user creation, it might include:

{
  "user_id": "auth0|mock-user",
  "email": "user@example.com",
  "given_name": "Test",
  "family_name": "User",
  "identities": []
}
Enter fullscreen mode Exit fullscreen mode

For organization creation:

{
  "id": "org_mock_001",
  "name": "mock-organization",
  "display_name": "Mock Organization"
}
Enter fullscreen mode Exit fullscreen mode

The scenario behind this article follows that approach for token exchange, password-change tickets, organizations, user queries, user creation and organization membership operations.

Matching similar Auth0 requests

Things become more interesting when multiple application operations hit the same endpoint.

Auth0's users endpoint is a good example.

Imagine that the application performs both:

GET /api/v2/users?q=email:"someone@example.com"
Enter fullscreen mode Exit fullscreen mode

and:

GET /api/v2/users
Enter fullscreen mode Exit fullscreen mode

The URL is the same, but these requests mean different things.

One is searching for a particular user.

The other is listing users.

We can distinguish them using query matching.

{
  method: 'GET',

  match: {
    query: {
      q: {
        startsWith: 'email:"',
      },
    },
  },

  response: {
    status: 200,
    body: {
      length: 0,
      limit: 50,
      start: 0,
      total: 0,
      users: [],
    },
  },

  url: '/api/v2/users',
}
Enter fullscreen mode Exit fullscreen mode

A separate mock can handle the generic list request:

{
  method: 'GET',
  url: '/api/v2/users',

  response: {
    status: 200,
    body: {
      users: mockUsers,
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

This lets the mock layer behave differently based on how the application actually uses the API.

The implementation that inspired this example uses this exact distinction: an email query receives an empty search result, while a generic users request receives a predefined user list.


Stateful mocks are where this gets interesting

Static responses work well until the application sends dynamic data.

User creation is a good example.

A Playwright test might generate a unique email:

const email = `e2e-${Date.now()}@example.com`
Enter fullscreen mode Exit fullscreen mode

The application then sends:

{
  "email": "e2e-123456@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Returning a hard-coded email from the mock isn't ideal.

The application sent one value but received a completely different one.

Instead, Scenarist can capture part of the incoming request.

{
  method: 'POST',
  url: '/api/v2/users',

  captureState: {
    mockUserEmail: 'body.email',
  },

  response: {
    status: 201,

    body: {
      user_id: 'auth0|mock-user',
      email: '{{state.mockUserEmail}}',
      given_name: 'Test',
      family_name: 'User',
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Now the mock behaves more like the real API.

If the application sends:

new-user-123@example.com
Enter fullscreen mode Exit fullscreen mode

the response contains:

new-user-123@example.com
Enter fullscreen mode Exit fullscreen mode

without the test needing to know anything about how the server makes that request.

This was one of the parts of the setup I found particularly useful.

The test owns the input data, while the mocked external service responds consistently with that data.

The underlying scenario uses captureState to capture body.email and interpolate it back into the mocked user-creation response.


Connecting the active scenario to Auth0 requests

Defining mocks isn't enough.

Scenarist also needs to know which test a server-side request belongs to.

The pattern I used was to propagate the Scenarist test identifier through the application's Auth0 Management client.

Conceptually:

// src\lib\auth0-clients.ts
headers: async () => {
  const requestHeaders = await headers()

  const scenaristHeaders =
    getScenaristHeadersFromReadonlyHeaders(requestHeaders)

  return {
    'x-scenarist-test-id':
      scenaristHeaders['x-scenarist-test-id'],
  }
}
Enter fullscreen mode Exit fullscreen mode

The important part here is that the header is resolved for each request.

That means individual Auth0 call sites don't need to know about Scenarist.

Application code can continue doing what it normally does:

await managementClient.users.create(...)
                or
await managementClient.organizations.create(...)
Enter fullscreen mode Exit fullscreen mode

The test context travels through the shared client configuration instead.

I prefer this over sprinkling logic like:

if (process.env.TEST_MODE) {
  return fakeUser
}
Enter fullscreen mode Exit fullscreen mode

The application code shouldn't need a test-specific branch every time it talks to Auth0.


Using Scenarist from Playwright

On the Playwright side, Scenarist provides a fixture helper.

The shared fixture can be very small:

//playwright\tests\scenarist-fixtures.ts
import { withScenarios } from '@scenarist/playwright-helpers'
import { scenarios } from './scenarios'

export const test = withScenarios(scenarios)

export { expect } from '@scenarist/playwright-helpers'
Enter fullscreen mode Exit fullscreen mode

That's also essentially the complete fixture used by the implementation behind this article.

Tests can then import this version of test instead of importing directly from @playwright/test.

For example:

import { test, expect } from './scenarist-fixtures'

test('creates a user', async ({
  page,
  switchScenario,
}) => {
  await switchScenario(page, 'default')

  await page.goto('/users')

  // Continue interacting with the application...
})
Enter fullscreen mode Exit fullscreen mode

That single line is the important part:

await switchScenario(page, 'default')
Enter fullscreen mode Exit fullscreen mode

The test isn't configuring individual network routes.

It is selecting an application scenario.

From there, server-side calls made during the test inherit the selected scenario.


Keep login and Management API mocking separate

One design decision that worked well for this setup was not trying to fake everything.

Authentication and Auth0 Management API operations solve two different problems.

The login flow establishes the browser session:

Browser
   ↓
Auth0 Login
   ↓
Authenticated session
   ↓
Saved Playwright storage state
Enter fullscreen mode Exit fullscreen mode

The application may then perform management operations:

Authenticated browser
   ↓
Application
   ↓
Auth0 Management SDK
   ↓
Scenarist
   ↓
Mocked Auth0 response
Enter fullscreen mode Exit fullscreen mode

The application still runs with a real authenticated browser session.

But operations such as creating users, searching users, generating password-reset tickets, or changing organization memberships don't need to modify a shared Auth0 tenant on every E2E run.


What should actually be mocked?

I wouldn't start by mocking every Auth0 endpoint.

Start with the external calls made by the workflow you're testing.

For a user-management workflow, that might be:

POST /oauth/token

GET  /api/v2/users

POST /api/v2/users

POST /api/v2/tickets/password-change

POST /api/v2/organizations

POST /api/v2/organizations/:id/members
Enter fullscreen mode Exit fullscreen mode

Then make each response realistic enough for the application code consuming it.

This is better than creating an enormous fake Auth0 implementation that your tests don't actually need.


Why I prefer this approach for E2E tests

After implementing this pattern, a few benefits stood out.

1. The UI still exercises the real application

Playwright isn't testing a special test-only page.

It drives the same UI and application routes used normally.

2. Server-side behavior stays in the test

This was important to me.

If clicking Create User triggers:

UI
 → Server Action
 → Application Service
 → Auth0 Management SDK
Enter fullscreen mode Exit fullscreen mode

I want the E2E test to exercise everything up to that external boundary.

Mocking at the Auth0 boundary allows exactly that.

3. Tests don't mutate the Auth0 tenant

Creating users and organizations in a real tenant during every test run creates cleanup problems surprisingly quickly.

With mocks, the workflow can still be exercised without leaving external state behind.

4. Responses are deterministic

If the application asks for a user list, I know exactly what comes back.

If it searches for an email, I can decide whether that user exists.

If it creates a user, I can return the exact response shape required for the next step.

5. Dynamic test data still works

State capture means the mocks don't have to be completely static.

A test can generate unique input and have that data flow through the mocked external API naturally.


The bigger opportunity: scenarios

The example in this article uses a single default scenario, but this architecture becomes more valuable when scenarios represent meaningful external states.

For example:

const scenarios = {
  default: { ... },

  userAlreadyExists: { ... },

  auth0RateLimited: { ... },

  tokenRequestFails: { ... },

  organizationCreationFails: { ... },
}
Enter fullscreen mode Exit fullscreen mode

Then an E2E test can say:

await switchScenario(page, 'auth0RateLimited')
Enter fullscreen mode Exit fullscreen mode

instead of manually configuring five different intercepted requests.

That's an interesting shift in how tests are expressed.

Rather than saying:

"When this URL is requested, return HTTP 429."

the test can say:

"Put the external system into the rate-limited scenario."

The infrastructure described here is ready for that style, although the implementation I started with currently registers only a default scenario. The scenario catalog itself confirms that the registered scenario contains multiple Auth0-related mocks under a single default definition.


Final thoughts

There are many ways to mock external services in E2E tests.

For browser-originated APIs, Playwright's built-in network interception is often all you need.

But when the application makes important external calls on the server, the mocking strategy needs to account for that architecture.

Using Scenarist inside a Next.js application gave me a useful separation:

Real browser interaction
        +
Real application routes
        +
Real server-side application logic
        +
Controlled external API responses
Enter fullscreen mode Exit fullscreen mode

Authentication can still establish a genuine browser session, while Auth0 Management operations are handled through predictable scenarios.

For me, that's the useful part of this pattern.

It isn't about mocking Auth0 just to make the tests pass.

It's about choosing a clear external boundary, keeping everything inside that boundary real, and making everything outside it deterministic.

And once that boundary is scenario-driven, testing failures and edge cases becomes much easier than trying to manufacture those conditions in a real external tenant.

Top comments (0)