Clerk Auth
How do you test authenticated flows without fighting the auth provider?
Clerk handles identity for Stackmates. The hardest part isn't configuring Clerk -- it's making tests work reliably against it. Bot detection, token ordering, session expiry. Get these wrong and tests fail silently with blank pages.
Testing Strategy
Three layers, each with a different job. Most teams build one and wonder why the other two break.
| Layer | What You Test | Clerk Involvement | Speed |
|---|---|---|---|
| Unit | Components, hooks, server helpers | Mocked entirely | Fast |
| Integration | Middleware, API routes, webhooks | Mocked entirely | Fast |
| E2E | Full sign-in/sign-out, protected flows | Real Clerk | Slow |
Rule: never hit Clerk's real API in unit/integration tests. Mock the SDK and control auth state in each test. Reserve real Clerk for E2E only.
Unit Testing
Mocking @clerk/nextjs
Create a shared mock that controls auth state per test.
// test/mocks/clerk-nextjs.ts
import type { SignedInAuthObject, SignedOutAuthObject } from "@clerk/nextjs/server";
let mockUser: any = null;
let mockAuth: SignedInAuthObject | SignedOutAuthObject = {
userId: null,
sessionId: null,
getToken: async () => null,
orgId: null,
orgRole: null,
orgSlug: null,
};
export const __setMockUser = (user: any) => {
mockUser = user;
mockAuth = user
? { ...mockAuth, userId: user.id, sessionId: "sess_123" }
: { ...mockAuth, userId: null, sessionId: null };
};
// Client hooks
export const useUser = () => ({
isLoaded: true,
isSignedIn: !!mockUser,
user: mockUser,
});
export const useAuth = () => ({
isLoaded: true,
isSignedIn: !!mockAuth.userId,
userId: mockAuth.userId,
sessionId: mockAuth.sessionId,
getToken: mockAuth.getToken,
});
// Server helpers (App Router)
export const auth = async () => mockAuth;
export const currentUser = async () => mockUser;
// Passthrough for ClerkProvider wrapper
export const ClerkProvider = ({ children }: any) => children;
Vitest Setup
// vitest.setup.ts
import { vi } from "vitest";
vi.mock("@clerk/nextjs", async () => {
return await import("./test/mocks/clerk-nextjs");
});
vi.mock("@clerk/nextjs/server", async () => {
const mod = await import("./test/mocks/clerk-nextjs");
return { auth: mod.auth, currentUser: mod.currentUser };
});
Jest Setup
// jest.setup.ts
jest.mock("@clerk/nextjs", () => require("./test/mocks/clerk-nextjs"));
jest.mock("@clerk/nextjs/server", () => {
const mod = require("./test/mocks/clerk-nextjs");
return { auth: mod.auth, currentUser: mod.currentUser };
});
Component Tests
import { __setMockUser } from '../test/mocks/clerk-nextjs';
import { render } from '@testing-library/react';
import { DashboardPage } from './DashboardPage';
beforeEach(() => __setMockUser(null)); // default: signed-out
it('shows login prompt when signed out', () => {
const { getByText } = render(<DashboardPage />);
expect(getByText('Sign in')).toBeInTheDocument();
});
it('renders dashboard for signed-in user', () => {
__setMockUser({
id: 'user_1',
emailAddresses: [{ emailAddress: 'test@example.com' }],
});
const { getByText } = render(<DashboardPage />);
expect(getByText('Welcome')).toBeInTheDocument();
});
Integration Testing
Middleware
Extract core logic into a testable function. Mock auth() at the boundary.
// middleware.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
const protectedRoutes = ["/dashboard", "/settings"];
export async function handleAuth(request: Request) {
const url = new URL(request.url);
const isProtected = protectedRoutes.some((p) => url.pathname.startsWith(p));
if (!isProtected) return NextResponse.next();
const { userId } = await auth();
if (!userId) {
const signInUrl = new URL("/sign-in", url.origin);
signInUrl.searchParams.set("redirect_url", url.pathname);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
}
// middleware.test.ts
import { __setMockUser } from "../test/mocks/clerk-nextjs";
import { handleAuth } from "./middleware";
it("redirects anonymous user from protected route", async () => {
__setMockUser(null);
const req = new Request("https://example.com/dashboard");
const res = await handleAuth(req);
expect(res.status).toBe(307);
});
it("allows authenticated user through", async () => {
__setMockUser({ id: "user_1" });
const req = new Request("https://example.com/dashboard");
const res = await handleAuth(req);
expect(res.status).toBe(200);
});
Webhook Testing (Svix)
Clerk webhooks arrive via Svix with signature verification. Mock Svix in tests.
// Mock svix in test setup
vi.mock("svix", () => ({
Webhook: class {
constructor(_secret: string) {}
verify(payload: string) {
return JSON.parse(payload); // trust payload in tests
}
},
}));
// webhook.test.ts
import { POST } from "@/app/api/webhooks/clerk/route";
it("handles user.created webhook", async () => {
const body = JSON.stringify({
type: "user.created",
data: { id: "user_1", email_addresses: [{ email_address: "a@test.com" }] },
});
const req = new Request("http://localhost/api/webhooks/clerk", {
method: "POST",
body,
headers: {
"svix-id": "msg_test",
"svix-timestamp": String(Math.floor(Date.now() / 1000)),
"svix-signature": "v1_test",
},
});
const res = await POST(req);
expect(res.status).toBe(200);
});
For realistic integration tests, use the Svix SDK to generate real signed payloads against your webhook secret.
E2E Testing (Playwright)
The Critical Rule
Set the testing token BEFORE page navigation. Always.
// CORRECT -- token before navigation
test("protected page loads", async ({ page }) => {
await setupClerkTestingToken({ page });
await page.goto("/dashboard");
});
// WRONG -- Clerk blocks the page before the token arrives
test("protected page loads", async ({ page }) => {
await page.goto("/dashboard");
await setupClerkTestingToken({ page }); // too late
});
setupClerkTestingToken() intercepts Clerk's Frontend API requests. Navigate first and Clerk's bot detection fires before the token exists. Result: blank page, no error, silent failure.
Global Setup
Authenticate once, store state, reuse across tests.
e2e/
├── global.setup.ts # Authenticate, save state
├── tests/
│ ├── admin.spec.ts # Uses admin storageState
│ └── user.spec.ts # Uses user storageState
playwright/
└── .clerk/
├── admin.json # Stored auth state (gitignored)
└── user.json
global.setup.ts:
import { clerkSetup, clerk } from "@clerk/testing/playwright";
import { test as setup } from "@playwright/test";
setup.describe.configure({ mode: "serial" });
setup("global setup", async ({}) => {
await clerkSetup();
});
setup("authenticate", async ({ page }) => {
await page.goto("/");
await clerk.signIn({
page,
signInParams: {
strategy: "password",
identifier: process.env.E2E_CLERK_USER_USERNAME!,
password: process.env.E2E_CLERK_USER_PASSWORD!,
},
});
await page.context().storageState({ path: "playwright/.clerk/user.json" });
});
playwright.config.ts:
export default defineConfig({
projects: [
{ name: "setup", testMatch: /global\.setup\.ts/ },
{
name: "authenticated",
use: { storageState: "playwright/.clerk/user.json" },
dependencies: ["setup"],
},
{
name: "unauthenticated",
testMatch: /.*\.unauth\.spec\.ts/,
},
],
});
Multi-Role Testing
Separate storage states per role. Different browser contexts for simultaneous interaction.
test("admin and user interact", async ({ browser }) => {
const adminCtx = await browser.newContext({
storageState: "playwright/.clerk/admin.json",
});
const userCtx = await browser.newContext({
storageState: "playwright/.clerk/user.json",
});
const adminPage = await adminCtx.newPage();
const userPage = await userCtx.newPage();
// Both pages operate with different auth states
});
Test Helpers
@clerk/testing (v1.14.3+) exports from @clerk/testing/playwright:
| Helper | Purpose |
|---|---|
clerkSetup() | Global token setup -- run once, serial mode |
setupClerkTestingToken({ page }) | Per-test token injection |
clerk.signIn({ page, signInParams }) | Programmatic sign-in (calls setupClerkTestingToken internally) |
clerk.signOut({ page }) | Sign out current user |
clerk.loaded({ page }) | Wait for Clerk JS to initialize |
Do not call both setupClerkTestingToken() and clerk.signIn(). signIn calls the token setup internally. Doubling up creates duplicate requests and flaky tests.
Environment Setup
| Variable | Purpose | Secret? |
|---|---|---|
CLERK_PUBLISHABLE_KEY | Identifies Clerk instance (pk_test_...) | No |
CLERK_SECRET_KEY | Backend API auth (sk_test_...) | Yes |
E2E_CLERK_USER_USERNAME | Test user credentials | Yes |
E2E_CLERK_USER_PASSWORD | Test user credentials | Yes |
Dev Instance Test Mode
Development instances provide built-in shortcuts:
- Test emails: any address with
+clerk_test(e.g.jane+clerk_test@example.com) -- no email sent - Test phones:
+1 (XXX) 555-0100through+1 (XXX) 555-0199-- no SMS sent - Verification code:
424242for both email and phone
Instance Limits
| Aspect | Development | Production |
|---|---|---|
| User cap | 100 | Unlimited |
| OAuth | Shared credentials (auto) | Must provide your own |
| Monthly SMS | 20 (test numbers exempt) | Per plan |
| Monthly email | 100 (test addresses exempt) | Per plan |
| Testing tokens | Supported | Supported (Aug 2025+) |
Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Token after navigation | Blank page, no error | Move setupClerkTestingToken() before page.goto() |
| Duplicate token setup | Flaky auth, intermittent failures | Use clerk.signIn() OR setupClerkTestingToken(), not both |
| Session token expiry | Auth failures after 60s | Use stored storageState -- Clerk SDK auto-refreshes |
| CI storage state | Works locally, fails in CI | mkdir -p playwright/.clerk before tests, add to .gitignore |
| Same user in parallel | Bot detection, account lockout | One test user per worker shard, or serialize auth tests |
| Rapid sign-in attempts | Temporary blocks from Clerk | Use long-lived test users, add small delays between auth actions |
| OTP in production | Sign-in fails with code-based | Use email/password strategy for production testing |
| MFA flows | clerk.signIn() no MFA support | Use manual Playwright UI interaction for MFA |
| Dev user cap | Can't create more test users | Clean up or use fixed set (max 100) |
| Mock internals | Brittle tests coupled to Clerk | Mock at boundary for unit tests, real Clerk for E2E only |
| No staging instance | Config drift between environments | Create separate Clerk app, replicate config manually |
| Mass user creation/deletion | Looks like abuse to Clerk | Keep stable test users, don't create/delete on each run |
Staging
Clerk has no native staging instance. Workaround: create a separate Clerk application with its own domain. Configuration does not sync between applications -- replicate manually.
For non-production environments, disable bot detection entirely in the Clerk Dashboard to eliminate the testing token requirement.
Clerk Testing Verdict
| Strength | Weakness |
|---|---|
| E2E helpers work when used right | Bot detection creates flaky CI |
| Testing tokens in production | No staging instance, manual config sync |
| storageState reuse is fast | Unit testing requires custom mocks |
| Dev instance shortcuts | 100 user cap in dev |
| Familiar OAuth/password flows | External dependency for every E2E run |
The fundamental tension: Clerk optimizes for security (bot detection, rate limiting) which directly conflicts with test automation patterns (rapid, repeated, scripted logins). Unit/integration mocking eliminates this. E2E requires careful orchestration.
Compare with Better Auth (self-hosted, full DB control in tests) and zkLogin (on-chain identity, no auth provider dependency).
Context
- Identity and Security -- The evaluation checklist
- Better Auth -- Self-hosted alternative with Drizzle integration
- Trust Architecture -- Trust infrastructure patterns
- Product Engineering -- Engineering standards
Links
- Clerk -- Testing with Playwright -- Official setup guide
- Clerk -- Test Authenticated Flows -- StorageState pattern
- Clerk -- Test Helpers Reference -- API reference
- Clerk -- Test Emails and Phones -- Dev instance test mode
- Clerk -- Production Testing Tokens -- Aug 2025 changelog
- Clerk -- Testing Blog -- Unit testing patterns
- clerk-playwright-nextjs -- Official example repo
Questions
How do you make auth testing as reliable as unit tests?
- What's the minimum test surface that proves auth works without testing Clerk's internals?
- When should you mock Clerk vs test against it directly?
- How do you handle auth state across parallel test workers without race conditions?
- At what point does the testing friction of a managed service outweigh the setup cost of self-hosting?