NextAuth.js v5 Testing: Auth.js Patterns, Session Mocking, and Provider Testing

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 of getServerSession() — the universal session function
  • App Router support — Server Components, Server Actions, Route Handlers
  • Edge-compatible — runs in Edge runtime with Cloudflare Workers
  • Callbacks restructuredauthorized, jwt, and session callbacks 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-in

HelpMeTest'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:

  1. Mocking auth() — the universal session function for Server Components and Actions
  2. Callback testingauthorized, jwt, and session callbacks directly
  3. Middleware tests — the authorized callback for route protection
  4. OAuth provider tests — MSW for mocking provider token exchange and user info
  5. Prisma adapter tests — user creation and session management
  6. Server Action tests — auth checks in Server Actions
  7. E2E monitoring — HelpMeTest for real OAuth flows and session persistence

Read more