Test mode: +clerk_test and 424242
Any address with the +clerk_test subaddress, such as [email protected], is a test address. For email codes Clerk sends nothing and accepts 424242. Email links are different: Clerk does send the email, and the link is valid for 10 minutes.
Test mode is on by default in development instances. It can be turned on for a production instance in the Dashboard, which Clerk strongly discourages.
In Playwright, Clerk's bot detection can block automated sign-ups. The @clerk/testing package gets a Testing Token in global setup (clerkSetup()) and adds it to a test with setupClerkTestingToken({ page }); it needs your development instance's CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY.
import { test, expect } from "@playwright/test";
import { setupClerkTestingToken } from "@clerk/testing/playwright";
test("sign-up with an email code (test mode)", async ({ page }) => {
await setupClerkTestingToken({ page });
const email = `user-${Date.now()}[email protected]`;
await page.goto("/sign-up");
await page.getByLabel("Email address").fill(email);
await page.getByLabel("Password", { exact: true }).fill("a-long-test-password");
await page.getByRole("button", { name: "Continue", exact: true }).click();
// No email is sent; the code for test addresses is always 424242.
await page.keyboard.type("424242");
await expect(page).toHaveURL(/\/dashboard/);
});The labels follow Clerk's prebuilt sign-up component; adjust them to your own UI.
What test mode skips
- The email itself: the template you customised, the subject, the sender.
- Delivery to a real mailbox, and the time it takes.
- The code:
424242proves your form submits a code, not that users can read theirs.
The 100-a-month cap on development instances
Emails that Clerk delivers from a development instance are capped at 100 per calendar month. Emails to test addresses don't count. Once the cap is reached, email OTP requests are rejected. A CI suite that signs up ordinary addresses on every push runs into it within days, and then every email test fails at once. Keep the real-email tests few, or run them against the instance your staging environment uses.
A real-email test
Sign up an ordinary address, read the code from the email and type it. Anchor the pattern to the wording around the code in your template and require exactly one match, as the one-time code post explains.
type Email = {
subject: string;
body: string;
html: string | null;
links: { url: string; text: string }[];
};
// Waits up to 30 s on the server for the first email to the address.
export async function lastEmail(address: string): Promise<Email> {
const res = await fetch(
`https://api.zyntra.app/emails/last?email=${encodeURIComponent(address)}`,
{
headers: { "X-API-Key": process.env.ZYNTRA_API_KEY! },
signal: AbortSignal.timeout(45_000),
},
);
if (!res.ok) throw new Error(`No email for ${address}: ${res.status}`);
return res.json();
}
export const testAddress = (tag: string) =>
`${process.env.ZYNTRA_TEAM_ID}.${tag}-${Date.now()}@zyntramail.com`;import { test, expect } from "@playwright/test";
import { setupClerkTestingToken } from "@clerk/testing/playwright";
import { lastEmail, testAddress } from "./zyntra";
test("sign-up code arrives by email", async ({ page }) => {
test.setTimeout(90_000);
await setupClerkTestingToken({ page });
const address = testAddress("clerk-signup");
await page.goto("/sign-up");
await page.getByLabel("Email address").fill(address);
await page.getByLabel("Password", { exact: true }).fill("a-long-test-password");
await page.getByRole("button", { name: "Continue", exact: true }).click();
const email = await lastEmail(address);
const codes = email.body.match(/\b\d{6}\b/g) ?? [];
expect(codes).toHaveLength(1);
await page.keyboard.type(codes[0]!);
await expect(page).toHaveURL(/\/dashboard/);
});