Test Utils
Testing utilities for integration and E2E testing
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.
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.
Installation
Add the plugin to a test-only auth config
import { betterAuth } from "better-auth"
import { testUtils } from "better-auth/plugins"
export const auth = betterAuth({
// ... other config options
plugins: [
testUtils()
]
})Keeping testUtils() in a separate test-only auth instance preserves type inference for ctx.test without adding the plugin to your production auth config.
Access test helpers via context
const ctx = await auth.$context
const test = ctx.testCan I include this in production?
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.
TypeScript caveat
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.
Usage
Factories
Factories create objects without writing to the database. Use them to generate test data with sensible defaults.
createUser
Creates a user object with default values that can be overridden.
// Create user with defaults
const user = test.createUser()
// { id: "...", email: "user-xxx@example.com", name: "Test User", emailVerified: true, ... }
// Create user with overrides
const user = test.createUser({
email: "alice@example.com",
name: "Alice",
emailVerified: false
})createOrganization
Creates an organization object. Only available when the organization plugin is installed.
const org = test.createOrganization({
name: "Acme Corp",
slug: "acme-corp"
})Database Helpers
Database helpers persist and remove test data from the database.
saveUser
Saves a user to the database.
const user = test.createUser({ email: "test@example.com" })
const savedUser = await test.saveUser(user)deleteUser
Deletes a user from the database.
await test.deleteUser(user.id)saveOrganization
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)deleteOrganization
Deletes an organization from the database. Only available with the organization plugin.
await test.deleteOrganization(org.id)addMember
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
Auth helpers create authenticated sessions for testing protected routes.
login
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 stringgetAuthHeaders
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 })getCookies
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")
OTP Capture
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.
OTP capture is passive - it does not prevent OTPs from being sent via your configured sendVerificationOTP function. It simply stores a copy for test retrieval.
import { betterAuth } from "better-auth"
import { testUtils, emailOTP } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
testUtils({ captureOTP: true }),
emailOTP({
async sendVerificationOTP({ email, otp }) {
// Your email sending logic
}
})
]
})getOTP
Retrieves a captured OTP by identifier (email or phone number).
// Send OTP
await auth.api.sendVerificationOTP({
body: { email: "user@example.com", type: "sign-in" }
})
// Retrieve captured OTP
const otp = test.getOTP("user@example.com")
// "123456"Options
| Option | Type | Default | Description |
|---|---|---|---|
captureOTP | boolean | false | Enable OTP capture for testing verification flows |
Examples
Integration Test (Vitest)
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: "test@example.com" })
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)
})
})E2E Test (Playwright)
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: "e2e@example.com",
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)
})OTP Verification Test
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 = "otp-test@example.com"
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)
})
})