Back to Insights
Testing & QA

Persona-Aware Playwright Fixtures for Multi-Role Testing

Three Playwright fixtures in parallel, each loading a different persona.

Your suite has 400 tests, a 92% pass rate, and every test starts with await use(adminPage). You have never shipped a bug in the admin flow. Last month a free-tier customer reported they could not reach the billing page — the link was absent from their sidebar. The suite said nothing, because the suite has never been logged in as a free-tier user.

A persona-aware Playwright fixture is a test configuration that creates separate storageState authentication files for each user persona — role, subscription tier, and onboarding state — so the test suite exercises the application’s navigation graph as each distinct user type, not just the admin.

How storageState works and why most teams end up with one shared fixture

Playwright’s storageState serialises cookies and local storage to a JSON file, letting tests skip the login flow. The default setup writes one file, for one user — and that user is almost always an admin.

Playwright’s storageState is the mechanism that makes authentication fast. You log in once in a setup step, serialise the cookies and local storage to a JSON file, and every subsequent test loads that file instead of repeating the login flow. The official documentation recommends a single globalSetup script that authenticates and writes playwright/.auth/user.json, which every project then references in playwright.config.ts:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: {
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

This works. It also means every test in the chromium project shares exactly one identity. That identity is almost always an admin — the account that was created first, has the broadest permissions, and sees the most complete navigation. The suite converges on admin-only coverage not by decision but by path of least resistance.

The persona-blindspot model: admin-only coverage as a structural pattern

When every test authenticates as admin, the suite exercises the admin’s navigation graph exclusively. Routes hidden from non-admin personas — or gated behind a subscription tier — are never verified.

When every test authenticates as admin, the suite exercises the admin’s navigation graph: the admin’s sidebar, the admin’s menu, the admin’s API permissions. Routes that are visible only to admins show up in the suite. Routes that are hidden from non-admin personas — or rendered differently, or gated behind a subscription tier — never get exercised.

This is not a missing test. It is a missing persona. The distinction matters because adding more tests under the admin fixture makes the coverage number rise without closing the gap. You could reach 100% line coverage and still have zero confidence that a free-tier user can navigate from login to billing, because no test has ever asked. The CVE-2025-29927 post-mortem showed the same pattern from the security side: routes that the suite could reach through middleware, but had never tested independently.

The pattern is structural, not accidental. It emerges wherever a suite uses a single storageState, which — according to the Playwright docs — is the default recommended setup.

Defining your persona set

A persona is a combination of role, permission level, subscription tier, and onboarding state. Most B2B SaaS applications need three to five personas to cover the distinct navigation graphs their users encounter.

Before you write any fixtures, define the personas your application supports. A persona is not just a role. It is a combination of:

  • Role: admin, editor, viewer, guest
  • Permission level: full access, read-only, department-scoped
  • Subscription tier: enterprise, professional, free, trial (expired)
  • Onboarding state: completed, partial, not started

Write these out as a simple schema. For a typical B2B SaaS with three tiers, this yields six to ten distinct personas. You do not need to test every combination — you need to test every persona whose navigation graph differs from the admin’s. In practice, that is usually three to five: admin, a standard paid user, a free-tier user, a guest (unauthenticated), and possibly a user whose trial has expired.

// personas.ts
export interface Persona {
  name: string;
  role: string;
  tier: string;
  credentials: { email: string; password: string };
}

export const personas: Persona[] = [
  {
    name: 'admin',
    role: 'admin',
    tier: 'enterprise',
    credentials: { email: 'admin@example.com', password: process.env.ADMIN_PW! },
  },
  {
    name: 'paid-user',
    role: 'member',
    tier: 'professional',
    credentials: { email: 'paid@example.com', password: process.env.PAID_PW! },
  },
  {
    name: 'free-user',
    role: 'member',
    tier: 'free',
    credentials: { email: 'free@example.com', password: process.env.FREE_PW! },
  },
  {
    name: 'expired-trial',
    role: 'member',
    tier: 'trial-expired',
    credentials: { email: 'trial@example.com', password: process.env.TRIAL_PW! },
  },
];

Implementing per-persona storageState fixtures

Playwright’s project-level configuration supports multiple projects, each with its own setup and storageState. Use this to create one project per persona:

// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { personas } from './personas';

export default defineConfig({
  projects: [
    // Setup projects: one per persona
    ...personas.map((p) => ({
      name: `setup-${p.name}`,
      testMatch: /.*\.setup\.ts/,
      use: {
        persona: p,
      },
    })),
    // Test projects: one per persona, each depending on its setup
    ...personas.map((p) => ({
      name: p.name,
      use: {
        storageState: `playwright/.auth/${p.name}.json`,
      },
      dependencies: [`setup-${p.name}`],
    })),
  ],
});

The shared setup script authenticates each persona and writes a separate state file:

// auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate persona', async ({ page }, testInfo) => {
  const persona = testInfo.project.use.persona;
  if (!persona) return;

  await page.goto('/login');
  await page.getByLabel('Email').fill(persona.credentials.email);
  await page.getByLabel('Password').fill(persona.credentials.password);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await page.waitForURL('/dashboard');
  await page.context().storageState({
    path: `playwright/.auth/${persona.name}.json`,
  });
});

Each test file now runs once per persona. A test written in the admin project authenticates as admin; the same test in the free-user project authenticates as a free-tier user. Playwright handles the isolation.

Writing a navigation-reachability assertion

With per-persona fixtures in place, you can write an assertion that checks whether a set of named routes renders without error for each persona:

// navigation-reachability.spec.ts
import { test, expect } from '@playwright/test';

const criticalRoutes = [
  { path: '/dashboard', name: 'Dashboard' },
  { path: '/billing', name: 'Billing' },
  { path: '/settings', name: 'Settings' },
  { path: '/reports', name: 'Reports' },
  { path: '/integrations', name: 'Integrations' },
];

for (const route of criticalRoutes) {
  test(`can reach ${route.name} at ${route.path}`, async ({ page }) => {
    const response = await page.goto(route.path);
    // Route should not redirect to login or return an error
    expect(response?.status()).toBeLessThan(400);
    // Route should not silently redirect to a different page
    expect(page.url()).toContain(route.path);
  });
}

When this test runs across all four persona projects, you get a matrix: admin reaches 5/5 routes, paid-user reaches 4/5, free-user reaches 3/5, expired-trial reaches 2/5. The missing cells are the gaps. Some will be intentional restrictions (an expired trial should not reach /integrations). Others will be the bugs your suite has never caught.

Diffing persona graphs

For a more systematic approach, write a script that crawls the navigation from the authenticated entry point per persona and compares the link graphs:

// diff-persona-graphs.ts
import { chromium } from '@playwright/test';
import { personas } from './personas';
import fs from 'fs';

async function collectLinks(statePath: string): Promise<Set<string>> {
  const browser = await chromium.launch();
  const context = await browser.newContext({ storageState: statePath });
  const page = await context.newPage();
  await page.goto('/dashboard');

  const links = await page.$$eval('a[href]', (anchors) =>
    anchors
      .map((a) => new URL(a.href, window.location.origin).pathname)
      .filter((href) => href.startsWith('/'))
  );

  await browser.close();
  return new Set(links);
}

async function main() {
  const graphs: Record<string, Set<string>> = {};

  for (const persona of personas) {
    const statePath = `playwright/.auth/${persona.name}.json`;
    if (!fs.existsSync(statePath)) continue;
    graphs[persona.name] = await collectLinks(statePath);
  }

  // Diff against admin as the baseline
  const adminLinks = graphs['admin'];
  for (const [name, links] of Object.entries(graphs)) {
    if (name === 'admin') continue;
    const missing = [...adminLinks].filter((l) => !links.has(l));
    const extra = [...links].filter((l) => !adminLinks.has(l));
    console.log(`\n--- ${name} vs admin ---`);
    console.log(`Missing from ${name}: ${missing.join(', ') || 'none'}`);
    console.log(`Extra in ${name}: ${extra.join(', ') || 'none'}`);
  }
}

main();

This surfaces two kinds of asymmetry. Missing links are routes visible to admin but not to the persona — either intentional restrictions or navigation bugs. Extra links are routes visible only to a non-admin persona, which can indicate permission escalation issues (a link that exists in the UI but should not).

Triage when you find a gap

Not every gap is a bug. The diff between admin and free-user will include routes that free-tier users genuinely should not see. The triage categories:

Security. A route is accessible by direct URL but has no UI link for the persona. This is either an RBAC misconfiguration or a missing server-side check. Priority: immediate.

Billing flow. A persona cannot reach a critical conversion path — upgrade, checkout, invoice download. This is almost certainly unintentional and represents lost revenue. Priority: high.

UX. A feature exists but the navigation to reach it is broken or absent for a specific persona. The feature works if you paste the URL directly. The user would never find it. Priority: depends on the feature’s importance, but these accumulate.

Intentional restriction. Admin-only routes that non-admin personas should not reach. Document these explicitly as expected gaps so they do not keep surfacing as false positives.

Integrating persona fixtures into CI without multiplying runtime

The obvious concern: if you have four personas and 200 tests, you now have 800 test runs. That is expensive.

The answer is selective persona runs. Not every test needs to run against every persona. Split your tests into two categories:

Navigation-reachability tests run against all personas. These are cheap — a page.goto and a status check, typically under two seconds per route. A sweep of 30 routes across four personas is 120 tests, completing in under a minute with parallel workers.

Functional tests run against the persona they were written for. Your admin-settings tests stay in the admin project. Your billing-flow tests run in paid-user and free-user. Your onboarding tests run in the personas where onboarding is incomplete.

Playwright’s test sharding distributes the load across CI workers. Use --shard=1/4 to split by persona or by test file. The total runtime increase is modest — typically 15–30% above a single-persona suite — while the coverage gain is categorical.

# In CI: run each persona shard in parallel
npx playwright test --project=admin --shard=1/4
npx playwright test --project=paid-user --shard=2/4
npx playwright test --project=free-user --shard=3/4
npx playwright test --project=expired-trial --shard=4/4

What this looks like in practice

After implementing persona fixtures on a typical 50-route B2B SaaS, the pattern that usually emerges is this: the admin persona reaches all 50 routes. The paid-user persona reaches 40–45. The free-tier persona reaches 25–35. And the expired-trial persona reaches 10–15. The delta between admin and free-user almost always includes the billing page, the integrations page, at least one settings sub-page, and two or three features that were added after launch and linked only into the admin sidebar.

These are not theoretical gaps. They are features your customers cannot find and your support team is fielding tickets about. The suite has been green for months. The tests pass. The routes exist. The links do not. For the lightweight version of this audit that does not require persona fixtures, the five-minute protected-routes audit covers the single-persona route diff.

Playwright’s Test Agents, introduced in v1.56 (October 2025), can now generate test plans by exploring your application from a seed URL. But the agents explore whatever the seed user can see — if the seed authenticates as admin, the agent’s test plan will cover the admin’s graph and nothing else. Persona fixtures are what make the agents’ exploration multi-dimensional. Without them, automated test generation inherits exactly the same blindspot the hand-written suite had.


Frequently asked questions

How do you create a separate storageState file for each user role in Playwright? Define a personas array with credentials for each role, then create a Playwright project per persona in playwright.config.ts. Each project’s setup step authenticates with the persona’s credentials and writes the session state to playwright/.auth/<persona-name>.json. Each test project references its own storageState file and declares a dependency on its setup project.

What is a role-blind test gap? A role-blind test gap occurs when a test suite authenticates exclusively as one user type (typically admin) and never exercises the application as other personas. Routes, features, or navigation elements that differ by role, subscription tier, or onboarding state are never verified for non-admin users, even though the suite reports high coverage.

How do you test whether admin routes are accessible to non-admin users? Create a navigation-reachability test that runs across all persona projects. For each critical route, assert that the route either renders successfully (if the persona should have access) or returns a redirect or 403 (if the persona should not). The routes that render for admin but redirect for other personas without a server-side authorisation check are RBAC gaps.

How do you diff the routes each persona can reach in Playwright? Write a script that authenticates as each persona, navigates to the application’s entry point, collects all links on the page, and recurses through them. Compare the resulting link sets per persona. Routes present in the admin graph but absent from other personas’ graphs are either intentional restrictions or navigation gaps.

How do you integrate persona fixtures into CI without multiplying test runtime? Separate your tests into navigation-reachability tests (which run across all personas and are cheap — a page.goto and status check per route) and functional tests (which run only against the persona they were written for). Use Playwright’s --shard flag to distribute persona projects across parallel CI workers.

Run a coverage scan on your app.

Point Glia Quest at a staging or production URL. The first run is free and the report shows up in two minutes.