QA Interview Hub · Knowledge base · Quizzes

Playwright

Authentication and storageState

Authentication: storageState and a setup project

StorageState is a JSON snapshot of a context's state: cookies and localStorage for each origin. Login is done once and the state is saved with page.context().storageState({ path: 'playwright/.auth/user.json' }), and tests pick up the file through use: { storageState }. The folder with state files is added to .gitignore, because live tokens are there.

The recommended way is a setup project: a separate project in the config with testMatch: /.*\.setup\.ts/, on which other projects depend through dependencies. Unlike globalSetup, a setup project is made of full tests: with fixtures, retries, a trace and display in the report. globalSetup remains for things without a browser, such as DB preparation or starting containers.

For several roles, separate state files are made (admin.json, user.json) and picked up through separate projects or test.use in a file. When one test needs two roles at once, two contexts are created through browser.newContext({ storageState }). SessionStorage is not saved in storageState: it is passed in manually through addInitScript.

Login through the API in a setup project

A setup project doesn't have to log in through the UI. If the login form is already covered by separate tests, it is faster to get the session through the API:

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

setup('admin login', async ({ request }) => {
  const res = await request.post('/api/auth/login', {
    data: { email: process.env.ADMIN_EMAIL, password: process.env.ADMIN_PASSWORD },
  });
  await expect(res).toBeOK();
  await request.storageState({ path: 'playwright/.auth/admin.json' });
});

This works if the session is kept in cookies. If the application stores the token in localStorage, after an API login you need to open a page and write the token through page.evaluate, and then save page.context().storageState().

An important limitation of shared state: all workers use one user. If tests change the account's state (balance, settings, cart), they will start interfering with each other in parallel. Then you move to an account per worker: a worker fixture logs in a separate user with the index test.info().parallelIndex and saves the state to a file with that index.

One more nuance is the token's lifetime. If the session lives 15 minutes and a run takes 40, the tests at the end will start failing with 401. Then you either increase the TTL on the test environment, or log in again in a fixture.

In an interview: In a setup project I log in through the API and save storageState, because it is faster than the UI. If tests change account data, I use a separate user per worker through parallelIndex. I also take the session TTL into account relative to the run length.

Several roles and the logged-out state

If a file tests only one role, test.use({ storageState: 'playwright/.auth/admin.json' }) at the start of the file or describe is enough. If one test needs two roles at once, it is convenient to make fixtures that return ready pages:

type Roles = { adminPage: Page; clientPage: Page };

export const test = base.extend<Roles>({
  adminPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
    await use(await ctx.newPage());
    await ctx.close();
  },
  clientPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'playwright/.auth/client.json' });
    await use(await ctx.newPage());
    await ctx.close();
  },
});

The test then looks like a scenario: a client submits a loan application, the administrator approves it, and the client sees the new status.

The opposite situation: storageState is configured globally, but you need a test for an unauthenticated user. Then the state is reset: test.use({ storageState: { cookies: [], origins: [] } }).

SessionStorage doesn't end up in storageState. If the application keeps the session there, it is read in setup through page.evaluate and restored through context.addInitScript, which runs before the page's scripts.

Roles can also be separated into projects in the config: a project admin with one state file and a testMatch for admin tests, and a project client with another.

In an interview: For scenarios with two roles I make the fixtures adminPage and clientPage, each with its own context and storageState. For tests of an unauthenticated user I reset the state through an empty storageState. I restore SessionStorage through addInitScript.

globalSetup or a setup project: a comparison

Both mechanisms perform preparation before the tests, but the difference is significant:

CapabilityglobalSetupsetup project
Visible in the HTML reportnoyes
Trace and screenshotsonly manuallyas in ordinary tests
The page, request fixturesnoyes
Retriesnoyes
Can run part of itnothrough dependencies

globalSetup is a function in a separate file that runs once before the whole run. It receives the config and can pass values to tests through process.env. If it returns a function, that becomes the teardown.

For a setup project the cleanup is done through the teardown property in the project: you give the name of the project that will run after all the dependent ones finish.

projects: [
  { name: 'setup', testMatch: /global\.setup\.ts/, teardown: 'cleanup' },
  { name: 'cleanup', testMatch: /global\.teardown\.ts/ },
  { name: 'e2e', dependencies: ['setup'], use: { storageState: 'playwright/.auth/user.json' } },
],

My choice: everything related to the browser and login goes into a setup project. I leave globalSetup for infrastructure: bringing up containers, running migrations, checking that the environment is available.

A nuance: if you run a test with a filter, for example npx playwright test checkout, the setup project still runs, because it is in dependencies. It can be skipped with the flag --no-deps when the state is already saved.

In an interview: A setup project is better for login, because it has a trace, retries, fixtures and shows up in the report, and cleanup is done through the teardown property. I leave globalSetup for infrastructure preparation without a browser.