← Back to blog

Test Own Auth with Vitest and Playwright


Authentication tests should exercise the same Own Auth methods and HTTP routes used in production. A test only login endpoint, hard coded session, or database fixture that inserts a token hash directly can make the suite fast while skipping the behavior that must work after release.

Use a dedicated Postgres database

Create a database that contains no customer data, apply the normal Own Auth migrations, and point the test process at it. Use a different token pepper from every deployed environment. Run database tests serially when they reset shared tables, or give each worker its own database or schema when tests run in parallel.

test/auth.integration.test.ts
import { afterAll, describe, expect, it } from "vitest";
import { createOwnAuth } from "own-auth";

const auth = createOwnAuth({
  tokenPepper: process.env.TEST_OWN_AUTH_TOKEN_PEPPER!,
});

afterAll(async () => {
  await auth.close();
});

describe("email and password sessions", () => {
  it("creates, verifies, and revokes a session", async () => {
    const email = "user-" + crypto.randomUUID() + "@example.test";
    const created = await auth.signUpEmailPassword({
      email,
      password: "correct horse battery staple",
      name: "Test User",
    });

    const current = await auth.getCurrentSession(created.sessionToken);
    expect(current?.user.id).toBe(created.user.id);

    await auth.signOut({ sessionToken: created.sessionToken });
    expect(await auth.getCurrentSession(created.sessionToken)).toBeNull();
  });
});

Generate a unique email for each test. Shared users create ordering problems when one test changes a password, enables MFA, or revokes every session. Close the Own Auth instance so database pools do not keep Vitest alive. Cleanup may delete users only in the dedicated test database and should use an explicit test utility that cannot be imported by production code.

Test every sign in result

Email and password sign in returns either complete or mfa_required. Tests must narrow the status before reading session fields. Cover invalid credentials, disabled users, absolute expiry, idle expiry, one session revocation, all session revocation, password reset, and the configured rate limits. An MFA test should prove that the first factor creates no session and that the second factor creates an aal2 session.

test/mfa.integration.test.ts
const firstFactor = await auth.signInEmailPassword({ email, password });
expect(firstFactor.status).toBe("mfa_required");

if (firstFactor.status !== "mfa_required") {
  throw new Error("Expected an MFA challenge");
}

const completed = await auth.completeMfaWithTotp({
  challengeToken: firstFactor.challengeToken,
  code: currentAuthenticatorCode,
});

expect(completed.session.assuranceLevel).toBe("aal2");

Create Playwright state through the real handler

Playwright can sign in through /api/auth/sign-in/email and save the returned HttpOnly cookie as storage state. Keep that state under the test output directory or an ignored playwright/.auth directory because it can impersonate the test account until the session expires or is revoked.

tests/auth.setup.ts
import { expect, test as setup } from "@playwright/test";

const authFile = "playwright/.auth/user.json";

setup("authenticate", async ({ request }) => {
  const response = await request.post("/api/auth/sign-in/email", {
    data: {
      email: process.env.E2E_USER_EMAIL!,
      password: process.env.E2E_USER_PASSWORD!,
    },
    headers: {
      origin: process.env.E2E_BASE_URL!,
    },
  });

  expect(response.ok()).toBe(true);
  const result = await response.json();
  expect(result.status).toBe("complete");
  await request.storageState({ path: authFile });
});
playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  use: { baseURL: process.env.E2E_BASE_URL },
  projects: [
    { name: "setup", testMatch: /auth.setup.ts/ },
    {
      name: "chromium",
      dependencies: ["setup"],
      use: { storageState: "playwright/.auth/user.json" },
    },
  ],
});

A shared authenticated state is suitable only when tests do not mutate account state. For parallel settings, billing, role, or security tests, allocate one account per Playwright worker and save one state file per worker. Reusing one mutable account produces failures that depend on execution order.

Keep production behavior intact

Do not disable CSRF checks, MFA, rate limits, email verification, or OAuth state validation globally for the suite. Supply the correct origin, use controlled email and SMS adapters, and capture their test deliveries without logging raw tokens. Browser coverage should include one normal sign in, one MFA path, one sign out, one revoked session, and one rejected cross origin mutation.

Capture delivery through test adapters

A test email provider can retain the latest verification, reset, magic link, or invitation delivery in process memory. The test reads the token or URL from that adapter and submits it through the normal verification method. Keep the adapter in the test module and fail startup if production configuration attempts to use it.

Reset captured deliveries between tests and identify them by recipient plus purpose. A single global latest message becomes nondeterministic when several tests run at once. The same rule applies to SMS codes.

Exercise OAuth without weakening state validation

Use a controlled provider adapter in integration tests or a provider sandbox in browser tests. The adapter should still issue signed, expiring responses with issuer, audience, subject, nonce, and state behavior that matches the production integration. Do not add a query parameter that tells the callback route to trust an arbitrary user identity.

Make cleanup observable

Clean test records by their generated run identifier and only inside the dedicated test database. Report leftover records as a failed teardown instead of silently ignoring them. This prevents a growing database from hiding uniqueness conflicts or making rate limit tests depend on old state.

Keep one production shaped browser journey that starts from a clean account and completes the visible interface. API setup state accelerates most tests, but it should not be the only proof that field validation, password manager behavior, MFA navigation, and error copy work together.

Record the database engine, Own Auth version, migration version, browser engines, and worker count with the test result. When a failure appears only under parallel execution, preserve the run identifier and inspect actual account, session, challenge, and rate limit ownership instead of rerunning until it disappears.

A passing component test proves the auth engine behavior. A passing Playwright journey proves that routing, cookies, proxy headers, browser origin checks, and application navigation agree. Keep both layers because neither substitutes for the other.