15 Days of Playwright - Day 6

Stop testing login forms repeatedly. Learn how Playwright's authentication management lets you bypass UI login, reuse sessions, and make your test suites 10x faster.

15 Days of Playwright - Day 6
Jhonatas Matos

Jhonatas Matos

You know what's more frustrating than testing file uploads? Testing login flows over and over again. I used to dread seeing those login forms in my tests - they were slow, flaky, and made test suites painfully repetitive.

But here's the game-changer: Playwright's authentication management features completely transformed how I handle logged-in states. Today, I will show you how to bypass UI login and focus testing on what really matters - your application's functionality.

Authentication Management in Playwright

Playwright provides a robust API for managing authentication, allowing you to easily set and clear authentication states across your tests. This means you can log in once and reuse the session in multiple tests, significantly speeding up your test execution.

The Problem with Login UI Testing

Why testing login UI is a anti-pattern:

  • Time-consuming: 5-10 seconds per test just for login
  • Flaky: Network issues, CAPTCHAs, 2FA can break tests
  • Repetitive: Same login code duplicated across tests
  • Slow feedback: Longer test runs delay development

The solution? Authenticate once, reuse everywhere.

Understanding storageState

Playwright's storageState feature allows you to capture and reuse the browser's authentication state, including cookies and local storage. This means you can log in once, save the state, and then restore it in subsequent tests.

  • Cookies
  • Local storage
  • Session storage
  • Origin-specific data

Basic Pattern:

Log in to your application and capture the storageState.

// 1. Authenticate once
await page.goto('/login');
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'password123');
await page.click('#login-button');

// 2. Save authentication state
const storageState = await page.context().storageState();
fs.writeFileSync('auth.json', JSON.stringify(storageState));

Reuse Across Tests:

Use the captured state in your tests to bypass the login UI.

// 3. Reuse in any test
test.use({ storageState: 'auth.json' });

test('access protected page', async ({ page }) => {
  await page.goto('/dashboard'); // Already logged in!
  await expect(page).toHaveURL(/dashboard/);
});

Authentication Strategies

When it comes to authentication in Playwright, you have several approaches depending on your needs. Each method has its own strengths and use cases. Let me walk you through the most effective strategies I've used in real projects.

Method 1: UI Login (Once)

Perfect for applications where API authentication isn't available or when you need to test the actual login flow once. This approach mimics real user behavior but only does it once, saving the state for reuse.

// authenticate.setup.js
import { test as setup } from '@playwright/test';
import fs from 'fs';

setup('authenticate', async ({ page }) => {
  await page.goto('https://bugbank.netlify.app');
  await page.getByText('Acessar').click();
  
  await page.fill('input[name="email"]', 'user@example.com');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button:has-text("Acessar")');
  
  // Wait for login completion
  await expect(page.locator('.welcome-message')).toBeVisible();
  
  // Save authentication state
  const storageState = await page.context().storageState();
  fs.writeFileSync('auth.json', JSON.stringify(storageState));
});

Method 2: API Authentication

The fastest and most reliable approach when you have API access. This bypasses the UI completely and is perfect for CI/CD environments where speed matters.

// auth-api.setup.js
import { test as setup } from '@playwright/test';
import fs from 'fs';

setup('api authenticate', async ({ request }) => {
  // Login via API
  const response = await request.post('https://bugbank.netlify.app/api/login', {
    data: {
      email: 'user@example.com',
      password: 'password123'
    }
  });
  
  // Get cookies from response
  const cookies = await response.headers()['set-cookie'];
  
  // Create storage state manually
  const storageState = {
    cookies: cookies.map(cookie => ({
      name: cookie.split('=')[0],
      value: cookie.split('=')[1],
      domain: 'bugbank.netlify.app',
      path: '/'
    })),
    origins: []
  };
  
  fs.writeFileSync('auth-api.json', JSON.stringify(storageState));
});

Method 3: Multiple User Roles

Essential for testing permission-based applications. This approach lets you switch between different user contexts seamlessly, perfect for testing admin vs user functionality.

// multi-auth.setup.js
import { test as setup } from '@playwright/test';
import fs from 'fs';

const users = [
  { role: 'admin', email: 'admin@bugbank.com', password: 'admin123' },
  { role: 'user', email: 'user@bugbank.com', password: 'user123' },
  { role: 'viewer', email: 'viewer@bugbank.com', password: 'viewer123' }
];

for (const user of users) {
  setup(`authenticate as ${user.role}`, async ({ page }) => {
    await page.goto('https://bugbank.netlify.app');
    await page.getByText('Acessar').click();
    
    await page.fill('input[name="email"]', user.email);
    await page.fill('input[name="password"]', user.password);
    await page.click('button:has-text("Acessar")');
    
    await expect(page.locator('.welcome-message')).toBeVisible();
    
    const storageState = await page.context().storageState();
    fs.writeFileSync(`${user.role}-auth.json`, JSON.stringify(storageState));
  });
}

Real-World Test Scenarios

Theory is good, but practice is everything. Let me show you how these authentication strategies work in real testing scenarios. These are patterns I've used successfully in multiple projects.

Scenario 1: Admin Dashboard Access

Testing role-based access control is crucial for security. This scenario ensures admins can access privileged areas while preventing unauthorized access.

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

test.use({ storageState: 'admin-auth.json' });

test('admin can access dashboard', async ({ page }) => {
  await page.goto('https://bugbank.netlify.app/admin/dashboard');
  
  // Already logged in as admin
  await expect(page.locator('.admin-panel')).toBeVisible();
  await expect(page.locator('.user-management')).toBeVisible();
  
  // Verify admin privileges
  await expect(page.locator('button:has-text("Delete User")')).toBeVisible();
});

Scenario 2: User Profile Operations

Testing user-specific functionality requires proper authentication state. This ensures users can perform actions within their permissions.

test.use({ storageState: 'user-auth.json' });

test('user can update profile', async ({ page }) => {
  await page.goto('https://bugbank.netlify.app/profile');
  
  // Already logged in as regular user
  await page.fill('#displayName', 'New Display Name');
  await page.click('#save-profile');
  
  await expect(page.locator('.success-message'))
    .toHaveText('Profile updated successfully');
  
  // Verify user cannot access admin features
  await page.goto('https://bugbank.netlify.app/admin/dashboard');
  await expect(page.locator('.access-denied')).toBeVisible();
});

Scenario 3: Multiple Sessions

Testing concurrent user interactions reveals race conditions and permission issues. This approach simulates real-world multi-user environments.

test('multiple user interactions', async ({ browser }) => {
  // Create two contexts with different auth states
  const adminContext = await browser.newContext({ storageState: 'admin-auth.json' });
  const userContext = await browser.newContext({ storageState: 'user-auth.json' });
  
  const adminPage = await adminContext.newPage();
  const userPage = await userContext.newPage();
  
  // Admin performs action
  await adminPage.goto('https://bugbank.netlify.app/admin/users');
  await adminPage.click('button:has-text("Create User")');
  
  // User sees result
  await userPage.goto('https://bugbank.netlify.app/notifications');
  await expect(userPage.locator('.notification'))
    .toContainText('New user created by admin');
  
  await adminContext.close();
  await userContext.close();
});

Advanced Techniques

When basic authentication isn't enough, these advanced techniques handle complex real-world scenarios.

Dynamic Authentication

For unreliable networks or flaky auth services, retry mechanisms ensure tests don't fail due to temporary issues.

// auth-manager.js
export class AuthManager {
  static async loginWithRetry(page, credentials, maxRetries = 3) {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        await page.goto('https://bugbank.netlify.app/login');
        await page.fill('input[name="email"]', credentials.email);
        await page.fill('input[name="password"]', credentials.password);
        await page.click('button:has-text("Acessar")');
        
        await expect(page.locator('.welcome-message')).toBeVisible({
          timeout: 10000
        });
        
        return await page.context().storageState();
      } catch (error) {
        if (attempt === maxRetries) throw error;
        console.log(`Login attempt ${attempt} failed, retrying...`);
      }
    }
  }
}

Authentication Refresh

For applications with token expiration, automatic refresh ensures tests don't break due to stale authentication.

// auth-refresh.setup.js
import { test as setup } from '@playwright/test';
import fs from 'fs';

setup('refresh authentication', async ({ page }) => {
  // Check if auth file exists and is recent
  if (fs.existsSync('auth.json')) {
    const stats = fs.statSync('auth.json');
    const hoursOld = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60);
    
    if (hoursOld < 24) {
      console.log('Using recent authentication state');
      return;
    }
  }
  
  // Re-authenticate if stale
  await page.goto('https://bugbank.netlify.app/login');
  await page.fill('input[name="email"]', process.env.TEST_USER);
  await page.fill('input[name="password"]', process.env.TEST_PASSWORD);
  await page.click('button:has-text("Acessar")');
  
  await expect(page.locator('.welcome-message')).toBeVisible();
  
  const storageState = await page.context().storageState();
  fs.writeFileSync('auth.json', JSON.stringify(storageState));
});

Best Practices

Following these practices will save you hours of debugging and make your tests more reliable.

Secure Credential Management

Never expose sensitive credentials in your codebase. Use environment variables or secret management systems.

// Never hardcode credentials!
// Use environment variables or secret management
test.use({
  storageState: process.env.TEST_AUTH_STATE || 'default-auth.json'
});

Organized Auth Files

Maintain a clear structure for different environments and user roles to avoid confusion.

// Organize by environment and role
const authPaths = {
  staging: {
    admin: 'auth/staging-admin.json',
    user: 'auth/staging-user.json'
  },
  production: {
    admin: 'auth/prod-admin.json',
    user: 'auth/prod-user.json'
  }
};

Validation and Error Handling

Verify authentication is still valid before running tests to avoid false failures.

test.beforeEach(async ({ page }) => {
  // Verify authentication is still valid
  await page.goto('https://bugbank.netlify.app/validate-auth');
  const isValid = await page.evaluate(() => {
    return window.localStorage.getItem('authValid') === 'true';
  });
  
  if (!isValid) {
    throw new Error('Authentication expired, please re-run auth setup');
  }
});

Debugging Authentication Issues

When authentication fails, these debugging techniques help identify the root cause quickly.

Debug Authentication State

Inspect the current authentication state to understand what's stored and what might be missing.

// Debug authentication state
test('debug auth state', async ({ page }) => {
  console.log('Current storage state:');
  console.log(await page.context().storageState());
  
  // Check specific cookies
  const cookies = await page.context().cookies();
  console.log('Authentication cookies:', cookies.filter(c => c.name.includes('auth')));
  
  // Check local storage
  const localStorage = await page.evaluate(() => {
    return JSON.stringify(window.localStorage);
  });
  console.log('Local storage:', localStorage);
});

The best way to learn is by doing! I've prepared a complete authentication setup with multiple user roles:

Link to GitHub projectGitHub Octocat

Conclusion

Authentication doesn't have to be your testing bottleneck. With Playwright's storage management, you can:

10x Faster Tests

  • Eliminate repetitive login UI interactions
  • Parallelize tests without login conflicts
  • Reduce test suite runtime significantly

Focus on What Matters

  • Test application functionality, not login forms
  • Simulate real user scenarios with proper auth states
  • Cover edge cases with different user roles

Maintainable Test Architecture

  • Centralized authentication setup
  • Easy role-based testing
  • Simple credential rotation

Remember: The goal isn't to avoid testing authentication entirely, but to test it strategically. Use UI login tests for auth flow validation, but use storage state for everything else.


Thank you for reading and see you in the next lesson! ☕💜