BETTER-AUTH.

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

auth.test.ts
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

test-setup.ts
const ctx = await auth.$context
const test = ctx.test

Can 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.

auth.ts
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 string

getAuthHeaders

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 value
  • domain - Cookie domain
  • path - Cookie path (defaults to "/")
  • httpOnly - Whether cookie is HTTP-only
  • secure - Whether cookie requires HTTPS
  • sameSite - 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.

auth.test.ts
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

OptionTypeDefaultDescription
captureOTPbooleanfalseEnable 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)
    })
})