DEV Community

Balaji K
Balaji K

Posted on

Scaling Playwright E2E Tests with a Role-Aware Mockserver

Table of Contents:

  1. A real working implementation with admin, viewer, and VIP UI flows
  2. What We Are Building
  3. Role Behavior in the UI
  4. Mockserver: Auth-Style Backend Stub
  5. Playwright Adds Role Headers to Mockserver Requests
  6. Mockserver Signs Roles Into the ID Token
  7. The App Uses the Token to Load Role-Specific Workspace Data
  8. The Protected Workspace Endpoint Verifies the Token
  9. Feature Flags Use the Same Header Pattern
  10. Backend Error Scenarios Are Also Header-Driven
  11. Playwright Projects Run the Role Matrix
  12. The Role UI Test
  13. Positive and Negative Role Checks
  14. Multiple Roles Are Supported
  15. Running the Project
  16. Why This Scales
  17. Lessons Learned
  18. Conclusion
  19. Credits

A real working implementation with admin, viewer, and VIP UI flows:

End-to-end tests usually start with a simple flow: open the app, log in, perform an action, assert the result.

That works until the application starts behaving differently based on the logged-in user's role.
For example:

  • An admin can manage users, review audit logs, and perform security overrides.
  • A viewer can search profiles and view reports, but cannot mutate anything.
  • A vip support agent can access priority handling tools and concierge notes.

If those scenarios depend on real identity providers, real test users, seeded permissions, and live backend state, the suite becomes slow and fragile. The tests are no longer only testing the UI. They are also depending on auth availability, user setup, backend data, and environment stability.

In a scalable automation project, the answer is not to push every scenario into full E2E. The better approach is to decide which layer should own which type of confidence.

For role-based testing, I usually think about the strategy like this:

That separation is important. The mocked E2E layer gives fast and deterministic coverage for role behavior. Integration and smoke tests prove that the real environment still connects correctly. They should complement each other instead of competing to test everything at the most expensive layer.

The architecture below focuses on the mocked Playwright E2E layer. The goal is to make role-based UI scenarios fast, repeatable, and explicit, while still leaving space for integration and smoke tests elsewhere in the delivery pipeline.

There is also an important evolution here.
In the initial version of the framework, Playwright route interception was used against real integration tests. The tests still navigated through the real application environment, but intercepted selected network calls and modified requests or responses to force edge cases.

That approach is useful when starting out, especially for one-off scenarios. But as the role matrix grows, it can become flaky in CI/CD because the test is still coupled to real auth, real backend availability, seeded users, changing data, and environment timing.
The more scalable version moves that responsibility into a mockserver.

So in the final mockserver setup, Playwright still uses page.route(), but only to continue requests to the local mockserver with test headers. It does not directly fulfill the production-like API response. The mockserver owns the actual auth token, config, workspace, role, feature flag, and error responses.

I also keep one comparison test in the sample project for the earlier style:

playwright/tests/legacy-network-interception.spec.ts

That test directly fulfills the UI config, token, and workspace API calls from Playwright. It is useful for explaining the migration path, but it is not the preferred scalable pattern for the role matrix.

The implementation below solves that by using:

  • a local application,
  • a Koa-based backend mockserver,
  • a mocked auth/token flow,
  • signed JWT id_token roles,
  • Playwright route continuation to add test headers,
  • and role-specific UI assertions.

This is not just a concept. The project runs locally and the Playwright suite verifies admin, viewer, and vip roles with different UI functionality.

The complete working example is available here:
https://github.com/balajiregt/plawright-role-aware-mockserver


Local Demo vs Real Stubbed Functional Environment

The GitHub project uses a local mockserver so the full idea can be cloned, run, and understood without any company infrastructure.

In a real project, this pattern usually lives in a dedicated stubbed functional environment. That environment is deployed and maintained like any other test environment, often with solution/platform/SRE involvement. The frontend points to a controlled BFF or mockserver layer, and the mockserver serves deterministic stubbed responses instead of depending on live downstream systems.
The important difference is this:

That deployed stubbed functional environment is what makes the approach practical at scale. The local repo is a simplified, public version of the same testing idea.


What We Are Building

The project has three parts:

real-role-auth-mockserver-project/
├── app/
│   ├── server.mjs
│   └── public/
│       ├── index.html
│       ├── app.js
│       └── styles.css
│
├── mockserver/
│   └── src/
│       ├── mock-server.js
│       └── backend/
│           ├── index.js
│           ├── utils.js
│           └── endpoints/
│               ├── auth/
│               │   ├── token.js
│               │   ├── jwks.js
│               │   ├── authorize.js
│               │   └── well-known.js
│               └── v1/
│                   ├── ui-config.js
│                   └── workspace.js
│
└── playwright/
    ├── playwright.config.ts
    ├── helpers/
    │   └── api-mock.ts
    ├── pages/
    │   ├── operations-console.page.ts
    │   └── operations-console.assertions.ts
    └── tests/
        ├── role-ui.spec.ts
        ├── feature-flags.spec.ts
        └── legacy-network-interception.spec.ts
Enter fullscreen mode Exit fullscreen mode

The local app does not hardcode the role. It calls the mockserver just like a real frontend would:

  • Load UI config from /bff/v1/ui-config.
  • Request a token from /bff/auth/token.
  • Decode the returned id_token.
  • Send that token to /bff/v1/workspace.
  • Render role-specific actions from the protected workspace response.

Playwright changes the role by continuing the token request with an agent-roles header.

The mockserver then owns the behavior: it reads that header, signs those roles into the id_token, and returns the role-aware workspace response.

That is the key boundary.


Role Behavior in the UI

The app has three roles, each with unique UI functionality.

| Role   | Workspace                  | Visible actions |
| :---   | :--------                  | :-------------- |
| admin  | Admin Operations Workspace | User Admin Console, Security  Override, Audit Log |
| viewer | Read-Only Viewer Workspace | Profile Search, Read-Only Dashboard, View Reports |
| vip    | VIP Support Workspace      | VIP Support Desk, Priority Override, Concierge Notes |
Enter fullscreen mode Exit fullscreen mode

Manual browser usage defaults to viewer, because no Playwright test is injecting roles.

Playwright drives the full role matrix.


Mockserver: Auth-Style Backend Stub

The mockserver is a Koa app.

import Koa from 'koa';
import Router from '@koa/router';
import mount from 'koa-mount';
import cors from '@koa/cors';
import bodyParser from '@koa/bodyparser';
import { generateKeyPairSync } from 'node:crypto';
import bffRoutes from './backend/index.js';

export const { privateKey, publicKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
});

export const issuerUri = 'http://127.0.0.1:3333/bff/auth';

const app = new Koa();
const router = new Router();

app.use(cors({
  origin: 'http://127.0.0.1:5173',
  credentials: true,
  allowHeaders: [
    'content-type',
    'authorization',
    'agent-roles',
    'override-feature-flags',
    'downstream-error',
  ],
}));

app.use(bodyParser());

router.get('/', (ctx) => {
  ctx.body = 'BFF mock server';
});

app.use(router.routes()).use(router.allowedMethods());
app.use(mount('/bff', bffRoutes()));
Enter fullscreen mode Exit fullscreen mode

The backend router exposes auth, config, and workspace endpoints.

router.get(/^\/auth\/authorize/, authorize);
router.post(/^\/auth\/token/, token);
router.get('/auth/jwks', jwks);
router.get(/^\/auth\/(.*)v2.0\/\.well-known\/openid-configuration$/, wellKnown);

router.get('/v1/ui-config', uiConfig);
router.get('/v1/workspace', workspace);
Enter fullscreen mode Exit fullscreen mode

This gives the local app a realistic backend shape:

  • token endpoint,
  • JWKS endpoint,
  • well-known configuration endpoint,
  • UI config endpoint,
  • protected workspace endpoint.

Playwright Adds Role Headers to Mockserver Requests

The role helper is deliberately small. It continues the token request and adds the role header that only the mockserver understands.

import { Page } from '@playwright/test';

export type AgentRole = 'admin' | 'viewer' | 'vip';

export async function mockAgentRoles(page: Page, roles: AgentRole[]) {
  await page.route('**/auth/token**', async (route, request) => {
    await route.continue({
      headers: {
        ...request.headers(),
        'agent-roles': roles.join(','),
      },
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

This mirrors the stabilized pattern used in the real framework: the test does not directly modify the DOM, local storage, frontend state, or fulfill production-like integration responses. It only adds test metadata to requests that are already going to the local mockserver.

A test can say:


Mockserver Signs Roles Into the ID Token

The token endpoint reads the agent-roles header.

const roles = parseCsvHeader(ctx.get('agent-roles'));
const normalizedRoles = roles.length ? roles : ['viewer'];
Enter fullscreen mode Exit fullscreen mode

Then it places those roles into the ID token payload.

const idTokenPayload = {
  sub: 'local-demo-user',
  aud: 'identity-manager-frontend',
  iss: issuerUri,
  name: 'Role Demo User',
  preferred_username: 'role.demo@example.test',
  iat: now,
  exp: now + 3600,
  roles: normalizedRoles,
};
Enter fullscreen mode Exit fullscreen mode

Finally, it signs the token using the mockserver’s private key.

ctx.body = {
  token_type: 'Bearer',
  expires_in: 3600,
  access_token: jwt.sign(accessTokenPayload, privateKey, signOptions),
  refresh_token: ctx.request.body?.refresh_token || 'local-refresh-token',
  id_token: jwt.sign(idTokenPayload, privateKey, signOptions),
};
Enter fullscreen mode Exit fullscreen mode

This is what makes the demo realistic. The frontend is not receiving a fake role variable from Playwright. It is receiving a signed token from the mockserver.


The App Uses the Token to Load Role-Specific Workspace Data

The frontend calls /auth/token, decodes the returned id_token, then calls the protected workspace endpoint with that token.

const tokenResponse = await getJson('/auth/token?client-request-id=role-demo', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    scope: 'openid profile',
    refresh_token: 'local-demo-refresh-token',
  }),
});

const tokenPayload = decodeJwtPayload(tokenResponse.id_token);

const workspace = await getJson('/v1/workspace', {
  headers: {
    authorization: `Bearer ${tokenResponse.id_token}`,
  },
});
Enter fullscreen mode Exit fullscreen mode

The app renders what the workspace endpoint returns.

elements.roleList.textContent = roles.join(', ');
elements.workspaceTitle.textContent = workspace.title;
elements.workspaceSummary.textContent = workspace.summary;
elements.actionCount.textContent = `${workspace.actions.length} actions`;
Enter fullscreen mode Exit fullscreen mode

Each action becomes a real UI card.

for (const action of workspace.actions) {
  const card = document.createElement('article');
  card.dataset.testid = `action-${action.id}`;
  card.innerHTML = `
    <span>${action.category}</span>
    <h3>${action.label}</h3>
    <p>${action.description}</p>
  `;
  elements.actionGrid.append(card);
}
Enter fullscreen mode Exit fullscreen mode

So the UI is genuinely different for admin, viewer, and vip.


The Protected Workspace Endpoint Verifies the Token

The workspace endpoint does not blindly trust a role header. It reads and verifies the bearer token

const token = tokenFromAuthHeader(ctx);

if (!token) {
  ctx.status = 401;
  ctx.body = { message: 'Missing bearer token' };
  return;
}
Enter fullscreen mode Exit fullscreen mode

Then it verifies the JWT using the public key.

const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  audience: 'identity-manager-frontend',
});
Enter fullscreen mode Exit fullscreen mode

The endpoint selects the primary role and returns the matching workspace.

const roles = Array.isArray(payload.roles) ? payload.roles : ['viewer'];
const primaryRole = selectedPrimaryRole(roles);

ctx.body = {
  primaryRole,
  roles,
  ...actionCatalog[primaryRole],
};
Enter fullscreen mode Exit fullscreen mode

The role precedence is explicit:

export function selectedPrimaryRole(roles) {
  if (roles.includes('admin')) return 'admin';
  if (roles.includes('vip')) return 'vip';
  return 'viewer';
}
Enter fullscreen mode Exit fullscreen mode

This also supports multiple-role users.


Feature Flags Use the Same Header Pattern

Roles are not the only thing that changes UI behavior. Feature flags can be controlled through the same mockserver header pattern.

export async function setFeatureFlags(
  page: Page,
  featureFlagSetup: FeatureFlagSetup,
) {
  await page.route('**/v1/ui-config**', async (route, request) => {
    await route.continue({
      headers: {
        ...request.headers(),
        'override-feature-flags': JSON.stringify(featureFlagSetup),
      },
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

The mockserver returns either default flags or the override.

const overrideFeatureFlags = ctx.get('override-feature-flags');

ctx.body = {
  azure: {
    issuerUri: 'http://127.0.0.1:3333/bff/auth',
    clientId: 'local-demo-client',
    apiScopes: ['openid', 'profile'],
  },
  featureFlags: overrideFeatureFlags
    ? JSON.parse(overrideFeatureFlags).featureFlags
    : defaultFeatureFlags,
};
Enter fullscreen mode Exit fullscreen mode

This makes feature-gated UI easy to test without changing application code.


Backend Error Scenarios Are Also Header-Driven

The same pattern can force downstream failures.

export async function mockDownstreamServerError(page: Page) {
  await page.route('**/bff/v1/workspace**', async (route, request) => {
    await route.continue({
      headers: {
        ...request.headers(),
        'downstream-error': 'true',
      },
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

The mockserver turns that into a deterministic backend failure.

const downstreamError = ctx.get('downstream-error') === 'true';

if (downstreamError) {
  ctx.status = 503;
  ctx.body = {
    message: 'Mocked downstream workspace service failure',
  };
  return;
}
Enter fullscreen mode Exit fullscreen mode

This is useful for error handling tests that are painful to reproduce against live systems.


Playwright Projects Run the Role Matrix

The Playwright config defines three projects.

projects: [
  {
    name: 'admin',
    use: { ...devices['Desktop Chrome'] },
    metadata: { roles: ['admin'] },
  },
  {
    name: 'viewer',
    use: { ...devices['Desktop Chrome'] },
    metadata: { roles: ['viewer'] },
  },
  {
    name: 'vip',
    use: { ...devices['Desktop Chrome'] },
    metadata: { roles: ['vip'] },
  },
],
Enter fullscreen mode Exit fullscreen mode

The config also starts both servers before the tests run.

webServer: [
  {
    command: 'npm run start:mockserver',
    url: 'http://127.0.0.1:3333',
    reuseExistingServer: !process.env.CI,
  },
  {
    command: 'npm run start:app',
    url: 'http://127.0.0.1:5173',
    reuseExistingServer: !process.env.CI,
  },
],
Enter fullscreen mode Exit fullscreen mode

That means the test command is enough:

npm run test:e2e
Enter fullscreen mode Exit fullscreen mode


The Role UI Test

The main test follows the same structure I use in larger Playwright frameworks:

  • keep page operations in page objects,
  • keep assertions in assertion helpers,
  • use test.step to make the business flow readable,
  • test both allowed and restricted role behavior.

The expectations stay close to the product behavior

const expectations = {
  admin: {
    title: 'Admin Operations Workspace',
    visible: ['admin-console', 'security-override', 'audit-log'],
    hidden: ['profile-search', 'vip-desk', 'concierge-notes'],
  },
  viewer: {
    title: 'Read-Only Viewer Workspace',
    visible: ['profile-search', 'read-only-dashboard', 'reports'],
    hidden: ['admin-console', 'security-override', 'vip-desk'],
  },
  vip: {
    title: 'VIP Support Workspace',
    visible: ['vip-desk', 'priority-override', 'concierge-notes'],
    hidden: ['admin-console', 'profile-search', 'audit-log'],
  },
};
Enter fullscreen mode Exit fullscreen mode

Then each Playwright project adds its role header and verifies the UI through the page object.

test('renders the correct workspace and actions for the injected role',
  async ({ page }, testInfo) => {
    const [role] = testInfo.project.metadata.roles as AgentRole[];
    const expected = expectations[role];

    await test.step(`GIVEN the agent has the '${role}' role`, async () => {
      await mockAgentRoles(page, [role]);
    });

    await test.step('WHEN the operations console is opened', async () => {
      await operationsConsole.open();
      await operationsAssertions.assertConnected();
    });

    await test.step('THEN the roles from the mocked id_token are visible', async () => {
      await operationsAssertions.assertRoles([role]);
      await operationsAssertions.assertTokenContainsRole(role);
    });

    await test.step('AND the role-specific workspace is rendered', async () => {
      await operationsAssertions.assertWorkspaceTitle(expected.title);
    });

    await test.step('AND only the allowed actions are available', async () => {
      for (const action of expected.visible) {
        await operationsAssertions.assertActionVisible(action);
      }

      for (const action of expected.hidden) {
        await operationsAssertions.assertActionNotAvailable(action);
      }
    });
  },
);
Enter fullscreen mode Exit fullscreen mode

This is the part that makes the implementation more than a mockserver example. The UI genuinely changes per role, and the test proves it through browser behavior.


Positive and Negative Role Checks

The role-specific tests are the public-demo equivalent of a real card or feature permission check.

test("Agent with only 'admin' role can access admin-only actions", async ({ page }) => {
  await test.step("GIVEN the agent has the 'admin' role", async () => {
    await mockAgentRoles(page, ['admin']);
  });

  await test.step('WHEN the operations console is opened', async () => {
    await operationsConsole.open();
    await operationsAssertions.assertConnected();
  });

  await test.step('THEN they can see the correct role against their account', async () => {
    await operationsAssertions.assertRoles(['admin']);
  });

  await test.step('AND admin-only actions are available', async () => {
    await operationsAssertions.assertActionVisible('admin-console');
    await operationsAssertions.assertActionVisible('security-override');
    await operationsAssertions.assertActionVisible('audit-log');
  });
});
Enter fullscreen mode Exit fullscreen mode

And the restricted path:

test("Agent without 'admin' role cannot access admin-only actions", async ({ page }) => {
  await test.step("GIVEN the agent does not have the 'admin' role", async () => {
    await mockAgentRoles(page, ['viewer', 'vip']);
  });

  await test.step('WHEN the operations console is opened', async () => {
    await operationsConsole.open();
    await operationsAssertions.assertConnected();
  });

  await test.step('THEN they can see the correct roles against their account', async () => {
    await operationsAssertions.assertRoles(['viewer', 'vip']);
  });

  await test.step('AND admin-only actions are not available', async () => {
    await operationsAssertions.assertActionNotAvailable('admin-console');
    await operationsAssertions.assertActionNotAvailable('security-override');
    await operationsAssertions.assertActionNotAvailable('audit-log');
  });
});
Enter fullscreen mode Exit fullscreen mode

This keeps the intent from the real-world spec: one scenario proves the role can use the feature, and another proves a user without the role is restricted.


Multiple Roles Are Supported

The suite also verifies role precedence.

test('supports multiple roles with admin taking precedence @roles', async ({ page }) => {
  await test.step("GIVEN the agent has 'viewer', 'vip', and 'admin' roles", async () => {
    await mockAgentRoles(page, ['viewer', 'vip', 'admin']);
  });

  await test.step('WHEN the operations console is opened', async () => {
    await operationsConsole.open();
    await operationsAssertions.assertConnected();
  });

  await test.step('THEN the admin workspace takes precedence', async () => {
    await operationsAssertions.assertRoles(['viewer', 'vip', 'admin']);
    await operationsAssertions.assertWorkspaceTitle('Admin Operations Workspace');
    await operationsAssertions.assertActionVisible('admin-console');
  });
});
Enter fullscreen mode Exit fullscreen mode

This helps when real users can have more than one role.


Running the Project

Install dependencies:

npm install
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode

Run the app manually:

npm run start:mockserver
npm run start:app
Enter fullscreen mode Exit fullscreen mode

Open:

http://127.0.0.1:5173
Enter fullscreen mode Exit fullscreen mode

Run the E2E suite:

npm run test:e2e
Enter fullscreen mode Exit fullscreen mode

Why This Scales

This architecture scales because each part has a clear responsibility.

| Layer                   | Responsibility |
| -----------------------------------------------|
| Playwright project      | Chooses the role matrix |
| Playwright helper       | Adds role/config/error headers to mockserver requests |
| Token endpoint          | Converts agent-roles into signed token claims |
| Workspace endpoint      | Verifies token and returns role-specific UI data |
| Frontend                | Renders based on real API responses |
| Test assertion          | Verifies user-visible behavior |
Enter fullscreen mode Exit fullscreen mode

The tests are deterministic, but they still exercise a realistic browser flow:

  • frontend calls backend,
  • backend returns token,
  • frontend uses token,
  • protected endpoint verifies token,
  • UI renders role-specific actions. That is much closer to production behavior than directly forcing frontend state.

Lessons Learned

Mock the boundary the app already trusts

For role-based applications, the auth token is often the right boundary. If roles live in the token in production, make the mockserver issue role-aware tokens in tests.

Keep Playwright helpers small

Helpers like mockAgentRoles, setFeatureFlags, and mockDownstreamServerError are simple enough to read in a spec but powerful enough to control backend behavior through the mockserver.

Make roles visible in the UI

For demos and debugging, showing the decoded roles and workspace actions makes failures easier to understand.

Test absence as well as presence

The tests assert that allowed actions are visible and disallowed actions do not exist. That prevents accidental permission leakage.

Keep public examples sanitized

Use local URLs, fake users, and fake profile data in public examples. The architecture matters; internal values do not.


Conclusion

Role-based E2E testing becomes scalable when Playwright sends lightweight test metadata and the mockserver owns backend behavior.

In this implementation:

  • The local app calls real mockserver endpoints.
  • Playwright continues selected mockserver requests with role/config/error headers.
  • The Koa mockserver signs those roles into an id_token.
  • The workspace endpoint verifies the token.
  • The UI renders different functionality for admin, viewer, and vip.
  • Playwright verifies those differences through real browser assertions. That gives you deterministic role coverage without losing the confidence of an end-to-end flow.
| Layer                         | Responsibility |
| :---                          | :---           |
| Playwright project            | Chooses the role matrix |
| Playwright helper             | Adds role/config/error headers to mockserver requests |
| Token endpoint                | Converts agent-roles into signed token claims |
| Workspace endpoint            | Verifies token and returns role-specific UI data |
| Frontend                      | Renders based on real API responses |
| Test assertion                | Verifies user-visible behavior |
Enter fullscreen mode Exit fullscreen mode

Credits

Special thanks to Cara Townsend and the solution team for establishing the stubbed functional test environment itself, including the mockserver configuration, setup, and backend stubbing model that made this approach possible.

From there, our automation team scaled the pattern into existing Playwright test cases and expanded the role-based test matrix. Thanks also to Priyadarshini Ravichandran for helping shape and validate that implementation.

Top comments (0)