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:
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 beapp.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:
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:
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:
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:
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:
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:
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:
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:
{
"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:
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:
// 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.combut you set the cookie domain toexample.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()andlocalStorage.clear()to prevent state leakage. - Save and reuse authenticated sessions with
storageStateto 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.






Comments
Loading comments…