mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
Signed-off-by: Gautam Manchandani <manchandanigautam@gmail.com> Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com>
375 lines
10 KiB
Plaintext
375 lines
10 KiB
Plaintext
---
|
|
title: Test Utils
|
|
description: 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.
|
|
|
|
<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>
|
|
|
|
## Installation
|
|
|
|
<Steps>
|
|
<Step>
|
|
### Add the plugin to a test-only auth config
|
|
|
|
```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.
|
|
</Step>
|
|
|
|
<Step>
|
|
### Access test helpers via context
|
|
|
|
```ts title="test-setup.ts"
|
|
const ctx = await auth.$context
|
|
const test = ctx.test
|
|
```
|
|
</Step>
|
|
</Steps>
|
|
|
|
## 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.
|
|
|
|
```ts title="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.
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
const user = test.createUser({ email: "test@example.com" })
|
|
const savedUser = await test.saveUser(user)
|
|
```
|
|
|
|
#### deleteUser
|
|
|
|
Deletes a user from the database.
|
|
|
|
```ts
|
|
await test.deleteUser(user.id)
|
|
```
|
|
|
|
#### saveOrganization
|
|
|
|
Saves an organization to the database. Only available with the organization plugin.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
await test.deleteOrganization(org.id)
|
|
```
|
|
|
|
#### addMember
|
|
|
|
Adds a user as a member of an organization. Only available with the organization plugin.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
<Callout>
|
|
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.
|
|
</Callout>
|
|
|
|
```ts title="auth.test.ts"
|
|
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
|
|
}
|
|
})
|
|
]
|
|
})
|
|
```
|
|
|
|
#### getOTP
|
|
|
|
Retrieves a captured OTP by identifier (email or phone number).
|
|
|
|
```ts
|
|
// 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)
|
|
|
|
```ts
|
|
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)
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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)
|
|
})
|
|
})
|
|
```
|