← Blog

"Playwright Series #7: Authentication — Sessions, Storage State and Multi-User Testing"

Logging in through the UI for every test is the biggest cause of slow test suites. Learn storageState, global setup, role-based fixtures and how to test with multiple simultaneous users.

reading now
views
comments

Series Navigation

Part 6: Network Interception and API Mocking

Part 8: Visual Testing — Screenshots and Pixel-Perfect Comparisons


The Problem: Login in Every Test

The most common mistake in growing test suites:

// ❌ Repeated in 50 tests — 50 full login round trips
test.beforeEach(async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('secret123');
  await page.getByRole('button', { name: 'Log in' }).click();
  await page.waitForURL('/dashboard');
});

If login takes 1 second and you have 100 tests, that's 100 seconds wasted purely on authentication.

The solution: log in once, save the session, reuse it everywhere.


storageState — The Core Concept

storageState saves everything the browser stores after authentication:

  • Cookies
  • localStorage
  • sessionStorage

You capture it once, save it to a file, and tell Playwright to load it at the start of each test — no UI login required.


Step 1: Global Setup — Log In Once

Create tests/global-setup.ts:

import { chromium, FullConfig } from '@playwright/test';

async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;

  const browser = await chromium.launch();
  const page    = await browser.newPage();

  // Perform login once
  await page.goto(`${baseURL}/login`);
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Log in' }).click();
  await page.waitForURL('/dashboard');

  // Save the session to a file
  await page.context().storageState({ path: '.auth/user.json' });

  await browser.close();
}

export default globalSetup;

Create the .auth directory and add it to .gitignore:

mkdir .auth
echo ".auth/" >> .gitignore

Step 2: Register Global Setup in Config

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

export default defineConfig({
  globalSetup: './tests/global-setup.ts',

  use: {
    baseURL: 'http://localhost:3000',
    // Load the saved session for every test
    storageState: '.auth/user.json',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
});

Now every test starts already logged in. Zero UI login steps.


Step 3: Override for Tests That Need No Auth

Some tests — like the login page itself — shouldn't start authenticated:

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

// Override storageState for this test file
test.use({ storageState: { cookies: [], origins: [] } });

test('login page is accessible when not authenticated', async ({ page }) => {
  await page.goto('/login');
  await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});

test('redirects to login when accessing protected route', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveURL(/\/login/);
});

Multiple Roles — Admin, User, Guest

Real applications have multiple user roles. Save a session file for each:

// tests/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';

async function loginAs(baseURL: string, email: string, password: string, savePath: string) {
  const browser = await chromium.launch();
  const page    = await browser.newPage();

  await page.goto(`${baseURL}/login`);
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Log in' }).click();
  await page.waitForURL('/dashboard');

  await page.context().storageState({ path: savePath });
  await browser.close();
}

async function globalSetup(config: FullConfig) {
  const baseURL = config.projects[0].use.baseURL as string;

  await Promise.all([
    loginAs(baseURL, process.env.ADMIN_EMAIL!, process.env.ADMIN_PASS!, '.auth/admin.json'),
    loginAs(baseURL, process.env.USER_EMAIL!,  process.env.USER_PASS!,  '.auth/user.json'),
  ]);
}

export default globalSetup;

Role-Based Fixtures

Use fixtures to inject the right user role into each test:

// tests/fixtures/index.ts
import { test as base, Page } from '@playwright/test';

type UserRole = 'admin' | 'user' | 'guest';

type AuthFixtures = {
  asAdmin: Page;
  asUser:  Page;
  asGuest: Page;
};

export const test = base.extend<AuthFixtures>({
  asAdmin: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: '.auth/admin.json'
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  asUser: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: '.auth/user.json'
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  asGuest: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: { cookies: [], origins: [] }
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';

Use in tests:

import { test, expect } from './fixtures';

test('admin can access user management', async ({ asAdmin }) => {
  await asAdmin.goto('/admin/users');
  await expect(asAdmin.getByRole('heading', { name: 'User Management' })).toBeVisible();
});

test('regular user cannot access admin panel', async ({ asUser }) => {
  await asUser.goto('/admin/users');
  await expect(asUser).toHaveURL(/\/403|\/dashboard/);
});

test('guest is redirected to login', async ({ asGuest }) => {
  await asGuest.goto('/dashboard');
  await expect(asGuest).toHaveURL(/\/login/);
});

Multi-User Scenarios — Two Users Simultaneously

Test real-time features like collaboration or notifications:

test('admin can see when user places an order', async ({ browser }) => {
  // Admin session
  const adminContext = await browser.newContext({ storageState: '.auth/admin.json' });
  const adminPage   = await adminContext.newPage();

  // User session
  const userContext = await browser.newContext({ storageState: '.auth/user.json' });
  const userPage   = await userContext.newPage();

  // Admin opens the orders dashboard
  await adminPage.goto('/admin/orders');

  // User places an order
  await userPage.goto('/shop');
  await userPage.getByRole('button', { name: 'Add to Cart' }).first().click();
  await userPage.goto('/checkout');
  await userPage.getByRole('button', { name: 'Place Order' }).click();
  await expect(userPage.getByText('Order confirmed')).toBeVisible();

  // Admin's dashboard should show the new order (real-time update)
  await expect(adminPage.getByRole('row').first()).toContainText('Pending');

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

API-Based Authentication (Faster)

If your app exposes an authentication API, use it instead of UI login — it's 10x faster:

// global-setup.ts
async function globalSetup(config: FullConfig) {
  const { baseURL } = config.projects[0].use;
  const browser = await chromium.launch();
  const context = await browser.newContext();

  // Authenticate via API
  const response = await context.request.post(`${baseURL}/api/auth/login`, {
    data: {
      email: process.env.TEST_USER_EMAIL,
      password: process.env.TEST_USER_PASSWORD
    }
  });

  const { token } = await response.json();

  // Store the token in localStorage
  const page = await context.newPage();
  await page.goto(baseURL!);
  await page.evaluate(t => localStorage.setItem('auth_token', t), token);

  await context.storageState({ path: '.auth/user.json' });
  await browser.close();
}

Environment Variables for Credentials

Never hardcode credentials. Use a .env file:

# .env.test (add to .gitignore)
TEST_USER_EMAIL=testuser@example.com
TEST_USER_PASSWORD=TestPassword123
ADMIN_EMAIL=admin@example.com
ADMIN_PASS=AdminPassword123

Load in playwright.config.ts:

import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });

Install dotenv:

npm install dotenv --save-dev

What's Next

In Part 8 we cover visual testing — capturing screenshots, comparing against baselines, testing responsive layouts across viewports, and integrating pixel-diff reporting into your CI pipeline.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000