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.