Playwright’s storageState is a powerful feature for managing authentication, but it often leads to a common workflow: logging in at the start of every test suite. This is usually done by creating a storageState.json file after a successful login and then telling Playwright to load it for subsequent tests.

Here’s how you can bypass the login step entirely for most of your tests, saving significant execution time.

The Problem: Redundant Logins

Imagine you have a web application where users must log in before they can interact with core features. In your Playwright tests, this typically translates to a setup like this:

  1. Initial Login: A test or a setup function logs a user in.
  2. Save State: The authentication tokens (cookies, local storage) are saved to a storageState.json file.
  3. Subsequent Tests: All other tests in the suite load this storageState.json and bypass the login form.

While this is efficient, the initial login still needs to happen. If your storageState.json becomes stale (e.g., due to token expiration, password changes, or application updates), you’re forced to re-run the entire login process, including the manual steps or the initial automated login, just to regenerate the state file. This can be a bottleneck, especially in CI/CD pipelines.

The Solution: Conditional State Generation

The core idea is to make the generation of storageState.json conditional. Instead of always generating it, we’ll only generate it when it’s necessary or when explicitly requested.

Let’s break down how to implement this.

1. The "Login" Test

Create a dedicated test file (e.g., login.spec.ts) whose sole purpose is to log in a user and save the storageState.

// tests/login.spec.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/loginPage'; // Assuming you have a LoginPage Page Object

const test = base.extend({
  // Define a custom storage state using a function
  storageState: async ({ page }, use) => {
    const storageStatePath = 'playwright/.auth/userState.json';

    // Check if the storage state file exists and is considered valid
    // (We'll refine this validity check later)
    if (await page.context().storageState({ path: storageStatePath })) {
      console.log('Using existing storage state.');
      await use(storageStatePath);
    } else {
      console.log('Generating new storage state.');
      const loginPage = new LoginPage(page);
      await loginPage.goto();
      await loginPage.login('testuser', 'password123'); // Replace with actual credentials or a method to get them

      // Save the state to a file
      await page.context().storageState({ path: storageStatePath });
      await use(storageStatePath);
    }
  },
});

test.describe('Authentication', () => {
  test('should log in and save state', async ({ page, storageState }) => {
    // This test will only run if the storageState function above
    // decides to generate a new state.
    // We can simply navigate to a protected page to verify login.
    await page.goto('/dashboard'); // Or any page that requires authentication
    await page.waitForURL(/.*dashboard/); // Ensure we landed on the dashboard
    await page.screenshot({ path: 'playwright/.screenshots/login_success.png' });
  });
});

Explanation:

  • We’re using test.extend to create a custom fixture named storageState.
  • This fixture is an async function that receives page as an argument.
  • Inside the fixture, we define the storageStatePath.
  • Crucially, we use page.context().storageState({ path: storageStatePath }). This method returns the storage state object if the file exists, allowing us to check for its presence. If it doesn’t exist or is invalid (which we’ll address), it might implicitly return undefined or throw an error depending on Playwright’s internal handling. A more robust check is to use fs.existsSync.
  • If the file exists, we await use(storageStatePath), telling Playwright to load this existing state for the tests that depend on the storageState fixture.
  • If the file doesn’t exist, we perform the login, save the new state, and then await use(storageStatePath).

2. Using the Conditional State in Other Tests

Now, in your other test files, you simply declare your test to use the storageState fixture.

// tests/profile.spec.ts
import { test } from '@playwright/test';

// This test will automatically use the storageState fixture
// defined in login.spec.ts.
test.describe('Profile Page', () => {
  test('should display user profile', async ({ page, storageState }) => {
    // If storageState.json exists and is valid, login is skipped.
    // Otherwise, login.spec.ts will run its login logic first.
    await page.goto('/profile');
    await page.waitForSelector('h1:has-text("My Profile")');
    await page.screenshot({ path: 'playwright/.screenshots/profile_loaded.png' });
  });

  test('should allow editing profile', async ({ page, storageState }) => {
    await page.goto('/profile/edit');
    await page.fill('input[name="bio"]', 'A short bio.');
    await page.click('button:has-text("Save")');
    await page.waitForSelector('.success-message:has-text("Profile updated")');
    await page.screenshot({ path: 'playwright/.screenshots/profile_edited.png' });
  });
});

Explanation:

  • By including storageState in the test function signature (async ({ page, storageState })), Playwright knows to apply the storageState fixture logic.
  • If login.spec.ts has already generated a valid storageState.json and it’s still valid, these tests will load that state and bypass the login form.
  • If the storageState.json is missing or deemed invalid by our logic, Playwright will first execute the storageState fixture from login.spec.ts to generate a new one.

3. Making the State "Valid"

The simple check await page.context().storageState({ path: storageStatePath }) doesn’t actually validate the content. It just checks for file existence. To make this more robust, you need a way to determine if the saved state is still good.

Option A: File Modification Timestamp

A common approach is to use the file’s last modified timestamp. If the file is older than a certain threshold (e.g., 24 hours), regenerate it.

Modify the login.spec.ts fixture:

// tests/login.spec.ts (Modified fixture)
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/loginPage';
import * as fs from 'fs';
import * as path from 'path';

const STORAGE_STATE_PATH = 'playwright/.auth/userState.json';
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours

const test = base.extend({
  storageState: async ({ page }, use) => {
    let needsLogin = true;

    if (fs.existsSync(STORAGE_STATE_PATH)) {
      const stats = fs.statSync(STORAGE_STATE_PATH);
      const now = new Date().getTime();
      const modified = stats.mtime.getTime();

      if (now - modified < MAX_AGE_MS) {
        console.log('Using existing and valid storage state.');
        needsLogin = false;
      } else {
        console.log('Storage state is too old. Regenerating.');
      }
    } else {
      console.log('Storage state file not found. Generating.');
    }

    if (needsLogin) {
      const loginPage = new LoginPage(page);
      await loginPage.goto();
      await loginPage.login('testuser', 'password123'); // Replace with actual credentials

      // Ensure the directory exists
      const dir = path.dirname(STORAGE_STATE_PATH);
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
      }

      await page.context().storageState({ path: STORAGE_STATE_PATH });
      console.log(`New storage state saved to ${STORAGE_STATE_PATH}`);
    }

    await use(STORAGE_STATE_PATH);
  },
});

// ... rest of login.spec.ts

Explanation:

  • We import fs and path for file system operations.
  • MAX_AGE_MS defines how old the storageState.json can be before we regenerate it.
  • fs.existsSync checks if the file is present.
  • fs.statSync retrieves file statistics, including mtime (modification time).
  • We compare the current time with the file’s modification time. If it’s within MAX_AGE_MS, we skip the login.

Option B: Application-Specific Validation

For more critical applications, you might want to perform a quick check within the application itself. After loading the storageState, navigate to a known protected page and assert a specific element or condition that only appears when logged in.

Modify the login.spec.ts fixture:

// tests/login.spec.ts (Application-specific validation)
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/loginPage';
import * as fs from 'fs';
import * as path from 'path';

const STORAGE_STATE_PATH = 'playwright/.auth/userState.json';

const test = base.extend({
  storageState: async ({ page }, use) => {
    let needsLogin = true;

    if (fs.existsSync(STORAGE_STATE_PATH)) {
      // Try to load the state and perform a quick validation
      try {
        // Temporarily set the storage state to perform validation
        await page.context().storageState({ path: STORAGE_STATE_PATH });

        // Navigate to a page that requires authentication
        await page.goto('/dashboard'); // Or a simple /me endpoint

        // Assert a condition that proves login is active
        // e.g., presence of a user avatar, a specific welcome message, or a logout button
        await page.waitForSelector('button:has-text("Logout")', { timeout: 5000 }); // Adjust selector and timeout

        console.log('Using existing and valid storage state.');
        needsLogin = false; // State is valid
      } catch (error) {
        console.log('Storage state is invalid or expired. Regenerating.', error);
        // If validation fails, needsLogin remains true
      }
    } else {
      console.log('Storage state file not found. Generating.');
    }

    if (needsLogin) {
      const loginPage = new LoginPage(page);
      await loginPage.goto();
      await loginPage.login('testuser', 'password123'); // Replace with actual credentials

      const dir = path.dirname(STORAGE_STATE_PATH);
      if (!fs.existsSync(dir)) {
        fs.mkdirSync(dir, { recursive: true });
      }

      await page.context().storageState({ path: STORAGE_STATE_PATH });
      console.log(`New storage state saved to ${STORAGE_STATE_PATH}`);
    }

    await use(STORAGE_STATE_PATH);
  },
});

// ... rest of login.spec.ts

Explanation:

  • When the storageState.json exists, we first attempt to load it into the page.context().
  • Then, we navigate to a protected page (/dashboard) and wait for a specific element (like a "Logout" button) that indicates a successful login session.
  • If waitForSelector succeeds within a short timeout, we consider the state valid and set needsLogin to false.
  • If waitForSelector times out or throws an error, the catch block executes, and needsLogin remains true, triggering a fresh login.

4. Running Your Tests

To run your tests and ensure the state is generated only when needed:

  1. First Run (or after deleting storageState.json):

    • Playwright will detect that playwright/.auth/userState.json doesn’t exist.
    • It will execute the storageState fixture in login.spec.ts.
    • The login will be performed, and playwright/.auth/userState.json will be created.
    • Then, profile.spec.ts (and any other tests using storageState) will run, loading the newly created state.
  2. Subsequent Runs (if storageState.json is valid):

    • Playwright will detect playwright/.auth/userState.json exists and is considered valid by your chosen validation logic.
    • It will load the existing state.
    • The login steps in login.spec.ts will be skipped.
    • profile.spec.ts will run directly, using the pre-authenticated session.
  3. Running Specific Tests:

    • If you run npx playwright test tests/login.spec.ts, it will execute the login and save the state.
    • If you run npx playwright test tests/profile.spec.ts, it will first check/generate the state (via the storageState fixture) and then run the profile tests.

5. Managing Credentials

Hardcoding credentials ('testuser', 'password123') in tests is generally bad practice. Consider these alternatives:

  • Environment Variables: Load credentials from process.env.PLAYWRIGHT_TEST_USER and process.env.PLAYWRIGHT_TEST_PASSWORD.
  • Secrets Management: Use a dedicated secrets manager if available in your CI/CD environment.
  • Dedicated Test Account: Use a specific test account with predictable credentials.

The Next Step: Handling Multiple User States

Once you’ve mastered conditional state generation for a single user, you’ll likely encounter the need to test with different user roles or accounts. The next logical step is to extend this pattern to manage multiple storageState.json files, perhaps keyed by username or role, and dynamically select which state to load based on test parameters or configurations.

Want structured learning?

Take the full Playwright course →