Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 014 / 100

Testing multiple user roles: what developers should know

Admins, members and guests in one suite.

Expona Admin10 min read

Testing multiple user roles: what developers should know

Most web applications aren't built for a single type of user. We deal with admins who can delete users, members who can edit their own content, and guests who can only view public pages. When your test suite doesn't account for these different permission levels, you end up shipping bugs that only surface in production—and your QA team spends weeks chasing edge cases you should have caught earlier. Testing multiple user roles: what developers should know covers the patterns that let us run comprehensive permission tests without duplicating test logic or drowning in maintenance overhead.

Over the past two weeks, we've covered fixtures, configuration, and basic test structure. Now we're tackling a problem that hits every real application: how do we efficiently test different user roles across the same features without rebuilding test data for each one? We'll walk through the patterns that make this scalable.

One storage state per role

The cleanest way to manage multiple user roles in Playwright is to store authentication state separately for each role. When you log in once per role and save that authenticated session, you avoid repeated login calls and keep your test execution fast. Each role gets its own storage state file—admin.json, member.json, guest.json—that contains cookies, local storage, and session tokens.

The workflow looks like this: before running tests, you run a single setup script that logs in as each role once and saves the resulting browser state. Then, every test that needs a specific role simply loads that pre-authenticated state instead of logging in from scratch.

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

const authFile = (role: string) => `playwright/.auth/${role}.json`;

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="username"]', 'admin@company.com');
  await page.fill('[data-testid="password"]', 'AdminPassword123');
  await page.click('[data-testid="submit"]');
  await page.waitForURL('/dashboard');
  
  await page.context().storageState({ path: authFile('admin') });
});

setup('authenticate as member', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="username"]', 'member@company.com');
  await page.fill('[data-testid="password"]', 'MemberPass123');
  await page.click('[data-testid="submit"]');
  await page.waitForURL('/dashboard');
  
  await page.context().storageState({ path: authFile('member') });
});

setup('authenticate as guest', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="username"]', 'guest@company.com');
  await page.fill('[data-testid="password"]', 'GuestPass123');
  await page.click('[data-testid="submit"]');
  await page.waitForURL('/dashboard');
  
  await page.context().storageState({ path: authFile('guest') });
});

This setup runs once before your entire test suite. Each storage state file contains everything needed to restore an authenticated session instantly. No re-login, no API calls to create test users—just load the state and go.

In your CI/CD pipeline, you typically run this setup stage once, then use those cached authentication files for all subsequent test runs. Many teams keep these setup tests in a separate project configuration so they only run when credentials change.

Separate projects or fixtures

Once you have storage states, you need a way to apply them to individual tests. There are two main approaches: separate projects in your Playwright config, or custom fixtures that inject the role context into each test.

The project approach works well when you have distinct test suites per role. You define each role as its own project in playwright.config.ts, and each project loads the corresponding storage state automatically.

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

export default defineConfig({
  testDir: './tests',
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    {
      name: 'admin',
      use: {
        ...devices['chromium'],
        storageState: 'playwright/.auth/admin.json',
      },
    },
    {
      name: 'member',
      use: {
        ...devices['chromium'],
        storageState: 'playwright/.auth/member.json',
      },
    },
    {
      name: 'guest',
      use: {
        ...devices['chromium'],
        storageState: 'playwright/.auth/guest.json',
      },
    },
  ],
});

Now every test in your suite automatically runs three times—once as admin, once as member, once as guest—all using the pre-authenticated state. This approach is simple but can feel heavy if you only need one or two roles per test file.

The fixture approach gives you more control. You create a custom fixture that manages role switching on a per-test basis.

typescript
// fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test';
import type { StorageState } from '@playwright/test';
import * as fs from 'fs';

type Role = 'admin' | 'member' | 'guest';

export const test = base.extend<{ authenticateAs: (role: Role) => Promise }>({
  authenticateAs: async ({ browser }, use) => {
    const authenticate = async (role: Role): Promise => {
      const context = await browser.newContext({
        storageState: `playwright/.auth/${role}.json`,
      });
      return context.newPage();
    };

    await use(authenticate);
  },
});

export { expect };

With this fixture, you can selectively apply roles to individual tests rather than running every test as every role.

typescript
// tests/permissions.spec.ts
import { test } from '../fixtures/auth.fixture';

test('admin can delete users', async ({ authenticateAs }) => {
  const adminPage = await authenticateAs('admin');
  await adminPage.goto('/users');
  await adminPage.click('[data-testid="user-delete-123"]');
  await adminPage.click('[data-testid="confirm-delete"]');
  // assertions...
  await adminPage.close();
});

test('member cannot delete users', async ({ authenticateAs }) => {
  const memberPage = await authenticateAs('member');
  await memberPage.goto('/users');
  // Button should not exist
  await expect(memberPage.locator('[data-testid="user-delete-123"]')).not.toBeVisible();
});

The fixture approach scales well when you have a mix of role-specific tests and tests that should run across all roles. You pay only for the authentication states you actually need in each test.

Test permission boundaries

The real value of testing multiple roles emerges when you explicitly test permission boundaries—the points where one role's access ends and another's begins. These aren't happy-path scenarios; they're the edge cases where authorization bugs hide.

A strong permission test suite covers three categories: positive permissions (role can do X), negative permissions (role cannot do X), and API-level permissions (the backend rejects unauthorized requests, not just the UI).

typescript
// tests/permissions/project-access.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Project access permissions', () => {
  test('admin can view all projects', async ({ page }) => {
    // Authenticate as admin
    await page.context().addCookies([/* admin cookies */]);
    await page.goto('/projects');
    
    const projectCount = await page.locator('[data-testid="project-card"]').count();
    expect(projectCount).toBeGreaterThan(0);
  });

  test('member can only view their own projects', async ({ page }) => {
    // Authenticate as member
    await page.context().addCookies([/* member cookies */]);
    await page.goto('/projects');
    
    // Only "My First Project" should be visible, not "Company Confidential"
    await expect(page.locator('text=My First Project')).toBeVisible();
    await expect(page.locator('text=Company Confidential')).not.toBeVisible();
  });

  test('guest cannot access /projects at all', async ({ page }) => {
    // Authenticate as guest
    await page.context().addCookies([/* guest cookies */]);
    await page.goto('/projects', { waitUntil: 'networkidle' });
    
    // Should redirect to /access-denied or /dashboard
    expect(page.url()).not.toContain('/projects');
  });

  test('member gets 403 when calling project API directly', async ({ page, context }) => {
    const adminProjectId = 'proj_company_confidential_789';
    
    const response = await context.request.get(
      `/api/projects/${adminProjectId}`,
      {
        headers: {
          // Include member's auth token from storage state
        },
      }
    );
    
    expect(response.status()).toBe(403);
  });
});

Notice the last test—it doesn't just check the UI. It directly calls the API with a member's credentials and verifies that the backend rejects the request. This catches authorization bugs that might live in your API layer even if the UI looks correct.

The most common authorization vulnerabilities happen when you forget to test the negative case: member tries to access admin features and succeeds because you only protected the UI, not the API. Real permission tests exercise both layers.

Use test.use to switch roles per file

When you have a whole file of tests that should all run as a specific role, the test.use() method keeps your code DRY. Instead of writing the same authenticateAs('admin') call in every test, you set it once at the file level.

typescript
// tests/admin/user-management.spec.ts
import { test, expect } from '@playwright/test';

// All tests in this file run as admin
test.use({ storageState: 'playwright/.auth/admin.json' });

test('can create a new user', async ({ page }) => {
  await page.goto('/admin/users');
  await page.click('[data-testid="create-user-btn"]');
  await page.fill('[data-testid="email-input"]', 'newuser@company.com');
  await page.click('[data-testid="submit"]');
  
  await expect(page.locator('text=newuser@company.com')).toBeVisible();
});

test('can bulk delete inactive users', async ({ page }) => {
  await page.goto('/admin/users/inactive');
  await page.click('[data-testid="select-all"]');
  await page.click('[data-testid="delete-selected"]');
  
  await expect(page.locator('text=Deleted 12 users')).toBeVisible();
});

You can combine this with describe blocks to organize role-specific suites cleanly.

typescript
// tests/authorization.spec.ts
import { test, expect } from '@playwright/test';

test.describe('as admin', () => {
  test.use({ storageState: 'playwright/.auth/admin.json' });

  test('can access admin panel', async ({ page }) => {
    await page.goto('/admin');
    await expect(page.locator('text=Admin Dashboard')).toBeVisible();
  });
});

test.describe('as member', () => {
  test.use({ storageState: 'playwright/.auth/member.json' });

  test('cannot access admin panel', async ({ page }) => {
    await page.goto('/admin');
    expect(page.url()).not.toContain('/admin');
  });
});

This structure makes it immediately obvious which tests run as which role. Maintainers see the intent without digging through fixture logic.

Common mistakes

Storing credentials in test files. We've seen teams hardcode login credentials in their tests. This belongs in environment variables or a secrets management system, never in source code. Your test setup script should read credentials from process.env or a CI secret store.

Not testing API-level authorization. A test that only checks the UI isn't a real authorization test. Admin buttons might be hidden, but if your API endpoint doesn't verify permissions, you have a security hole. Always test both layers.

Logging in during every test. If you're calling login in each test function, you're wasting time and making your suite fragile. Pre-authenticate once, save the state, load it in tests. Login should only happen in your setup script.

Treating all roles the same way. Some teams run their entire test suite as each role, which sounds thorough but generates noise. Not every feature needs to be tested as every role. Guest users probably don't need to test the admin panel. Target your permission tests to boundaries that actually matter.

Forgetting to clean up between role switches. If you create test data while authenticated as one role, then switch to another role, that data might not be visible or might cause conflicts. Use isolated test data or clean up explicitly between role transitions.

Key takeaways

  • Run a setup script once per role to generate storage state files containing pre-authenticated sessions.
  • Use separate projects in your config for role-wide test suites, or custom fixtures for selective role application per test.
  • Always test permission boundaries: positive permissions, negative permissions, and API-level authorization together.
  • Apply test.use({ storageState }) at the file or describe level to keep role context visible and reduce duplication.
  • Test both UI and API authorization; UI-only tests miss backend vulnerabilities.
  • Store authentication credentials in environment variables or CI secrets, never in test files.
  • Pre-authenticate roles once in setup; don't login during regular tests.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles