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.
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
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),
})
})
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
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()
}
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'
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
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
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
Playwright can then be configured to use that endpoint.
// playwright.config.ts
export default defineConfig({
use: {
scenaristEndpoint: '/api/scenario',
},
})
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',
},
},
},
],
},
}
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"
}
For user creation, it might include:
{
"user_id": "auth0|mock-user",
"email": "user@example.com",
"given_name": "Test",
"family_name": "User",
"identities": []
}
For organization creation:
{
"id": "org_mock_001",
"name": "mock-organization",
"display_name": "Mock Organization"
}
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"
and:
GET /api/v2/users
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',
}
A separate mock can handle the generic list request:
{
method: 'GET',
url: '/api/v2/users',
response: {
status: 200,
body: {
users: mockUsers,
},
},
}
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`
The application then sends:
{
"email": "e2e-123456@example.com"
}
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',
},
},
}
Now the mock behaves more like the real API.
If the application sends:
new-user-123@example.com
the response contains:
new-user-123@example.com
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'],
}
}
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(...)
The test context travels through the shared client configuration instead.
I prefer this over sprinkling logic like:
if (process.env.TEST_MODE) {
return fakeUser
}
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'
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...
})
That single line is the important part:
await switchScenario(page, 'default')
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
The application may then perform management operations:
Authenticated browser
↓
Application
↓
Auth0 Management SDK
↓
Scenarist
↓
Mocked Auth0 response
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
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
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: { ... },
}
Then an E2E test can say:
await switchScenario(page, 'auth0RateLimited')
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
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)