docs/content/docs/plugins/test-utils.mdx
The Test Utils plugin provides helpers for writing integration and E2E tests against Better Auth. It includes factories, database helpers, authentication helpers, and OTP capture functionality.
<Callout type="warn"> This plugin is designed for test environments only. It does not add public routes, but it does expose privileged helpers on `ctx.test`. Prefer keeping it out of production auth configs. </Callout>```ts title="auth.test.ts"
import { betterAuth } from "better-auth"
import { testUtils } from "better-auth/plugins" // [!code highlight]
export const auth = betterAuth({
// ... other config options
plugins: [
testUtils() // [!code highlight]
]
})
```
Keeping `testUtils()` in a separate test-only auth instance preserves type inference for `ctx.test` without adding the plugin to your production auth config.
```ts title="test-setup.ts"
const ctx = await auth.$context
const test = ctx.test
```
testUtils() does not register HTTP routes or API endpoints. Simply adding it to plugins does not create a public auth bypass on its own.
However, it still adds privileged server-side helpers on ctx.test. Those helpers can create sessions, persist users and organizations, and delete records directly through the auth context. When captureOTP: true is enabled, the plugin also installs a verification hook and stores OTPs in memory for later retrieval.
Because of that, the recommended setup is to keep testUtils out of your production auth config and add it from a separate test-only auth instance such as auth.test.ts or a dedicated test auth factory. That keeps the helpers available in tests without shipping them as part of your production server context.
Better Auth infers plugin helpers best from statically defined plugin arrays. If you conditionally spread testUtils() into plugins, TypeScript can stop inferring ctx.test correctly.
import { betterAuth } from "better-auth"
import { testUtils } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
...(process.env.NODE_ENV === "test"
? [testUtils()]
: [])
]
})
If you include testUtils() unconditionally to preserve static type inference, treat that as a convenience tradeoff rather than the recommended default. It still does not expose public routes, but you should avoid using ctx.test in production code paths.
Factories create objects without writing to the database. Use them to generate test data with sensible defaults.
Creates a user object with default values that can be overridden.
// Create user with defaults
const user = test.createUser()
// { id: "...", email: "[email protected]", name: "Test User", emailVerified: true, ... }
// Create user with overrides
const user = test.createUser({
email: "[email protected]",
name: "Alice",
emailVerified: false
})
Creates an organization object. Only available when the organization plugin is installed.
const org = test.createOrganization({
name: "Acme Corp",
slug: "acme-corp"
})
Database helpers persist and remove test data from the database.
Saves a user to the database.
const user = test.createUser({ email: "[email protected]" })
const savedUser = await test.saveUser(user)
Deletes a user from the database.
await test.deleteUser(user.id)
Saves an organization to the database. Only available with the organization plugin.
const org = test.createOrganization({ name: "Test Org" })
const savedOrg = await test.saveOrganization(org)
Deletes an organization from the database. Only available with the organization plugin.
await test.deleteOrganization(org.id)
Adds a user as a member of an organization. Only available with the organization plugin.
const member = await test.addMember({
userId: user.id,
organizationId: org.id,
role: "admin"
})
Auth helpers create authenticated sessions for testing protected routes.
Creates a session for a user and returns session details, headers, cookies, and token.
const { session, user, headers, cookies, token } = await test.login({
userId: user.id
})
// session - The session object with userId, token, etc.
// user - The user object
// headers - Headers object with session cookie (for fetch/Request)
// cookies - Cookie array (for Playwright/Puppeteer)
// token - The session token string
Returns a Headers object with the session cookie set. Useful for making authenticated requests.
const headers = await test.getAuthHeaders({ userId: user.id })
// Use with auth API
const session = await auth.api.getSession({ headers })
// Use with fetch
const response = await fetch("/api/protected", { headers })
Returns an array of cookie objects compatible with browser testing tools like Playwright and Puppeteer.
const cookies = await test.getCookies({
userId: user.id,
domain: "localhost" // optional, defaults to baseURL domain
})
// Playwright example
await context.addCookies(cookies)
// Puppeteer example
for (const cookie of cookies) {
await page.setCookie(cookie)
}
Each cookie object contains:
name - Cookie name (e.g., "better-auth.session_token")value - Cookie valuedomain - Cookie domainpath - Cookie path (defaults to "/")httpOnly - Whether cookie is HTTP-onlysecure - Whether cookie requires HTTPSsameSite - SameSite attribute ("Lax", "Strict", or "None")When captureOTP: true is set, the plugin passively captures OTPs as they are created. This allows you to retrieve OTPs in tests without needing to mock email or SMS sending.
import { betterAuth } from "better-auth"
import { testUtils, emailOTP } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
testUtils({ captureOTP: true }), // [!code highlight]
emailOTP({
async sendVerificationOTP({ email, otp }) {
// Your email sending logic
}
})
]
})
Retrieves a captured OTP by identifier (email or phone number).
// Send OTP
await auth.api.sendVerificationOTP({
body: { email: "[email protected]", type: "sign-in" }
})
// Retrieve captured OTP
const otp = test.getOTP("[email protected]")
// "123456"
| Option | Type | Default | Description |
|---|---|---|---|
captureOTP | boolean | false | Enable OTP capture for testing verification flows |
import { describe, it, expect, beforeAll } from "vitest"
import { auth } from "./auth"
import type { TestHelpers } from "better-auth/plugins"
describe("protected route", () => {
let test: TestHelpers
beforeAll(async () => {
const ctx = await auth.$context
test = ctx.test
})
it("should return user data for authenticated request", async () => {
// Setup
const user = test.createUser({ email: "[email protected]" })
await test.saveUser(user)
// Get authenticated headers
const headers = await test.getAuthHeaders({ userId: user.id })
// Test authenticated request
const session = await auth.api.getSession({ headers })
expect(session?.user.id).toBe(user.id)
// Cleanup
await test.deleteUser(user.id)
})
})
import { test, expect } from "@playwright/test"
import { auth } from "./auth"
test("dashboard shows user name", async ({ context, page }) => {
const ctx = await auth.$context
const testUtils = ctx.test
// Create and save user
const user = testUtils.createUser({
email: "[email protected]",
name: "E2E User"
})
await testUtils.saveUser(user)
// Get cookies and inject into browser
const cookies = await testUtils.getCookies({
userId: user.id,
domain: "localhost"
})
await context.addCookies(cookies)
// Navigate to protected page
await page.goto("/dashboard")
// Assert user name is visible
await expect(page.getByText("E2E User")).toBeVisible()
// Cleanup
await testUtils.deleteUser(user.id)
})
import { describe, it, expect, beforeAll, beforeEach } from "vitest"
import { auth } from "./auth"
import type { TestHelpers } from "better-auth/plugins"
describe("OTP verification", () => {
let test: TestHelpers
beforeAll(async () => {
const ctx = await auth.$context
test = ctx.test
})
beforeEach(() => {
test.clearOTPs()
})
it("should verify email with captured OTP", async () => {
const email = "[email protected]"
const user = test.createUser({ email, emailVerified: false })
await test.saveUser(user)
// Request OTP
await auth.api.sendVerificationOTP({
body: { email, type: "email-verification" }
})
// Get captured OTP
const otp = test.getOTP(email)
expect(otp).toBeDefined()
// Verify email
await auth.api.verifyEmail({
body: { email, otp }
})
// Cleanup
await test.deleteUser(user.id)
})
})