[GH-ISSUE #5609] Expose test utils for easy integration / E2E tests #27622

Closed
opened 2026-04-17 18:43:30 -05:00 by GiteaMirror · 15 comments
Owner

Originally created by @janhesters on GitHub (Oct 27, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/5609

Edit: Created a PR for this here: https://github.com/better-auth/better-auth/pull/7746

Edit 2: It's landed: https://better-auth.com/docs/plugins/test-utils

If you find this issue, kindly comment on the PR to make some buzz to get it merged.

Is this suited for github?

  • Yes, this is suited for github

Hi team 👋 I love Better Auth, thank you for the work you are doing.

Core problem: there is no first-class, easy way to use Better Auth in automated tests.

Two practical needs:

  1. Integration tests with Vitest (e.g. testing a React Router v7 action or a Next.js API route) need a fast way to create a user and attach valid auth headers to a Request.
  2. E2E tests with Playwright need a fast, programmatic login so each test can run in isolation with its own fresh user and cookies.

Pain points today

  • Logging in through the UI is very slow at scale and increases flakiness.
  • UI login forces shared, mutable test users, which hurts parallelization.
  • UI flows do not help integration tests that run without a browser.
  • Lack of docs or helpers means a lot of custom boilerplate and discovery time.
  • In my setup I even have to maintain a duplicate auth instance for Playwright because Bun modules do not load in Playwright’s Node context. That is not a Better Auth bug, but a Bun issue - just mentioned it here for context.

What I am trying to achieve

  • Create a user directly in the DB.
  • Programmatically sign in and receive session cookies or ready-to-use Headers.
  • Use those cookies in both Request objects (integration) and browser contexts (E2E).
  • Keep tests isolated and parallel-friendly.

Describe the solution you'd like

A first-class test mode or official plugin that ships with Better Auth.

API sketch

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";

export const auth = betterAuth({
  ...authOptions,
  database: drizzleAdapter(db, { provider: "sqlite", schema }),
  testMode: process.env.NODE_ENV === "test", // or plugins: [testHelpers()]
});

When testMode is true (or the plugin is added), expose helpers:

// DB helpers
const user = await auth.test.createUser(overrides?);
const org  = await auth.test.createOrganization(overrides?);
await auth.test.addMember({ userId: user.id, organizationId: org.id });

// Auth helpers
const { headers, cookies, session } = await auth.test.login({
  userId: user.id, // or email
  returnHeaders: true,     // Headers with Cookie already set
  returnCookies: true,     // parsed cookie objects
});

// Reset between tests if needed
await auth.test.reset(); // clears sessions, verifications for isolation

Features that would make this great

  • An in-memory OTP sink that automatically captures OTPs in test mode, so email delivery is skipped but the rest of the flow is exercised.

  • A cookie utility that returns either:

    • Headers with a proper Cookie header for server-side requests, or
    • an array of cookie objects that Playwright can pass to context.addCookies.
  • Cross-runtime support (Node and Bun) so Playwright can reuse the same helpers without a second auth instance.

  • Minimal surface that still covers common stacks: React Router v7 actions, Next.js Route Handlers, Vitest integration tests, Playwright E2E.

  • Basic docs with short examples for each of the above.

Tiny usage examples

Integration (Vitest):

const user = await auth.test.createUser();
const { headers } = await auth.test.login({ userId: user.id, returnHeaders: true });

const request = new Request("http://localhost/onboarding/user", {
  method: "POST",
  headers, // already contains Cookie
  body: formData,
});

const res = await action({ request, params: {}, context });
expect(res.status).toBe(302);

E2E (Playwright):

const user = await auth.test.createUser();
const { cookies } = await auth.test.login({ userId: user.id, returnCookies: true });
await page.context().addCookies(cookies);

Describe alternatives you've considered

Since there are no built-in helpers, I implemented the following.

1) OTP capture for tests

// app/tests/otp-store.ts
export const otpStore = new Map<string, string>();

export function setOtp(email: string, otp: string) {
  otpStore.set(email, otp);
}

export function getOtp(email: string) {
  const code = otpStore.get(email);
  if (!code) throw new Error(`No OTP captured for ${email}`);
  return code;
}

Hooked into my auth config:

plugins: [
  emailOTP({
    async sendVerificationOTP({ email, otp }) {
      if (process.env.NODE_ENV === "test") setOtp(email, otp);
      // real email logic otherwise
    },
  }),
],

2) Programmatic login for integration tests (Vitest)

export async function createAuthenticationHeaders(email: string): Promise<Headers> {
  await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } });
  const otp = getOtp(email);

  const { headers } = await auth.api.signInEmailOTP({
    body: { email, otp },
    returnHeaders: true,
  });

  const setCookies = headers.getSetCookie();
  const cookies = setCookies
    .map((c) => setCookieParser.parseString(c))
    .map((c) => `${c.name}=${c.value}`)
    .join("; ");

  if (!cookies) throw new Error("No session cookies returned from sign-in");
  return new Headers({ Cookie: cookies });
}

export async function createAuthenticatedRequest({
  url,
  method = "POST",
  formData,
  headers,
  user,
}: {
  url: string;
  method?: string;
  formData?: FormData;
  headers?: Headers;
  user: User;
}) {
  const authHeaders = await createAuthenticationHeaders(user.email);
  const existingCookie = headers?.get("Cookie");
  const authCookie = authHeaders.get("Cookie");

  const combinedHeaders = new Headers();
  if (headers) {
    for (const [k, v] of headers.entries()) if (k.toLowerCase() !== "cookie") combinedHeaders.set(k, v);
  }

  const cookie = [existingCookie, authCookie].filter(Boolean).join("; ");
  if (cookie) combinedHeaders.set("cookie", cookie);

  return new Request(url, { method, headers: combinedHeaders, body: formData });
}

Example test:

test("given: a valid name, should: update the user's name", async () => {
  const user = createPopulatedUser();
  await saveUserToDatabase(user);

  const request = await createAuthenticatedRequest({
    url: "http://localhost:3000/onboarding/user",
    method: "POST",
    formData: toFormData({ intent: ONBOARD_USER_INTENT, name: "New Name" }),
    user,
  });

  const res = await action({ request, params: {}, context: await createAuthTestContextProvider({ request, params: {} }) });
  expect(res.status).toEqual(302);

  const updated = await retrieveUserFromDatabaseById(user.id);
  expect(updated?.name).toEqual("New Name");

  await deleteUserFromDatabaseById(user.id);
});

3) Programmatic login for E2E (Playwright)

Because Playwright runs in Node, I keep a duplicate auth instance that uses better-sqlite3 instead of bun:sqlite:

// playwright/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./database"; // better-sqlite3
import { authOptions } from "~/lib/auth/auth-options";
import * as schema from "~/lib/database/schema";

export const auth = betterAuth({
  ...authOptions,
  database: drizzleAdapter(db, { provider: "sqlite", schema }),
});

Then I generate cookies and add them to the browser context:

export async function loginAndSaveUserToDatabase({ page, user = createPopulatedUser() }: { page: Page; user?: User }) {
  await saveUserToDatabase(user);
  await loginByCookie(page, user.email);
  return user;
}

async function loginByCookie(page: Page, email: string) {
  const authHeaders = await createAuthenticationHeaders(email);
  const cookieHeader = authHeaders.get("Cookie")!;
  const cookiePairs = cookieHeader.split("; ");

  const cookies = cookiePairs.map((pair) => {
    const [name, ...valueParts] = pair.split("=");
    const value = valueParts.join("=");

    return { name, value, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" as const };
  });

  await page.context().addCookies(cookies);
}

Example Playwright test:

test("given: valid name and profile image, should: save successfully", async ({ page }) => {
  const user = await loginAndSaveUserToDatabase({ page });
  await page.goto("/onboarding/user");
  await page.getByRole("textbox", { name: /name/i }).fill("John Doe");
  await page.getByRole("button", { name: /save/i }).click();
  await expect(page).toHaveURL(/\/onboarding\/organization/);

  const updated = await retrieveUserFromDatabaseById(user.id);
  expect(updated?.name).toBe("John Doe");
  await deleteUserFromDatabaseById(user.id);
});

Why these alternatives are not ideal

  • Lots of boilerplate to maintain.
  • Need to keep OTP capture in sync with auth config.
  • Took significant trial and error to get cookie handling correct.

Additional context

Why this matters

  • Programmatic login in tests is 50x to 100x faster than UI login in my runs.
  • Fresh users and sessions per test make suites more parallel-friendly and less flaky.

Environment where I hit this

  • React Router v7, Vitest, Playwright
  • Drizzle ORM, SQLite (bun:sqlite in app, better-sqlite3 for Playwright)
  • Email OTP plugin

Nice-to-have acceptance criteria

  • testMode or testHelpers plugin exposes auth.test.createUser, auth.test.login, auth.test.createOrganization, auth.test.addMember etc..
  • Helpers return either Headers with Cookie or parsed cookies for Playwright.
  • Works in both Node and Bun.
  • Short docs with examples for Vitest and Playwright.
Originally created by @janhesters on GitHub (Oct 27, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/5609 ~~**Edit:** Created a PR for this here: https://github.com/better-auth/better-auth/pull/7746~~ **Edit 2:** It's landed: https://better-auth.com/docs/plugins/test-utils ❗ If you find this issue, kindly comment on the PR to make some buzz to get it merged. ### Is this suited for github? - [x] Yes, this is suited for github ### Is your feature request related to a problem? Please describe. Hi team 👋 I love Better Auth, thank you for the work you are doing. **Core problem:** there is no first-class, easy way to use Better Auth in automated tests. Two practical needs: 1. **Integration tests** with Vitest (e.g. testing a React Router v7 `action` or a Next.js API route) need a fast way to create a user and attach valid auth headers to a `Request`. 2. **E2E tests** with Playwright need a fast, programmatic login so each test can run in isolation with its own fresh user and cookies. **Pain points today** * Logging in through the UI is very slow at scale and increases flakiness. * UI login forces shared, mutable test users, which hurts parallelization. * UI flows do not help integration tests that run without a browser. * Lack of docs or helpers means a lot of custom boilerplate and discovery time. * In my setup I even have to maintain a duplicate auth instance for Playwright because Bun modules do not load in Playwright’s Node context. That is not a Better Auth bug, but a Bun issue - just mentioned it here for context. **What I am trying to achieve** * Create a user directly in the DB. * Programmatically sign in and receive session cookies or ready-to-use `Headers`. * Use those cookies in both Request objects (integration) and browser contexts (E2E). * Keep tests isolated and parallel-friendly. ### Describe the solution you'd like A first-class **test mode** or **official plugin** that ships with Better Auth. ### API sketch ```ts import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; export const auth = betterAuth({ ...authOptions, database: drizzleAdapter(db, { provider: "sqlite", schema }), testMode: process.env.NODE_ENV === "test", // or plugins: [testHelpers()] }); ``` When `testMode` is true (or the plugin is added), expose helpers: ```ts // DB helpers const user = await auth.test.createUser(overrides?); const org = await auth.test.createOrganization(overrides?); await auth.test.addMember({ userId: user.id, organizationId: org.id }); // Auth helpers const { headers, cookies, session } = await auth.test.login({ userId: user.id, // or email returnHeaders: true, // Headers with Cookie already set returnCookies: true, // parsed cookie objects }); // Reset between tests if needed await auth.test.reset(); // clears sessions, verifications for isolation ``` ### Features that would make this great * An **in-memory OTP sink** that automatically captures OTPs in test mode, so email delivery is skipped but the rest of the flow is exercised. * A **cookie utility** that returns either: * `Headers` with a proper `Cookie` header for server-side requests, or * an array of cookie objects that Playwright can pass to `context.addCookies`. * Cross-runtime support (Node and Bun) so Playwright can reuse the same helpers without a second auth instance. * Minimal surface that still covers common stacks: React Router v7 actions, Next.js Route Handlers, Vitest integration tests, Playwright E2E. * Basic docs with short examples for each of the above. ### Tiny usage examples **Integration (Vitest):** ```ts const user = await auth.test.createUser(); const { headers } = await auth.test.login({ userId: user.id, returnHeaders: true }); const request = new Request("http://localhost/onboarding/user", { method: "POST", headers, // already contains Cookie body: formData, }); const res = await action({ request, params: {}, context }); expect(res.status).toBe(302); ``` **E2E (Playwright):** ```ts const user = await auth.test.createUser(); const { cookies } = await auth.test.login({ userId: user.id, returnCookies: true }); await page.context().addCookies(cookies); ``` ### Describe alternatives you've considered Since there are no built-in helpers, I implemented the following. ### 1) OTP capture for tests ```ts // app/tests/otp-store.ts export const otpStore = new Map<string, string>(); export function setOtp(email: string, otp: string) { otpStore.set(email, otp); } export function getOtp(email: string) { const code = otpStore.get(email); if (!code) throw new Error(`No OTP captured for ${email}`); return code; } ``` Hooked into my auth config: ```ts plugins: [ emailOTP({ async sendVerificationOTP({ email, otp }) { if (process.env.NODE_ENV === "test") setOtp(email, otp); // real email logic otherwise }, }), ], ``` ### 2) Programmatic login for integration tests (Vitest) ```ts export async function createAuthenticationHeaders(email: string): Promise<Headers> { await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); const otp = getOtp(email); const { headers } = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true, }); const setCookies = headers.getSetCookie(); const cookies = setCookies .map((c) => setCookieParser.parseString(c)) .map((c) => `${c.name}=${c.value}`) .join("; "); if (!cookies) throw new Error("No session cookies returned from sign-in"); return new Headers({ Cookie: cookies }); } export async function createAuthenticatedRequest({ url, method = "POST", formData, headers, user, }: { url: string; method?: string; formData?: FormData; headers?: Headers; user: User; }) { const authHeaders = await createAuthenticationHeaders(user.email); const existingCookie = headers?.get("Cookie"); const authCookie = authHeaders.get("Cookie"); const combinedHeaders = new Headers(); if (headers) { for (const [k, v] of headers.entries()) if (k.toLowerCase() !== "cookie") combinedHeaders.set(k, v); } const cookie = [existingCookie, authCookie].filter(Boolean).join("; "); if (cookie) combinedHeaders.set("cookie", cookie); return new Request(url, { method, headers: combinedHeaders, body: formData }); } ``` Example test: ```ts test("given: a valid name, should: update the user's name", async () => { const user = createPopulatedUser(); await saveUserToDatabase(user); const request = await createAuthenticatedRequest({ url: "http://localhost:3000/onboarding/user", method: "POST", formData: toFormData({ intent: ONBOARD_USER_INTENT, name: "New Name" }), user, }); const res = await action({ request, params: {}, context: await createAuthTestContextProvider({ request, params: {} }) }); expect(res.status).toEqual(302); const updated = await retrieveUserFromDatabaseById(user.id); expect(updated?.name).toEqual("New Name"); await deleteUserFromDatabaseById(user.id); }); ``` ### 3) Programmatic login for E2E (Playwright) Because Playwright runs in Node, I keep a **duplicate** auth instance that uses `better-sqlite3` instead of `bun:sqlite`: ```ts // playwright/auth.ts import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db } from "./database"; // better-sqlite3 import { authOptions } from "~/lib/auth/auth-options"; import * as schema from "~/lib/database/schema"; export const auth = betterAuth({ ...authOptions, database: drizzleAdapter(db, { provider: "sqlite", schema }), }); ``` Then I generate cookies and add them to the browser context: ```ts export async function loginAndSaveUserToDatabase({ page, user = createPopulatedUser() }: { page: Page; user?: User }) { await saveUserToDatabase(user); await loginByCookie(page, user.email); return user; } async function loginByCookie(page: Page, email: string) { const authHeaders = await createAuthenticationHeaders(email); const cookieHeader = authHeaders.get("Cookie")!; const cookiePairs = cookieHeader.split("; "); const cookies = cookiePairs.map((pair) => { const [name, ...valueParts] = pair.split("="); const value = valueParts.join("="); return { name, value, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax" as const }; }); await page.context().addCookies(cookies); } ``` Example Playwright test: ```ts test("given: valid name and profile image, should: save successfully", async ({ page }) => { const user = await loginAndSaveUserToDatabase({ page }); await page.goto("/onboarding/user"); await page.getByRole("textbox", { name: /name/i }).fill("John Doe"); await page.getByRole("button", { name: /save/i }).click(); await expect(page).toHaveURL(/\/onboarding\/organization/); const updated = await retrieveUserFromDatabaseById(user.id); expect(updated?.name).toBe("John Doe"); await deleteUserFromDatabaseById(user.id); }); ``` **Why these alternatives are not ideal** * Lots of boilerplate to maintain. * Need to keep OTP capture in sync with auth config. * Took significant trial and error to get cookie handling correct. ### Additional context **Why this matters** * Programmatic login in tests is 50x to 100x faster than UI login in my runs. * Fresh users and sessions per test make suites more parallel-friendly and less flaky. **Environment where I hit this** * React Router v7, Vitest, Playwright * Drizzle ORM, SQLite (`bun:sqlite` in app, `better-sqlite3` for Playwright) * Email OTP plugin **Nice-to-have acceptance criteria** * [ ] `testMode` or `testHelpers` plugin exposes `auth.test.createUser`, `auth.test.login`, `auth.test.createOrganization`, `auth.test.addMember` etc.. * [ ] Helpers return either `Headers` with `Cookie` or parsed cookies for Playwright. * [ ] Works in both Node and Bun. * [ ] Short docs with examples for Vitest and Playwright.
GiteaMirror added the lockedenhancement labels 2026-04-17 18:43:30 -05:00
Author
Owner

@dbworku commented on GitHub (Nov 15, 2025):

Yes, this would be huge. I see that Better Auth already has some test-utils for testing the framework. Perhaps start by exposing as "better-auth/test-utils". Thoughts?

<!-- gh-comment-id:3535292426 --> @dbworku commented on GitHub (Nov 15, 2025): Yes, this would be huge. I see that Better Auth already has some test-utils for testing the framework. Perhaps start by exposing as `"better-auth/test-utils"`. Thoughts?
Author
Owner

@ozgurozalp commented on GitHub (Nov 19, 2025):

@Bekacru

<!-- gh-comment-id:3553367751 --> @ozgurozalp commented on GitHub (Nov 19, 2025): @Bekacru
Author
Owner

@ftzi commented on GitHub (Nov 23, 2025):

+1. We need a good way to integrate Better-Auth with Playwright. E2E mostly always starts with auth

<!-- gh-comment-id:3568216892 --> @ftzi commented on GitHub (Nov 23, 2025): +1. We need a good way to integrate Better-Auth with Playwright. E2E mostly always starts with auth
Author
Owner

@backrunner commented on GitHub (Dec 18, 2025):

+1, there're always some features will require a valid session in projects, the sign-in panel might have a captcha so I can't use the auto input either, it will be more convenient if we can create a temp session for e2e tests.

<!-- gh-comment-id:3668049906 --> @backrunner commented on GitHub (Dec 18, 2025): +1, there're always some features will require a valid session in projects, the sign-in panel might have a captcha so I can't use the auto input either, it will be more convenient if we can create a temp session for e2e tests.
Author
Owner

@brennan-chestnut commented on GitHub (Dec 18, 2025):

This would also be really useful for us too. Right now, we are doing something similar to https://nelsonlai.dev/blog/e2e-testing-with-better-auth

<!-- gh-comment-id:3668251728 --> @brennan-chestnut commented on GitHub (Dec 18, 2025): This would also be really useful for us too. Right now, we are doing something similar to https://nelsonlai.dev/blog/e2e-testing-with-better-auth
Author
Owner

@himself65 commented on GitHub (Jan 2, 2026):

We have better-auth/test-utils endpoint which uses vitest. At some points we goona have a separate package for testing

<!-- gh-comment-id:3704680238 --> @himself65 commented on GitHub (Jan 2, 2026): We have `better-auth/test-utils` endpoint which uses vitest. At some points we goona have a separate package for testing
Author
Owner

@AmirSa12 commented on GitHub (Jan 5, 2026):

we really need this for easier integration with playwright

<!-- gh-comment-id:3710686078 --> @AmirSa12 commented on GitHub (Jan 5, 2026): we really need this for easier integration with playwright
Author
Owner

@abielzulio commented on GitHub (Jan 26, 2026):

what's the latest update here?

<!-- gh-comment-id:3798077115 --> @abielzulio commented on GitHub (Jan 26, 2026): what's the latest update here?
Author
Owner

@janhesters commented on GitHub (Feb 1, 2026):

Created a PR for this here: https://github.com/better-auth/better-auth/pull/7746

<!-- gh-comment-id:3830875766 --> @janhesters commented on GitHub (Feb 1, 2026): Created a PR for this here: https://github.com/better-auth/better-auth/pull/7746
Author
Owner

@janhesters commented on GitHub (Feb 3, 2026):

@Bekacru opened a PR for this, as this is highly requested (and we still need this, too 🙏 )

<!-- gh-comment-id:3840765539 --> @janhesters commented on GitHub (Feb 3, 2026): @Bekacru opened a PR for this, as this is highly requested (and we still need this, too 🙏 )
Author
Owner

@brandensilva commented on GitHub (Feb 4, 2026):

This would be amazing.

<!-- gh-comment-id:3848902975 --> @brandensilva commented on GitHub (Feb 4, 2026): This would be amazing.
Author
Owner

@janhesters commented on GitHub (Feb 5, 2026):

Also pinging @himself65 🙏

<!-- gh-comment-id:3855805582 --> @janhesters commented on GitHub (Feb 5, 2026): Also pinging @himself65 🙏
Author
Owner

@mikenicklas commented on GitHub (Feb 9, 2026):

Bumping this. It would be great to have a standard way of writing playwright tests without doing some hacky auth stuff to get it to work.

<!-- gh-comment-id:3871573494 --> @mikenicklas commented on GitHub (Feb 9, 2026): Bumping this. It would be great to have a standard way of writing playwright tests without doing some hacky auth stuff to get it to work.
Author
Owner

@ping-maxwell commented on GitHub (Feb 11, 2026):

Hello all, we're moving all feature requests or enhancement issues over to Github Discussions.

I've went ahead and created the discussion here:
https://github.com/better-auth/better-auth/discussions/7914

<!-- gh-comment-id:3883028339 --> @ping-maxwell commented on GitHub (Feb 11, 2026): Hello all, we're moving all feature requests or enhancement issues over to Github Discussions. I've went ahead and created the discussion here: https://github.com/better-auth/better-auth/discussions/7914
Author
Owner

@janhesters commented on GitHub (Mar 1, 2026):

This has now landed! https://better-auth.com/docs/plugins/test-utils

<!-- gh-comment-id:3979622867 --> @janhesters commented on GitHub (Mar 1, 2026): This has now landed! https://better-auth.com/docs/plugins/test-utils
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#27622