lecture 6 of 170 completed

Multi-user and role-based testing

Two users in one test, and permission matrices across many roles. Playwright is unusually good at both, and both are hard to design well.

Some of the most valuable tests in a business application involve two people. An admin approves a request and the requester sees it appear. A support agent updates a ticket while the customer watches. A document is shared and the recipient's list updates.

These are exactly the flows that break in production and exactly the ones most suites never cover, because in most tools they are painful. In Playwright they are straightforward, and knowing how is a strong senior signal.

Two users in one test.

test('an approval reaches the requester', async ({ browser }) => {
  const adminContext = await browser.newContext({ storageState: '.auth/admin.json' });
  const userContext = await browser.newContext({ storageState: '.auth/standard.json' });

  const admin = await adminContext.newPage();
  const user = await userContext.newPage();

  await user.goto('/requests');
  await user.getByRole('button', { name: 'New request' }).click();
  await user.getByLabel('Amount').fill('500');
  await user.getByRole('button', { name: 'Submit' }).click();

  await admin.goto('/approvals');
  await admin.getByRole('button', { name: 'Approve' }).first().click();

  await expect(user.getByTestId('request-status')).toHaveText('Approved');

  await adminContext.close();
  await userContext.close();
});

The key is that contexts are the isolation boundary, so two contexts means two independent sessions in one test. With one context you could not be two users, because they would share cookies.

Make it a fixture, because the boilerplate is otherwise repeated everywhere:

export const test = base.extend<{ userAs: (role: string) => Promise<Page> }>({
  userAs: async ({ browser }, use) => {
    const contexts: BrowserContext[] = [];

    await use(async (role) => {
      const context = await browser.newContext({ storageState: `.auth/${role}.json` });
      contexts.push(context);
      return context.newPage();
    });

    for (const c of contexts) await c.close();
  },
});

Note the teardown: contexts you create yourself are not cleaned up automatically, and leaking them across a long run is a real resource problem. The fixture tracks and closes them.

Now the test reads cleanly:

test('an approval reaches the requester', async ({ userAs }) => {
  const admin = await userAs('admin');
  const user = await userAs('standard');
  // ...
});

The synchronisation trap. Two-user tests introduce a timing problem that single-user tests do not have. The admin approves; how long before the user's page reflects it?

If the app uses websockets or polling, the update arrives on its own and a web-first assertion handles it, because it retries. If the app only updates on navigation, the user's page will never change and your test will wait pointlessly until timeout. Reload deliberately in that case.

Getting this wrong produces a test that is flaky in a way that looks random and is actually a race between two sessions. Decide which mechanism the app uses before writing the assertion.

Permission matrices. The second half of role-based testing: proving that each role can do exactly what it should and nothing more.

const MATRIX = [
  { role: 'admin',    canDelete: true,  canExport: true  },
  { role: 'standard', canDelete: false, canExport: true  },
  { role: 'readonly', canDelete: false, canExport: false },
];

for (const { role, canDelete, canExport } of MATRIX) {
  test(`${role}: delete=${canDelete} export=${canExport}`, async ({ userAs }) => {
    const page = await userAs(role);
    await page.goto('/contacts');

    await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible({ visible: canDelete });
    await expect(page.getByRole('button', { name: 'Export' })).toBeVisible({ visible: canExport });
  });
}

Test the negative properly. This is where most permission testing is weak. Checking that a read-only user cannot see the delete button is not enough: a hidden button is a UI detail, not a permission. The real check is that the API refuses when the action is attempted directly.

const response = await request.delete('/api/contacts/123');
expect(response.status()).toBe(403);

A hidden button with an unprotected endpoint is a genuine security bug, and it is the exact bug this kind of test exists to catch. If you only assert on the UI, you would never find it.

Remember this: one context per user, close what you create, decide how updates propagate before asserting, and always check permissions at the API and not only in the UI.

Reference: Multiple contexts.