Skip to main content

How to Test Login Flows With Playwright: Complete Example

Login is often the most critical user journey in a web application—and the most vulnerable to bugs. Testing login flows end-to-end with Playwright ensures that your authentication, session management, redirect logic, and error handling all work correctly across browsers. This guide walks you through testing typical login scenarios: successful login, invalid credentials, validation errors, and multi-factor authentication stubs.

The Anatomy of a Login Test

A login test follows a standard pattern: fill the login form with email and password, submit, and verify that the user is redirected to the authenticated dashboard. Here's a minimal example:

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

test('user can log in with valid credentials', async ({ page }) => {
// Navigate to the login page
await page.goto('/login');

// Fill the form
await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');

// Submit
await page.locator('role=button[name="Sign In"]').click();

// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');

// Verify the dashboard loaded
await expect(page.locator('h1')).toContainText('Welcome, user');
});

This test is straightforward but incomplete—it doesn't verify the application's security posture or test error cases. Real login tests need multiple scenarios.

Testing Valid Login with Session Persistence

After login, the user's authentication token is typically stored in a cookie, localStorage, or sessionStorage. Verify that the session persists across navigations:

test('user session persists across page navigations', async ({ page, context }) => {
// Log in
await page.goto('/login');
await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();

// Wait for redirect
await expect(page).toHaveURL('/dashboard');

// Verify auth token is in localStorage (common pattern)
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeTruthy();

// Navigate away and back—session should still be valid
await page.goto('/settings');
await expect(page.locator('h1')).toContainText('Settings');

// Navigate to protected route—should not redirect to login
await page.goto('/dashboard');
await expect(page).toHaveURL('/dashboard');
});

This test catches issues where users get logged out unexpectedly after navigation—a common bug in SPA (Single-Page Application) authentication.

Testing Invalid Credentials and Error Messages

Users will enter wrong passwords. Your login page must display clear error messages without exposing sensitive information:

test('login fails with invalid password', async ({ page }) => {
await page.goto('/login');

await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('WrongPassword');
await page.locator('role=button[name="Sign In"]').click();

// Expect error message (no redirect)
const errorMsg = page.locator('[data-testid="error-message"]');
await expect(errorMsg).toBeVisible();
await expect(errorMsg).toContainText('Invalid email or password');

// User should still be on login page
await expect(page).toHaveURL('/login');
});

test('login fails with nonexistent email', async ({ page }) => {
await page.goto('/login');

await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('SomePassword123');
await page.locator('role=button[name="Sign In"]').click();

// Server returns same generic error (don't reveal if email exists)
const errorMsg = page.locator('[data-testid="error-message"]');
await expect(errorMsg).toContainText('Invalid email or password');
});

Security best practice: return the same error for both "user not found" and "wrong password" to prevent attackers from enumerating valid email addresses.

Testing Client-Side Validation

Many login forms validate input before submitting (email format, minimum password length). Test these validations:

test('form validation prevents submission with invalid email', async ({ page }) => {
await page.goto('/login');

await page.locator('[data-testid="email-input"]').fill('not-an-email');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');

// Submit button should be disabled, or form shouldn't submit
const submitButton = page.locator('role=button[name="Sign In"]');

// If disabled attribute is used:
await expect(submitButton).toBeDisabled();

// Or check for validation message
const emailInput = page.locator('[data-testid="email-input"]');
const validationMsg = await emailInput.getAttribute('aria-invalid');
expect(validationMsg).toBeTruthy();
});

test('form validation requires password length', async ({ page }) => {
await page.goto('/login');

await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('abc'); // too short

// Expect validation error
const passwordInput = page.locator('[data-testid="password-input"]');
const errorText = await passwordInput.getAttribute('aria-describedby');
expect(errorText).toBeTruthy();
});

Testing validation prevents bugs where the form accidentally allows invalid input to submit, or validation messages are unclear.

Testing Multi-Step or Multi-Factor Authentication

Some login flows require additional steps (e.g., email verification code, TOTP). Test the entire flow:

test('user can complete login with email verification code', async ({ page }) => {
// Step 1: Email and password
await page.goto('/login');
await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();

// Step 2: Redirect to verification code page
await expect(page).toHaveURL('/verify-code');
await expect(page.locator('h1')).toContainText('Enter Verification Code');

// Step 3: Enter the code (in real tests, you'd retrieve this from your test backend)
await page.locator('[data-testid="code-input"]').fill('123456');
await page.locator('role=button[name="Verify"]').click();

// Step 4: Redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});

For production tests, integrate with your test infrastructure to programmatically generate valid verification codes (via test API endpoints) instead of hardcoding them.

Testing Session Cleanup and Logout

Verify that logout properly clears the session:

test('user is logged out after clicking logout', async ({ page }) => {
// First, log in (we'll cover reusing auth in the next article)
await page.goto('/login');
await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();

await expect(page).toHaveURL('/dashboard');

// Click logout
await page.locator('[data-testid="logout-button"]').click();

// Verify redirect to login
await expect(page).toHaveURL('/login');

// Verify auth token is cleared
const token = await page.evaluate(() => localStorage.getItem('authToken'));
expect(token).toBeNull();

// Verify trying to access dashboard redirects to login
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});

This test ensures that logout is more than just a redirect—the session state is actually cleared.

Testing Protected Routes and Access Control

Verify that unauthenticated users can't access protected pages:

test('unauthenticated user is redirected to login', async ({ page, context }) => {
// Clear all auth data to simulate an unauthenticated session
await context.clearCookies();
await page.evaluate(() => localStorage.clear());

// Try to access a protected route
await page.goto('/dashboard');

// Should redirect to login
await expect(page).toHaveURL('/login');
});

test('user with insufficient permissions sees error', async ({ page }) => {
// Log in as a basic user (we'll cover selective login in the next article)
await page.goto('/login');
await page.locator('[data-testid="email-input"]').fill('[email protected]');
await page.locator('[data-testid="password-input"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();

await expect(page).toHaveURL('/dashboard');

// Try to access an admin page
await page.goto('/admin');

// Should show access denied or redirect
await expect(page.locator('text=Access Denied|Forbidden')).toBeVisible();
});

Access control testing catches authorization bypass bugs—critical for security.

Common Pitfalls and Solutions

Hardcoded test credentials: Don't hardcode passwords. Instead, use a test user API endpoint or environment variables to create users dynamically.

test('login with dynamically created test user', async ({ page }) => {
// Create a test user via API
const response = await page.request.post('https://api.example.com/test/create-user', {
data: { email: `test-${Date.now()}@example.com`, password: 'TestPassword123' }
});
const { email, password } = await response.json();

// Log in
await page.goto('/login');
await page.locator('[data-testid="email-input"]').fill(email);
await page.locator('[data-testid="password-input"]').fill(password);
await page.locator('role=button[name="Sign In"]').click();

await expect(page).toHaveURL('/dashboard');
});

Race condition between API response and redirect: Playwright automatically waits, but being explicit helps:

// Bad: assuming redirect happens immediately
await page.locator('role=button[name="Sign In"]').click();
await expect(page).toHaveURL('/dashboard'); // might fail if API is slow

// Good: wait for the network request to complete
const loginPromise = page.waitForResponse(
response => response.url().includes('/api/login') && response.status() === 200
);
await page.locator('role=button[name="Sign In"]').click();
await loginPromise;
await expect(page).toHaveURL('/dashboard');

Key Takeaways

  • Test successful login with session persistence to ensure auth tokens are created and maintained.
  • Test invalid credentials with generic error messages (don't reveal if email exists).
  • Validate client-side form validation to catch user input errors early.
  • Test multi-step flows (MFA, verification codes) by stepping through each page.
  • Test logout and access control to prevent authorization bypass bugs.

Frequently Asked Questions

How do I avoid hardcoding test credentials?

Create test users via an API endpoint in your test backend or use a dedicated test authentication service. Alternatively, use environment variables for a shared test account, but rotate passwords regularly.

How do I test Google/GitHub OAuth login?

For social login, you have two options: (1) mock the OAuth flow by intercepting network requests (covered in the network mocking article), or (2) create a test account on the OAuth provider and log in. The mock approach is faster; the real approach is more thorough but slower.

Should I test error pages like 500 errors?

Yes, but in a separate test. Use page.route() to simulate server errors and verify your error page displays correctly and allows the user to retry.

How long should a login test take?

A single login test typically takes 200–800 ms. If it's taking seconds, your app's login API is slow—investigate performance before assuming the test is fine.

Can I test expired sessions and token refresh?

Yes, by mocking time. Use context.addInitScript() to set a past expiration on the auth token, then trigger a network request. Playwright will wait for the refresh to complete.

Further Reading