Skip to main content
Test Double company logo
Services
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say Hello
Test Double logo
Menu
Services
BackGrid of dots icon
Services Overview
Holistic software investment consulting
Software Delivery
Accelerate quality software development
Product Management
Launch modern product orgs
Legacy Modernization
Renovate legacy software systems
Cycle icon
DevOps
Scale infrastructure smoothly
Upgrade Rails
Update Rails versions seamlessly
Technical Recruitment
Build tech & product teams
Technical Assessments
Uncover root causes & improvements
Case Studies
Solutions
Solutions
Accelerate Quality Software
Software Delivery, DevOps, & Product Delivery
Maximize Software Investments
Product Performance, Product Scaling, & Technical Assessments
Future-Proof Innovative Software
Legacy Modernization, Product Transformation, Upgrade Rails, Technical Recruitment
About
About
About
What's a test double?
Approach
Meeting you where you are
Founder's Story
The origin of our mission
Culture
Culture
Culture & Careers
Double Agents decoded
Great Causes
Great code for great causes
EDI
Equity, diversity & inclusion
Insights
Insights
All Insights
Hot takes and tips for all things software
Leadership
Bold opinions and insights for tech leaders
Developer
Essential coding tutorials and tools
Product Manager
Practical advice for real-world challenges
Say hello
Developers
Developers
Developers
Testing

End-to-End Testing Auth Flows with Playwright and Next.js

Streamline authentication testing in your Next.js applications, regardless of your auth provider. This practical guide demonstrates how to use Playwright's powerful features to create reusable test helpers, implement efficient session caching, and debug with confidence.
Dave Mosher
|
June 3, 2025
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Interested in watching or checking out some code more than reading? No problem, we've got you covered with a screencast and some sample code.

‍

Authentication is a critical part of most web applications, yet testing auth flows remains challenging for many development teams. In this post, I'll share a practical approach to testing authentication flows in Next.js applications using Playwright, an increasingly popular end-to-end testing tool.

Why End-to-End Testing for Auth?

When it comes to verifying authentication functionality, end-to-end testing offers significant advantages over unit or integration tests. End-to-end tests allow us to:

  • Test the actual login and signup experience users will encounter
  • Verify authentication flows across your application
  • Ensure redirects, tokens, and sessions work correctly
  • Catch issues that might appear in managed auth providers' login interfaces
  • Provide regression safety via smoke tests that cover critical authentication flows

Setting Up Your Testing Environment

To get started, you'll need a Next.js application with authentication already configured, or you can use our example repo.

For the purposes of this guide, we'll be using the Auth0 Next.js SDK, but you could just as easily use another managed auth provider like Clerk, Firebase, or Supabase.

To start, I've created a basic Next.js application that displays different content based on authentication state, a logged out page that shows signup and login links, and a logged in page that shows a welcome message and logout link.

import { auth0 } from "@/lib/auth0";

export default async function Home() {
  const session = await auth0.getSession();

  if (!session) {
    return (
      <main>
        <a href="/auth/login?screen_hint=signup">Sign up</a>
        <a href="/auth/login">Log in</a>
      </main>
    );
  }

  return (
    <main>
      <h1>Welcome, {session.user.name}!</h1>
      <a href="/auth/logout">Log out</a>
    </main>
  );
}
Installing and Configuring Playwright

First, let's install Playwright in our Next.js project:

npm install playwright@latest

After installation, I recommend setting up the following test scripts in your package.json:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:record": "playwright codegen http://localhost:3000",
    "test:e2e:smoke": "playwright test --project=Smoke",
    "test:e2e:features": "playwright test --project=Features",
    "test:e2e:debug": "PWDEBUG=1 playwright test --debug"
  }
}

This setup gives us multiple ways to run our tests based on specific needs:

  • Headless testing for CI/CD pipelines
  • UI mode for visual debugging
  • Record mode to generate tests through manual interaction
  • Specialized smoke and feature tests
  • Debug mode for troubleshooting

Creating Reusable Test Helpers

Let's add a spec.helpers.ts file in our e2e directory with the following content:

import type { Page } from "@playwright/test";

type TestContext = {
  email: string;
  password: string;
};

let cachedTestCtx: TestContext | null = null;

export function testContext(): TestContext {
  if (!cachedTestCtx) {
    const timestamp = new Date().getTime();
    cachedTestCtx = {
      email: `${timestamp}+testuser@example.com`,
      password: "tD20xt6h0m3!",
    };
  }
  return cachedTestCtx;
}

export const createTestContext = () => {
  const timestamp = Date.now();
  return {
    email: `${timestamp}+testuser@example.com`,
    password: 'S0me-R@ndom-P@ssword!'
  };
};

export async function signUp(page: Page, ctx: TestContext) {
  await page.goto("/");
  await page.getByRole("link", { name: "Sign up" }).click();
  await page.getByLabel("Email address").click();
  await page.getByLabel("Email address").fill(ctx.email);
  await page.getByRole("textbox", { name: "Password" }).click();
  await page.getByRole("textbox", { name: "Password" }).fill(ctx.password);
  await page.getByRole("button", { name: "Continue", exact: true }).click();
  await page.getByRole("button", { name: "Accept" }).click();
  await page.getByText(`Welcome, ${ctx.email}!`).isVisible();
}

export async function signIn(page: Page, ctx: TestContext) {
  await page.goto("/");
  await page.getByRole("link", { name: "Log in" }).click();
  await page.getByLabel("Email address").click();
  await page.getByLabel("Email address").fill(ctx.email);
  await page.getByRole("textbox", { name: "Password" }).click();
  await page.getByRole("textbox", { name: "Password" }).fill(ctx.password);
  await page.getByRole("button", { name: "Continue", exact: true }).click();
  await page.getByText(`Welcome, ${ctx.email}!`).isVisible();
}

export async function signOut(page: Page, ctx: TestContext) {
  await page.goto("/");
  await page.getByRole("link", { name: "Log out" }).click();
  await page.getByRole("link", { name: "Sign up" }).isVisible();
  await page.getByRole("link", { name: "Log in" }).isVisible();
}

Creating a Basic Smoke Test

Let's create a smoke test that verifies our core authentication flows. You might want to configure this to run regularly as part of your CI/CD pipeline for continued verification of sign-up, sign-in, and sign-out flows.

import { test } from '@playwright/test';
import {
  testContext,
  signUp,
  signOut,
  signIn,
} from './spec.helpers';

const ctx = testContext();

test('smoke', async ({ page }) => {
  await signUp(page, ctx);
  await signOut(page, ctx);
  await signIn(page, ctx);
});

Debugging with Playwright's UI Tools

Playwright offers excellent tools for debugging tests, which is especially helpful when working with auth flows that involve third-party interfaces like Auth0.

The UI Explorer

To launch the UI Explorer:

npm run test:e2e:ui

This opens an interactive UI where you can:

  • Run individual tests
  • View a timeline of test steps
  • Inspect the DOM at specific points
  • Debug failing tests with detailed error information
The UI Explorer is particularly valuable when testing auth flows because it lets you see exactly what the browser was seeing when an error occurred.

‍

The Recorder

When testing with third-party auth providers, finding the right selectors for UI elements can be challenging. Thankfully, Playwright's recorder makes quick work of this task:

npm run dev
npm run test:e2e:record

This opens a browser where you can:

1. Interact with your application manually

2. See the Playwright code generated in real-time

3. Copy the generated selectors for use in your tests

Common Pitfalls

One common challenge when testing managed auth integrations is that the selectors for elements on the login page may not be what you expect. Using the recorder, we can identify the correct selectors.

For example, in my testing I discovered that the Auth0 continue button wasn't accessible page.getByRole('button', { name: "Continue" }).click() because there were two buttons on the rendered element that had the same starting text of "Continue".

// Playwright flags this as a duplicate selector error, and the tests fail to proceed
page.getByRole('button', { name: "Continue" }).click()

// We can be more specific by using the exact option, so we ensure the correct button is clicked
await page.getByRole('button', { name: "Continue", exact: true }).click();

Similarly, sometimes the getByLabel method may not work as expected. I hit this issue when attempting to fill in the password field in the Auth0 login form and ended up having to use page.getByRole('textbox', { name: 'Password' }).fill('password'). Debugging these issues manually is tricky but the Playwright recorder makes it much easier to identify the correct selectors for 3rd party UI elements.

// This didn't work with the Auth0 password field markup
page.getByLabel('Password').fill('password')

// After running the playwright recorder, this was the working selector that was generated
page.getByRole('textbox', { name: 'Password' }).fill('password')

Preventing Slowdown

Session Caching

For feature tests that don't specifically test authentication but require an authenticated user, we can use session caching to improve performance by reusing the same authenticated session across multiple tests. Playwright makes this a breeze using page.context().storageState().

Create an auth.setup.ts file:

import { test as setup } from "@playwright/test";
import { signUp, testContext } from "./spec.helpers";

// https://playwright.dev/docs/auth
// we cache the auth state in a file so we can reuse it across tests
// without having to log the user in again
const AUTH_FILE = "./playwright/.auth/user.json";
const ctx = testContext();

setup("sign up, persist session to disk", async ({ page }) => {
  await signUp(page, ctx);
  await page.context().storageState({ path: AUTH_FILE });
});

‍

Then in your Playwright config, set up a "Features" project that uses this stored auth state via the dependencies key.

projects: [
  { name: 'setup', testMatch: 'auth.setup.ts' },
  {
    name: 'Features',
    testMatch: /.*\.feature\.ts/,
    dependencies: ['setup'],
    use: {
      ...devices['Desktop Chrome'],
      storageState: 'playwright/.auth/user.json',
    },
  },
  {
    name: 'Smoke',
    testMatch: 'smoke.spec.ts',
    use: { ...devices['Desktop Chrome'] },
  },
],

‍

Now your feature tests can assume an authenticated user without going through the auth flow each time.

Recommendations

Based on my experience implementing this testing approach, here are some recommendations:

1. Use the recorder from the start

Don't try to guess selectors, especially for third-party auth UIs.

2. Focus on critical paths

Test the core auth flows thoroughly, but don't try to test every edge case with E2E tests.

3. Prioritize accessibility-friendly selectors:

When possible, use role-based selectors as they also verify your app's accessibility.

4. Cache sessions when appropriate

Authentication is slow - only test it directly when that's what you're actually testing.

5. Run smoke tests in CI

Make auth testing part of your continuous integration process.

Wrapping Up

Setting up proper end-to-end testing for your managed auth integration might take some initial effort, but the confidence it provides in your authentication flows is well worth it. With Playwright's powerful debugging tools and some simple test helpers, you can create reliable, maintainable tests that provide a great foundation for continued development.

The complete code for this example is available on our GitHub, and you can watch a more detailed walkthrough in this YouTube video.

Resources
  • ‍Playwright Documentation‍
  • Auth0 Next.js SDK Quickstart‍
  • Next.js Documentation‍
  • GitHub Repository with Example Code‍
  • YouTube Tutorial

Related Insights

🔗
End-to-end testing with Cypress series
🔗
How to stop hating your tests
🔗
Breaking up (with) your test suite
🔗
Beyond MVP: Why your most valuable tactic matters

Explore our insights

See all insights
Developers
Developers
Developers
You’re holding it wrong! The double loop model for agentic coding

Joé Dupuis has noticed an influx of videos and blog posts about the "correct" way of working with AI agents. Joé thinks most of it is bad advice, and has a better approach he wants to show you.

by
Joé Dupuis
Leadership
Leadership
Leadership
Don't play it safe: Improve your continuous discovery process to reduce risk

We often front-load discovery to feel confident before building—but that’s not real agility. This post explores how continuous learning reduces risk better than perfect plans ever could.

by
Doc Norton
Leadership
Leadership
Leadership
How an early-stage startup engineering team improved the bottom line fast

A fast-growing startup was burning cash faster than it could scale. Here’s how smart engineering decisions helped them improve the bottom line.

by
Jonathon Baugh
Letter art spelling out NEAT

Join the conversation

Technology is a means to an end: answers to very human questions. That’s why we created a community for developers and product managers.

Explore the community
Test Double Executive Leadership Team

Learn about our team

Like what we have to say about building great software and great teams?

Get to know us
Test Double company logo
Improving the way the world builds software.
What we do
Services OverviewSoftware DeliveryProduct ManagementLegacy ModernizationDevOpsUpgrade RailsTechnical RecruitmentTechnical Assessments
Who WE ARE
About UsCulture & CareersGreat CausesEDIOur TeamContact UsNews & AwardsN.E.A.T.
Resources
Case StudiesAll InsightsLeadership InsightsDeveloper InsightsProduct InsightsPairing & Office Hours
NEWSLETTER
Sign up hear about our latest innovations.
Your email has been added!
Oops! Something went wrong while submitting the form.
Standard Ruby badge
614.349.4279hello@testdouble.com
Privacy Policy
© 2020 Test Double. All Rights Reserved.