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

Handling cookies and local storage: a step-by-step guide

Control state directly.

Expona Admin10 min read

Introduction

Authentication tokens in localStorage, session cookies tracking user preferences, cart state in sessionStorage—your application's behavior depends on browser storage. When you're automating tests in Playwright, managing this state correctly separates flaky tests from reliable ones. This guide walks you through handling cookies and local storage: a step-by-step guide to controlling state directly, so you can write deterministic browser automation that doesn't leak state between test runs.

Why storage state matters in automation

In production, users log in, their token lands in a cookie or localStorage, and the browser persists it across page reloads. In tests, if you don't explicitly manage that state, you'll either:

  • Repeat the login flow for every test, wasting time.
  • Accidentally share state between tests, causing failures that don't reproduce locally.
  • Start tests with stale or conflicting data that doesn't match your test scenario.

Playwright gives you several tools to control this directly: context.addCookies() for cookies, page.evaluate() to read and manipulate storage APIs, and storageState to persist and reuse authenticated sessions. Using these correctly means faster tests and confidence that what you're testing actually works.

Using context.addCookies to set cookies

Cookies are the traditional way browsers store user identity and preferences. context.addCookies() lets you inject them before your test runs, simulating a logged-in user without hitting the login endpoint every time.

Here's a practical example. Suppose your app sets an authentication cookie called auth_token and a preferences cookie for theme:

typescript
import { test, expect } from '@playwright/test';

test('user dashboard with pre-set cookies', async ({ browser }) => {
  // Create a fresh context
  const context = await browser.newContext();

  // Add cookies before navigating
  await context.addCookies([
    {
      name: 'auth_token',
      value: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
      domain: 'app.example.com',
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      expires: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
    },
    {
      name: 'user_theme',
      value: 'dark',
      domain: 'app.example.com',
      path: '/',
    },
  ]);

  const page = await context.newPage();
  await page.goto('https://app.example.com/dashboard');

  // Browser now has the auth cookie; dashboard should render
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
  await expect(page.locator('body')).toHaveClass(/dark-theme/);

  await context.close();
});

Key points here:

  • Create a context first. You call addCookies() on the context, not the page. The cookies must be added before you navigate.
  • Match the domain and path. If your cookie is for app.example.com, the domain must be app.example.com. A mismatch and the cookie won't stick.
  • Set expiry correctly. If the token expires before your test runs, the server will reject it. Use a timestamp far enough in the future (we typically use 1 hour ahead for tests).
  • Respect httpOnly and secure flags. If your app sets these in production, Playwright respects them. You can set them in addCookies() too, and they'll behave as expected.

This approach is fast because you skip the login flow entirely. For a dashboard test suite, you're now running 20 tests in 30 seconds instead of 10 minutes.

Reading storage with page.evaluate

Cookies work at the HTTP level, but your app might store user state in localStorage, sessionStorage, or even IndexedDB. You need a way to read and set these values during tests. page.evaluate() executes JavaScript in the browser context and returns the result.

Here's how to read localStorage:

typescript
test('read localStorage during test', async ({ page }) => {
  await page.goto('https://app.example.com');

  // Read a value from localStorage
  const cartState = await page.evaluate(() => {
    return localStorage.getItem('cart');
  });

  console.log('Current cart state:', cartState);
  // Output: {"items": [{"id": 1, "qty": 2}], "total": 25.99}
});

And here's how to set values in localStorage before your test starts:

typescript
test('prefill cart from localStorage', async ({ page }) => {
  await page.goto('https://app.example.com');

  // Set cart state in localStorage
  await page.evaluate(() => {
    const cart = {
      items: [
        { id: 'sku-001', qty: 2, price: 12.99 },
        { id: 'sku-002', qty: 1, price: 5.50 },
      ],
      total: 31.48,
    };
    localStorage.setItem('cart', JSON.stringify(cart));
  });

  // Reload or navigate to trigger cart display
  await page.reload();

  // Verify cart renders with pre-set items
  await expect(page.locator('[data-testid="cart-item"]')).toHaveCount(2);
  await expect(page.locator('[data-testid="cart-total"]')).toContainText('$31.48');
});

You can also manipulate sessionStorage and read multiple storage keys at once:

typescript
test('manage multiple storage values', async ({ page }) => {
  await page.goto('https://app.example.com');

  // Read all relevant storage at once
  const state = await page.evaluate(() => {
    return {
      user: JSON.parse(localStorage.getItem('user') || '{}'),
      theme: localStorage.getItem('theme'),
      sessionId: sessionStorage.getItem('session_id'),
    };
  });

  console.log('Current state:', state);

  // Set multiple values
  await page.evaluate(() => {
    localStorage.setItem('user', JSON.stringify({ id: 123, role: 'admin' }));
    localStorage.setItem('theme', 'light');
    sessionStorage.setItem('session_id', 'sess_abc123xyz');
  });
});

This is powerful because it lets you replicate the exact state your frontend expects, without going through the UI or API.

Clearing state between tests

One of the biggest sources of flaky tests is state leaking from one test to the next. If test A sets a user preference and test B doesn't clear it, test B might pass locally but fail in CI. Playwright handles this with context isolation, but you need to be explicit.

By default, each test gets a fresh browser context, so cookies and storage are isolated. But if you're creating contexts manually or running tests in sequence on the same page, you need to clean up:

typescript
test('test A - set user preference', async ({ page }) => {
  await page.goto('https://app.example.com');
  await page.evaluate(() => {
    localStorage.setItem('user_preference', 'value_a');
  });
  await expect(page.locator('[data-testid="preference"]')).toContainText('A');
});

test('test B - should not see test A state', async ({ page }) => {
  // This test gets a fresh context by default, so localStorage is empty
  await page.goto('https://app.example.com');

  const preference = await page.evaluate(() => {
    return localStorage.getItem('user_preference');
  });

  // preference is null, as expected
  expect(preference).toBeNull();
});

However, if you need to clear state manually—perhaps you're reusing a context for performance—do this:

typescript
test.beforeEach(async ({ page }) => {
  // Clear localStorage and cookies before each test
  await page.context().clearCookies();
  await page.evaluate(() => localStorage.clear());
  await page.evaluate(() => sessionStorage.clear());
});

test('isolated test run', async ({ page }) => {
  await page.goto('https://app.example.com');
  // Storage is guaranteed to be clean
});

You can also clear specific cookies or storage keys if full clearing is too aggressive:

typescript
test.beforeEach(async ({ page }) => {
  // Clear only auth-related cookies
  const cookies = await page.context().cookies();
  const authCookies = cookies.filter(c => c.name.includes('auth'));
  await page.context().clearCookies({ name: authCookies[0]?.name });

  // Clear only user preferences from localStorage
  await page.evaluate(() => {
    localStorage.removeItem('user_preference');
    localStorage.removeItem('sidebar_state');
  });
});

Storage state files capture both cookies and localStorage

Playwright provides a built-in way to snapshot and restore the entire storage state—cookies, localStorage, and sessionStorage—in one file. This is perfect for setting up authenticated sessions once and reusing them across many tests.

First, authenticate once and save the state:

typescript
test.describe('setup', () => {
  test('authenticate and save storage state', async ({ page }) => {
    await page.goto('https://app.example.com/login');

    // Log in through the UI
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="password"]', 'secure_password_123');
    await page.click('[data-testid="login-button"]');

    // Wait for redirect to confirm login succeeded
    await page.waitForURL('**/dashboard');

    // Save the storage state (cookies, localStorage, sessionStorage)
    await page.context().storageState({ path: 'auth.json' });
  });
});

The auth.json file now contains:

typescript
{
  "cookies": [
    {
      "name": "auth_token",
      "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "domain": "app.example.com",
      "path": "/",
      "httpOnly": true,
      "secure": true,
      "sameSite": "Strict",
      "expires": 1699564800
    }
  ],
  "origins": [
    {
      "origin": "https://app.example.com",
      "localStorage": [
        { "name": "user", "value": "{\"id\": 123, \"role\": \"admin\"}" },
        { "name": "theme", "value": "dark" }
      ],
      "sessionStorage": []
    }
  ]
}

Now reuse this state in other tests:

typescript
import { test, expect, devices } from '@playwright/test';

const authFile = 'auth.json';

test.describe('authenticated user flows', () => {
  test.use({ storageState: authFile });

  test('user can view protected dashboard', async ({ page }) => {
    // Storage state is automatically loaded before the test runs
    await page.goto('https://app.example.com/dashboard');
    await expect(page.locator('[data-testid="user-name"]')).toContainText('Test User');
  });

  test('user can access settings', async ({ page }) => {
    // Same auth state, different test
    await page.goto('https://app.example.com/settings');
    await expect(page.locator('h1')).toContainText('Account Settings');
  });
});

You can also use storageState in the config to set it globally for a project:

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

export default defineConfig({
  projects: [
    {
      name: 'authenticated',
      use: { ...devices['Desktop Chrome'], storageState: 'auth.json' },
    },
    {
      name: 'guest',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

This approach saves significant time in large test suites. Instead of logging in 50 times, you log in once, save the state, and reuse it. Tests run faster, and the state is guaranteed to be consistent.

Common mistakes

  • Adding cookies after navigation. addCookies() must happen before you navigate. If you navigate first, the cookies won't be sent with the initial request. Always create the context, add cookies, then navigate.
  • Mismatching domain in addCookies. If you're testing app.example.com but you set the cookie domain to example.com, the cookie may or may not be sent depending on the browser's same-site policy. Be explicit and precise.
  • Forgetting that storageState captures the entire storage snapshot. When you load a storageState file, it replaces all cookies and storage for that origin. If you need to add just one cookie to an existing state, you'll need to merge manually or use addCookies() after loading the file.
  • Not clearing state in beforeEach when tests share a context. If you're using a shared context for performance, tests will see each other's localStorage and session state. Always clear if state matters.
  • Using storageState with expired tokens. If your auth token expires during your test run, tests using that storageState will start to fail. Store auth files with sufficient expiry or regenerate them periodically in CI.

Key takeaways

  • Use context.addCookies() to inject cookies before navigation, matching domain and expiry exactly.
  • Use page.evaluate() to read and manipulate localStorage and sessionStorage directly from JavaScript.
  • Clear storage between tests with context.clearCookies() and localStorage.clear() to prevent state leakage.
  • Save and reuse authenticated sessions with storageState to speed up test setup and ensure consistency.
  • Always add cookies before navigating, and verify domain and expiry match your application's expectations.
  • Use storage state files in CI to avoid repeated login flows and reduce test flakiness.
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