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:
- Initial Login: A test or a setup function logs a user in.
- Save State: The authentication tokens (cookies, local storage) are saved to a
storageState.jsonfile. - Subsequent Tests: All other tests in the suite load this
storageState.jsonand 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.extendto create a custom fixture namedstorageState. - This fixture is an
asyncfunction that receivespageas 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 returnundefinedor throw an error depending on Playwright’s internal handling. A more robust check is to usefs.existsSync. - If the file exists, we
await use(storageStatePath), telling Playwright to load this existing state for the tests that depend on thestorageStatefixture. - 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
storageStatein the test function signature (async ({ page, storageState })), Playwright knows to apply thestorageStatefixture logic. - If
login.spec.tshas already generated a validstorageState.jsonand it’s still valid, these tests will load that state and bypass the login form. - If the
storageState.jsonis missing or deemed invalid by our logic, Playwright will first execute thestorageStatefixture fromlogin.spec.tsto 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
fsandpathfor file system operations. MAX_AGE_MSdefines how old thestorageState.jsoncan be before we regenerate it.fs.existsSyncchecks if the file is present.fs.statSyncretrieves file statistics, includingmtime(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.jsonexists, we first attempt to load it into thepage.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
waitForSelectorsucceeds within a shorttimeout, we consider the state valid and setneedsLogintofalse. - If
waitForSelectortimes out or throws an error, thecatchblock executes, andneedsLoginremainstrue, triggering a fresh login.
4. Running Your Tests
To run your tests and ensure the state is generated only when needed:
-
First Run (or after deleting
storageState.json):- Playwright will detect that
playwright/.auth/userState.jsondoesn’t exist. - It will execute the
storageStatefixture inlogin.spec.ts. - The login will be performed, and
playwright/.auth/userState.jsonwill be created. - Then,
profile.spec.ts(and any other tests usingstorageState) will run, loading the newly created state.
- Playwright will detect that
-
Subsequent Runs (if
storageState.jsonis valid):- Playwright will detect
playwright/.auth/userState.jsonexists and is considered valid by your chosen validation logic. - It will load the existing state.
- The login steps in
login.spec.tswill be skipped. profile.spec.tswill run directly, using the pre-authenticated session.
- Playwright will detect
-
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 thestorageStatefixture) and then run the profile tests.
- If you run
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_USERandprocess.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.