NextAuth.js v5 Testing: Auth.js Patterns, Session Mocking, and Provider Testing
NextAuth.js v5 (now called Auth.js) is a significant rewrite that embraces Next.js App Router and Edge runtime. Testing Auth.js v5 apps is different from v4 — there's no more getServerSideProps, sessions are handled via auth() from the server, and the middleware approach changed. This guide covers the v5 testing patterns you actually need.
What Changed in NextAuth.js v5
Auth.js v5 key differences that affect testing:
auth()instead ofgetServerSession()— the universal session function- App Router support — Server Components, Server Actions, Route Handlers
- Edge-compatible — runs in Edge runtime with Cloudflare Workers
- Callbacks restructured —
authorized,jwt, andsessioncallbacks work differently - Prisma adapter v2 — updated adapter interface
Setting Up Test Auth Configuration
// lib/auth.ts (your actual auth config)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from './prisma';
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
return isLoggedIn;
}
return true;
},
session({ session, user }) {
session.user.id = user.id;
return session;
},
},
});Mocking auth() in Tests
The auth() function is the key to testing App Router components:
// test-utils/auth-mock.ts
import { Session } from 'next-auth';
export const mockSession: Session = {
user: {
id: 'user-1',
name: 'Jane Smith',
email: 'jane@example.com',
image: null,
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};
// Mock the entire auth module
jest.mock('../lib/auth', () => ({
auth: jest.fn(),
signIn: jest.fn(),
signOut: jest.fn(),
handlers: { GET: jest.fn(), POST: jest.fn() },
}));
export function mockAuthenticatedSession(overrides: Partial<Session> = {}) {
const { auth } = require('../lib/auth');
auth.mockResolvedValue({ ...mockSession, ...overrides });
return auth;
}
export function mockUnauthenticatedSession() {
const { auth } = require('../lib/auth');
auth.mockResolvedValue(null);
return auth;
}Testing Server Components
With App Router, Server Components call auth() directly:
import { render, screen } from '@testing-library/react';
import { mockAuthenticatedSession, mockUnauthenticatedSession } from '../test-utils/auth-mock';
import DashboardPage from '../app/dashboard/page';
describe('DashboardPage Server Component', () => {
test('renders dashboard content for authenticated users', async () => {
mockAuthenticatedSession({ user: { name: 'Jane', email: 'jane@example.com', id: 'u1', image: null } });
const page = await DashboardPage({});
render(page);
expect(screen.getByText('Welcome, Jane!')).toBeInTheDocument();
expect(screen.getByTestId('dashboard-content')).toBeInTheDocument();
});
test('returns null or redirects for unauthenticated users', async () => {
mockUnauthenticatedSession();
// If your page component redirects, this throws a redirect error
await expect(DashboardPage({})).rejects.toThrow();
// Or check for redirect behavior depending on implementation
});
});Testing Middleware
Auth.js v5 uses the auth export directly as middleware:
// middleware.ts
export { auth as middleware } from './lib/auth';
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};Testing the middleware's authorized callback:
import { auth } from '../lib/auth';
import { NextRequest } from 'next/server';
// Test the authorized callback directly
const authorizedCallback = auth.config?.callbacks?.authorized;
describe('authorized callback', () => {
test('allows access for authenticated users on dashboard', () => {
const result = authorizedCallback?.({
auth: { user: { name: 'Jane', email: 'jane@example.com', id: 'u1', image: null }, expires: 'future' },
request: { nextUrl: new URL('http://localhost/dashboard/overview') } as any,
});
expect(result).toBe(true);
});
test('denies access for unauthenticated users on dashboard', () => {
const result = authorizedCallback?.({
auth: null,
request: { nextUrl: new URL('http://localhost/dashboard/overview') } as any,
});
expect(result).toBe(false);
});
test('allows access to public routes without auth', () => {
const result = authorizedCallback?.({
auth: null,
request: { nextUrl: new URL('http://localhost/about') } as any,
});
expect(result).toBe(true);
});
});Testing JWT Callbacks
JWT callbacks shape the token and session data:
// Test your jwt callback directly
const jwtCallback = auth.config?.callbacks?.jwt;
describe('JWT callback', () => {
test('adds user role to token on sign-in', async () => {
const mockUser = {
id: 'user-1',
email: 'admin@example.com',
name: 'Admin',
image: null,
role: 'ADMIN', // Custom field from database
};
const result = await jwtCallback?.({
token: { sub: 'user-1', iat: Date.now() },
user: mockUser,
account: null,
trigger: 'signIn',
});
expect(result?.role).toBe('ADMIN');
expect(result?.userId).toBe('user-1');
});
test('preserves token claims on session refresh', async () => {
const existingToken = {
sub: 'user-1',
role: 'ADMIN',
userId: 'user-1',
iat: Date.now(),
};
const result = await jwtCallback?.({
token: existingToken,
user: undefined,
account: null,
trigger: 'update',
});
expect(result?.role).toBe('ADMIN');
expect(result?.userId).toBe('user-1');
});
});Testing Session Callbacks
const sessionCallback = auth.config?.callbacks?.session;
describe('session callback', () => {
test('exposes user ID in session', async () => {
const mockSession: Session = {
user: { name: 'Jane', email: 'jane@example.com', image: null },
expires: new Date(Date.now() + 86400000).toISOString(),
};
const result = await sessionCallback?.({
session: mockSession,
token: { sub: 'user-1', role: 'USER', userId: 'user-1', iat: Date.now() },
user: { id: 'user-1', email: 'jane@example.com', emailVerified: null, name: 'Jane', image: null },
newSession: null,
trigger: 'update',
});
expect(result?.user.id).toBe('user-1');
});
});Testing OAuth Providers
Mock the provider's token exchange and user info calls:
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
// Mock GitHub OAuth token exchange
http.post('https://github.com/login/oauth/access_token', () => {
return HttpResponse.json({
access_token: 'gho_test123',
token_type: 'bearer',
scope: 'read:user,user:email',
});
}),
// Mock GitHub user info
http.get('https://api.github.com/user', () => {
return HttpResponse.json({
id: 12345,
login: 'janesmith',
name: 'Jane Smith',
email: 'jane@example.com',
avatar_url: 'https://github.com/jane.png',
});
}),
// Mock GitHub user emails (if needed)
http.get('https://api.github.com/user/emails', () => {
return HttpResponse.json([
{ email: 'jane@example.com', primary: true, verified: true },
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('OAuth callback creates user and session', async () => {
// Simulate the callback URL being hit
const callbackUrl = '/api/auth/callback/github?code=test-code&state=test-state';
// Use your handler directly
const { GET } = require('../app/api/auth/[...nextauth]/route');
const request = new Request(`http://localhost${callbackUrl}`);
const response = await GET(request);
// Should redirect to callbackUrl after successful OAuth
expect(response.status).toBe(302);
expect(response.headers.get('location')).not.toContain('error');
});Testing Database Adapter Integration
Test that the Prisma adapter correctly stores and retrieves users:
import { PrismaClient } from '@prisma/client';
import { PrismaAdapter } from '@auth/prisma-adapter';
// Use a test database or mock Prisma
jest.mock('../lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
create: jest.fn(),
},
account: {
findUnique: jest.fn(),
create: jest.fn(),
},
session: {
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
verificationToken: {
create: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
},
},
}));
describe('Prisma adapter integration', () => {
const { prisma } = require('../lib/prisma');
test('creates user on first OAuth sign-in', async () => {
prisma.user.findUnique.mockResolvedValue(null); // User doesn't exist
prisma.user.create.mockResolvedValue({
id: 'user-1',
name: 'Jane Smith',
email: 'jane@example.com',
emailVerified: null,
image: 'https://github.com/jane.png',
});
const adapter = PrismaAdapter(prisma);
const user = await adapter.createUser!({
name: 'Jane Smith',
email: 'jane@example.com',
emailVerified: null,
image: 'https://github.com/jane.png',
});
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: 'jane@example.com',
name: 'Jane Smith',
}),
});
expect(user.id).toBe('user-1');
});
});Testing Server Actions with Auth
Auth.js v5 works with Next.js Server Actions:
// app/actions/profile.ts
'use server';
import { auth } from '../lib/auth';
import { prisma } from '../lib/prisma';
export async function updateProfile(data: { name: string }) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
return prisma.user.update({
where: { id: session.user.id },
data: { name: data.name },
});
}
// Testing the Server Action
describe('updateProfile server action', () => {
test('updates user name for authenticated users', async () => {
mockAuthenticatedSession({ user: { id: 'user-1', name: 'Jane', email: 'j@e.com', image: null } });
const mockUpdate = jest.fn().mockResolvedValue({ id: 'user-1', name: 'Jane Doe' });
jest.mock('../lib/prisma', () => ({
prisma: { user: { update: mockUpdate } },
}));
const result = await updateProfile({ name: 'Jane Doe' });
expect(mockUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { name: 'Jane Doe' },
});
expect(result.name).toBe('Jane Doe');
});
test('throws for unauthenticated users', async () => {
mockUnauthenticatedSession();
await expect(updateProfile({ name: 'Jane Doe' })).rejects.toThrow('Unauthorized');
});
});E2E Testing with HelpMeTest
Auth.js v5 authentication flows — OAuth redirects, session cookies, Server Action auth checks — need browser-level E2E testing:
Save As NextAuthGitHubUser
Go to https://your-app.com/api/auth/signin
Click Sign in with GitHub
Complete GitHub OAuth flow
Verify redirect to /dashboard
Verify user name appears in nav
As NextAuthGitHubUser
Go to https://your-app.com/dashboard
Verify dashboard loads without re-authentication
Update profile name via the edit form
Verify name change persists after page refresh
Click Sign Out
Verify redirect to /
Verify /dashboard redirects to sign-inHelpMeTest's session state saving means you authenticate once with real GitHub OAuth and reuse that session across all your tests.
Common Auth.js v5 Testing Pitfalls
Mocking the auth export, not the module. auth is a named export. Mock it as jest.mock('../lib/auth', () => ({ auth: jest.fn(), ... })).
Forgetting Edge runtime limitations. If your middleware runs in Edge, it can't use Node.js APIs. Test middleware with Edge-compatible test runners or mock Edge APIs.
Session vs JWT strategy differences. The session callback behaves differently for JWT vs database sessions. Know which your app uses before writing callback tests.
App Router vs Pages Router. Auth.js v5 has different APIs for each. Ensure you're using auth() for App Router, not the v4 getServerSession().
Summary
Testing NextAuth.js v5 (Auth.js) effectively covers:
- Mocking
auth()— the universal session function for Server Components and Actions - Callback testing —
authorized,jwt, andsessioncallbacks directly - Middleware tests — the
authorizedcallback for route protection - OAuth provider tests — MSW for mocking provider token exchange and user info
- Prisma adapter tests — user creation and session management
- Server Action tests — auth checks in Server Actions
- E2E monitoring — HelpMeTest for real OAuth flows and session persistence