How to Test Clerk Authentication in Playwright
Short answer
Clerk-hosted sign-in UI is fast to ship but slow and brittle in every spec. Use @clerk/testing or Backend API to create per-run users, clerk.signIn for session bootstrap, and probe Assert on protected routes—reserve full hosted UI flows for a few smoke tests. Org switching and JWT templates need explicit scenarios so wrong orgId does not slip through green CI.
Part of Testing Guides by auth and identity.
Who this is for
Teams using Clerk for auth on Next.js App Router, React, or Remix who need Playwright E2E with organization switching, RBAC roles, and parallel CI—without logging through Clerk's hosted components on every test.
Typical stacks: Next.js + @clerk/nextjs, Clerk Organizations, Clerk + Supabase JWT templates, or B2B SaaS with org-scoped data.
Why Clerk needs different Arrange/Assert
| Layer | Pitfall | E2E fix |
|---|---|---|
| Hosted UI | iframe / shadow DOM drift | @clerk/testing sign-in |
| Session JWT | Stale after role change | Re-sign-in or getToken({ skipCache: true }) |
| Organizations | Active org wrong | clerk.setActive({ organization }) |
| Parallel CI | Shared test@ user | Backend API create user per runId |
| Middleware | auth() null in RSC | Storage state from testing helper |
| Webhooks | User created async | Poll probe profile row |
Client useUser() showing signed-in is not proof your API validates Clerk session on server routes.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
@clerk/testing setup | Missing CLERK_TESTING_TOKEN | Sign-in fails | Export from Clerk Dashboard |
| Per-run user | Email collision | 422 on create | user+${runId}@test.local |
| Org switch | Data from previous org | Cross-tenant leak | setActive + probe org scope |
| Role change | JWT cached | Old role in API | Force token refresh |
| Invitation accept | Token in email | CI inbox | API create membership |
| OAuth social | Popup in headless | Hang | Testing helper or split tier |
| MFA enabled | TOTP on test user | Blocked automation | Test user without MFA in CI |
| Sign-out | Storage state persists | Next test logged in | Fresh context per test |
| Backend API rate | Mass user create | 429 | Reuse worker-scoped user |
authorizedParties | Wrong domain cookie | Session invalid | Match PLAYWRIGHT_BASE_URL |
| Impersonation | Admin feature | Audit gap | Probe impersonation log |
| Deleted user | Still in storageState | False positive | Regenerate state |
| Multi-tab | Org switch tab A only | Tab B stale | Reload after setActive |
| JWT template claims | Supabase RLS mismatch | Empty data | Probe with template claims |
| Satellite domains | Cross-domain session | Untested | Document staging URLs |
Setup: @clerk/testing
Official Playwright integration:
npm install -D @clerk/testing
// playwright.config.ts
import { clerkSetup } from '@clerk/testing/playwright';
export default defineConfig({
globalSetup: require.resolve('./global-setup.ts'),
// ...
});
// global-setup.ts
import { clerkSetup } from '@clerk/testing/playwright';
export default async function globalSetup() {
await clerkSetup();
}
Set CLERK_PUBLISHABLE_KEY, CLERK_SECRET_KEY, and CLERK_TESTING_TOKEN from Clerk testing docs.
Per-run user via Backend API
// tests/helpers/clerk-users.ts
import { clerkClient } from '@clerk/clerk-sdk-node';
export async function createRunUser(runId: string) {
const email = `e2e+${runId}@test.local`;
const user = await clerkClient.users.createUser({
emailAddress: [email],
password: `pw-${runId}`,
skipPasswordChecks: true,
});
return { userId: user.id, email, password: `pw-${runId}` };
}
Tear down in afterEach or nightly job—Clerk test instances accumulate users.
Sign in without hosted UI clicks
// @Scenario: auth/clerk-org-member
import { test, expect } from '@playwright/test';
import { clerk } from '@clerk/testing/playwright';
test('org member sees org-scoped invoice', async ({ page, runId }) => {
const { email, password } = await createRunUser(runId);
const org = await createRunOrg(runId, email);
await page.goto('/');
await clerk.signIn({ page, signInParams: { strategy: 'password', identifier: email, password } });
await clerk.setActive({ page, organization: org.id });
await page.goto('/invoices');
await expect(page.getByRole('heading', { name: 'Invoices' })).toBeVisible();
await expect.poll(async () => {
const res = await page.request.get(`/api/test/probe-invoices?runId=${runId}`);
return (await res.json()).orgId;
}).toBe(org.id);
});
Use hosted UI smoke tests separately—one spec per release train.
Org switching scenario
test('switching org changes visible project count', async ({ page, runId }) => {
const user = await createRunUser(runId);
const orgA = await createRunOrg(`${runId}-a`, user.email);
const orgB = await createRunOrg(`${runId}-b`, user.email);
await seedProjects(orgA.id, { count: 2, runId });
await seedProjects(orgB.id, { count: 5, runId });
await clerk.signIn({ page, signInParams: { strategy: 'password', identifier: user.email, password: user.password } });
await clerk.setActive({ page, organization: orgA.id });
await page.goto('/projects');
await expect.poll(() => probeProjectCount(page, runId, orgA.id)).toBe(2);
await clerk.setActive({ page, organization: orgB.id });
await page.reload();
await expect.poll(() => probeProjectCount(page, runId, orgB.id)).toBe(5);
});
Next.js middleware integration
Ensure tests hit the same domain Clerk expects:
// middleware.ts — clerkMiddleware protects routes
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
Playwright baseURL must match Clerk authorized parties for session cookies to stick.
Probe Assert on protected API
await expect.poll(async () => {
const res = await page.request.get('/api/protected/resource');
return res.status();
}).toBe(200);
// Negative: signed out
await clerk.signOut({ page });
await expect(page.request.get('/api/protected/resource')).resolves.toHaveProperty('status', 401);
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Click Clerk UI every spec | Slow, flaky iframes | clerk.signIn |
| Shared staging user | Org data collision | Per-run Backend API user |
Assert useUser() only | Client state lies | Probe API with session |
| Skip org switch tests | Cross-tenant bugs | setActive matrix |
| Production Clerk keys in CI | User pollution | Dedicated test instance |
| No sign-out between tests | State leak | signOut or fresh context |
External references
- Clerk testing overview
- Clerk Playwright helpers
@clerk/testingon npm- Clerk Backend API
- Clerk Organizations
- Playwright storage state
Example scenario
Situation: User belongs to org A and org B; active org still A while navigating to org B invoice URL.
Expected outcome: 403 or empty state; probe shows zero invoices for wrong org context.
Why UI-only automation breaks: UI hides nav link but direct URL still returns data—probe leaks JSON.
- Arrange: Seed user in two orgs with runId-scoped invoices via test API.
- Act: Sign in; setActive org A; navigate to org B invoice deep link.
- Assert: Probe invoice count for active org only; API returns 403 for cross-org id.
TestChimp workflow: // @Scenario: auth/clerk-org-isolation; /testchimp test repairs specs when Clerk component regen changes selectors.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
- OAuth social login
- RBAC permissions
- Supabase + Clerk JWT
- Next.js App Router
- World-state auth pollution
Frequently asked questions
Should I use @clerk/testing or click the Clerk sign-in UI?
Use @clerk/testing clerk.signIn for most specs—fast and stable. Keep a small set of hosted UI smoke tests to catch component integration regressions.
How do I create per-run Clerk users in CI?
Clerk Backend API createUser with email e2e+runId@test.local. Never share one staging user across parallel workers.
How do I test organization switching?
After signIn, call clerk.setActive with organization id, reload if needed, and probe org-scoped data—not just visible nav labels.
What is CLERK_TESTING_TOKEN?
Short-lived token from Clerk Dashboard enabling testing helpers. Configure in CI secrets alongside publishable and secret keys for your test instance.
Clerk + Supabase—how do tests assert RLS?
Ensure JWT template claims match seed data org_id. Probe Supabase rows as the signed-in user via server route—see Supabase guide.
Can I reuse storageState across tests?
Yes for read-only suites; org-switch and role-change specs need fresh sign-in or setActive to avoid stale JWT claims.
How do I test invited org members?
Backend API create organization membership or accept invite via test token route. Avoid real email unless using inbox capture.
TestChimp with Clerk apps?
/testchimp init wires probe routes for org-scoped data; /testchimp test maintains Clerk specs when auth UI or middleware matchers change on agent PRs.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.