Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 036 / 100

Testing authentication flows: what developers should know

Login, logout and password reset.

Expona Admin9 min read

Testing authentication flows: what developers should know

Authentication is often treated as a black box in end-to-end testing—something we skip because it feels complex or because we assume the library handles it. In reality, a broken login flow or a session that doesn't expire properly can take your entire application offline for users. Testing authentication flows: what developers should know is essential knowledge for anyone building reliable applications, and Playwright makes it straightforward once you understand the patterns.

Over the next few sections, we'll walk through the core authentication scenarios you need to test: login with happy and sad paths, session expiry handling, password reset workflows, and OAuth integration. We'll show you concrete examples that you can adapt to your stack, whether you're using a traditional session-based architecture or modern token-based authentication.

Login — Happy path and wrong password

The login flow is your first line of defense. A successful login isn't just about the user entering credentials; it's about the entire journey: form submission, credential validation, session creation, and redirect to a protected page. Testing both the happy path and error states ensures that your authentication layer works as intended.

Start with the happy path—a valid email and password should log the user in and redirect them to the dashboard or home page.

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

test('login with valid credentials', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'ValidPassword123!');
  await page.click('button[type="submit"]');
  
  // Wait for navigation to complete
  await page.waitForURL('http://localhost:3000/dashboard');
  
  // Assert we're on the dashboard
  expect(page.url()).toBe('http://localhost:3000/dashboard');
  
  // Assert a protected element is visible
  await expect(page.locator('text=Welcome')).toBeVisible();
});

This test covers the essentials: filling credentials, submitting, and verifying the redirect. Now test the failure case—wrong password should display an error and keep the user on the login page.

typescript
test('login with wrong password shows error', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'WrongPassword123!');
  await page.click('button[type="submit"]');
  
  // Should not navigate away
  expect(page.url()).toBe('http://localhost:3000/login');
  
  // Error message should be visible
  await expect(page.locator('text=Invalid credentials')).toBeVisible();
});

These two tests form the backbone of your login testing. They're fast, reliable, and catch the most common issues. In production, you'd also test edge cases like empty fields, SQL injection attempts, and rate limiting—but start here.

Session expiry — Clear cookies and assert redirect

Sessions don't last forever. When a session expires, users should be redirected to login or presented with a re-authentication prompt. Testing session expiry requires you to manipulate the browser's cookie store and verify that the application handles the expired state correctly.

In Playwright, you can add and delete cookies directly. This lets you simulate an expired session without waiting for time to pass.

typescript
test('expired session redirects to login', async ({ page, context }) => {
  // First, log in normally to establish a valid session
  await page.goto('http://localhost:3000/login');
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'ValidPassword123!');
  await page.click('button[type="submit"]');
  await page.waitForURL('http://localhost:3000/dashboard');
  
  // Get the session cookie
  const cookies = await context.cookies();
  const sessionCookie = cookies.find(c => c.name === 'session_id');
  
  // Simulate expiry by deleting the session cookie
  await context.clearCookies({ name: 'session_id' });
  
  // Navigate to a protected page
  await page.goto('http://localhost:3000/dashboard');
  
  // Should redirect to login
  await page.waitForURL('http://localhost:3000/login');
  expect(page.url()).toContain('/login');
});

This approach is powerful because it isolates the session logic from your login tests. You're not testing login again; you're testing that the application correctly interprets an absent or invalid session token.

For token-based auth (JWT), you can modify the token to be invalid or expired before storing it:

typescript
test('expired JWT token triggers re-authentication', async ({ page, context }) => {
  // Assume we've already authenticated and have a valid JWT
  const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
  
  // Set the expired token in localStorage
  await page.goto('http://localhost:3000/dashboard');
  await page.evaluate(token => {
    localStorage.setItem('auth_token', token);
  }, expiredToken);
  
  // Refresh to trigger token validation
  await page.reload();
  
  // Should redirect to login or show a re-auth prompt
  await expect(page.locator('text=Session expired')).toBeVisible();
});

Session expiry testing is often overlooked but catches silent auth failures that can leave users stranded or expose protected data.

Password reset — Read reset emails from a test inbox API

Password reset workflows involve email delivery, which makes them tricky to test end-to-end. The solution is to use a test email service like Mailhog, Mailtrap, or SendGrid's test mode that provides an API to read sent emails.

Here's a realistic flow: user requests a reset, we fetch the reset link from a test inbox, and we verify the reset works.

typescript
test('password reset flow completes successfully', async ({ page, request }) => {
  const testEmail = 'reset-test@example.com';
  
  // Step 1: Request password reset
  await page.goto('http://localhost:3000/forgot-password');
  await page.fill('input[name="email"]', testEmail);
  await page.click('button:has-text("Send reset link")');
  
  // Step 2: Assert success message
  await expect(page.locator('text=Check your email')).toBeVisible();
  
  // Step 3: Fetch the reset email from test inbox
  const emailResponse = await request.get(
    `http://mailhog:8025/api/v2/search?kind=to&query=${testEmail}`
  );
  const emails = await emailResponse.json();
  expect(emails.items.length).toBeGreaterThan(0);
  
  // Extract reset link from email body
  const emailBody = emails.items[0].Content.Body;
  const resetLinkMatch = emailBody.match(/reset-password\?token=([a-zA-Z0-9_-]+)/);
  const resetToken = resetLinkMatch[1];
  
  // Step 4: Navigate to reset page with token
  await page.goto(`http://localhost:3000/reset-password?token=${resetToken}`);
  
  // Step 5: Enter new password
  await page.fill('input[name="password"]', 'NewPassword123!');
  await page.fill('input[name="confirmPassword"]', 'NewPassword123!');
  await page.click('button:has-text("Reset password")');
  
  // Step 6: Verify redirect to login
  await page.waitForURL('http://localhost:3000/login');
  
  // Step 7: Verify login with new password
  await page.fill('input[name="email"]', testEmail);
  await page.fill('input[name="password"]', 'NewPassword123!');
  await page.click('button[type="submit"]');
  await page.waitForURL('http://localhost:3000/dashboard');
});

This test validates the entire password reset lifecycle. The key trick is using the email API to extract the reset token—without it, you'd be stuck manually checking emails. Most test email services provide an API for exactly this purpose.

If you're using a service like Mailtrap, the API call looks similar but the response format differs. Always consult your email service's documentation for the correct endpoint and response shape.

OAuth — Mock the provider or use test accounts

OAuth flows (Google, GitHub, Microsoft) add complexity because they involve redirects to external providers. You have two options: mock the OAuth provider or use dedicated test accounts provided by the provider.

Option 1: Mock the OAuth provider

Use Playwright's route interception to mock the OAuth provider's responses. This approach is fast and doesn't depend on external services staying online.

typescript
test('login with mocked OAuth provider', async ({ page }) => {
  // Mock the OAuth authorization endpoint
  await page.route('https://accounts.google.com/o/oauth2/v2/auth*', route => {
    // Intercept the redirect and simulate provider behavior
    route.continue();
  });
  
  // Mock the token exchange endpoint
  await page.route('https://oauth2.googleapis.com/token', route => {
    route.abort(); // Or respond with a mocked token
  });
  
  // Your test would then verify the OAuth flow
  await page.goto('http://localhost:3000/login');
  await page.click('button:has-text("Sign in with Google")');
  
  // At this point, your app should handle the mocked OAuth response
});

While powerful, mocking can become fragile if the OAuth flow changes. A better approach for most teams is to use test accounts provided by the OAuth provider.

Option 2: Use test accounts

Most major OAuth providers offer test credentials. Google, GitHub, and Microsoft all allow you to create test applications with dedicated test user accounts. This approach tests the real OAuth flow without relying on production accounts.

typescript
test('login with real OAuth test account', async ({ page }) => {
  const testGoogleEmail = 'test-oauth@gmail.com';
  const testGooglePassword = process.env.GOOGLE_TEST_PASSWORD;
  
  await page.goto('http://localhost:3000/login');
  await page.click('button:has-text("Sign in with Google")');
  
  // Google redirects to their login page
  await page.waitForURL('https://accounts.google.com/**');
  
  // Fill in test credentials on Google's login form
  await page.fill('input[name="identifier"]', testGoogleEmail);
  await page.click('button:has-text("Next")');
  
  await page.fill('input[type="password"]', testGooglePassword);
  await page.click('button:has-text("Next")');
  
  // Handle any 2FA or consent screens if needed
  if (page.url().includes('consent.google.com')) {
    await page.click('button:has-text("Allow")');
  }
  
  // Should redirect back to your app with authorization code
  await page.waitForURL('http://localhost:3000/dashboard');
  expect(page.url()).toBe('http://localhost:3000/dashboard');
});

This test hits the real OAuth flow end-to-end. It's slower than mocking, but it catches real issues that mocking might miss—like incorrect redirect URIs or scope misconfigurations.

For most production testing, we recommend mocking OAuth for CI/CD pipelines (fast, reliable) and running real OAuth tests occasionally or manually (catches integration issues).

Common mistakes

  • Not waiting for navigation after login: Use waitForURL() or waitForNavigation() to ensure the redirect completes before asserting. Without this, your test might pass flakily.
  • Hardcoding credentials: Always use environment variables for test credentials. Never commit passwords to your repository, even for test accounts.
  • Ignoring CSRF tokens: If your app uses CSRF protection, you need to extract the token from the login form and include it in the request. Playwright handles this if you're testing via the UI, but it matters for API-based tests.
  • Testing only the happy path: Wrong password, expired sessions, and malformed tokens are where real bugs hide. Test the sad paths equally.
  • Not cleaning up test data: After password reset or OAuth tests, clean up test accounts or reset their state. Leftover test data can cause flaky tests later.
  • Mocking without understanding OAuth flow: Before you mock OAuth, understand the real flow (authorization code, token exchange, redirect). Incomplete mocks will miss bugs.

Key takeaways

  • Test login with both valid and invalid credentials to catch authentication logic bugs early.
  • Simulate session expiry by removing cookies or tokens directly; don't wait for them to expire naturally.
  • Use test email APIs to extract password reset tokens automatically, making password reset workflows testable end-to-end.
  • For OAuth, start with test accounts provided by the provider; mock only when necessary for speed in CI/CD.
  • Always use environment variables for credentials and sensitive test data; never hardcode them.
  • Test error states and edge cases as rigorously as the happy path—authentication failures are high-impact bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles