Next Starter Logo

Testing

Run end-to-end tests with Playwright in Next Starter: a setup project signs in once so every test reuses the session, plus how to add and run new specs.

Next Starter ships a Playwright end-to-end (E2E) suite. It runs against a production build on localhost:3000 with your local Postgres, never production data. Tests live in e2e/, configured by playwright.config.ts.

There is no unit test framework, and that is on purpose. Zod validates data at runtime, TypeScript strict mode catches type errors, and pnpm build proves the app compiles. E2E tests cover the critical user flows on top of that. For a project this size, that mix is enough.

How it works

Playwright runs three projects defined in playwright.config.ts. A project is a named group of tests with its own settings (browser, device, what to run).

ProjectWhat it does
setupSigns in once. Runs first (see below).
chromiumRuns every *.spec.ts in Desktop Chrome, already logged in
iphoneRuns only smoke.spec.ts on iPhone 14 Pro, already logged in

Both real test projects set two things:

  • dependencies: ["setup"] makes the setup project always run first.
  • storageState: "playwright/.auth/user.json" starts each test from the saved login.

Locally everything runs in parallel. In CI it uses a single worker with 2 retries and trace: "on-first-retry", which records a trace you can replay to debug a flaky failure. The webServer block starts the app with pnpm start and reuses a server that is already running.

Sign in once, reuse the session

Signing in before every test is slow. So the suite logs in one time, saves the login, and every test reuses it. This is what the setup project does.

Here is the idea step by step:

  1. e2e/auth.setup.ts POSTs a sign-in request straight to Better Auth's /api/auth/sign-in/email endpoint. No browser, no OAuth.
  2. The server replies with session cookies (your "logged in" proof).
  3. Those cookies get saved to a file: playwright/.auth/user.json.
  4. The chromium and iphone projects load that file, so they open already logged in.

There is no bot check to clear on that request. Turnstile guards the contact form only, not the auth routes, so the sign-in call goes straight through (see Turnstile). The contact form still needs TURNSTILE_SECRET_KEY, which the app validates at boot, so playwright.config.ts starts the server with Cloudflare's always-pass test secret. It is test-only and never used in production.

The email and password are hardcoded in e2e/auth.setup.ts (the user is e2e@test.com). There are no E2E_* environment variables. Before you run the tests, create a matching user in your local database with a verified email and the admin role, which the dashboard and admin route tests need. Prefer your own values? Edit the email and password in that file.

Running tests

CommandWhat it does
pnpm test:e2eRuns pnpm build first, then the full Playwright suite
pnpm test:e2e:uiOpens Playwright's interactive UI runner
pnpm test:e2e:reportShows the HTML report from the last run

The build script runs prisma generate && next build && prisma migrate deploy. So pnpm test:e2e always makes a fresh production build and applies the DB schema before testing. For :ui and :report, run a build yourself first so pnpm start has something to serve.

What's covered

Test fileWhat it checks
smoke.spec.tsHomepage loads, /api/health returns 200, 404 page renders
auth.spec.tsSign out works; a signed-out user is redirected to /auth/sign-in
dashboard.spec.tsBilling, admin users, and profile pages load for the test user
settings.spec.tsNotification preferences show, and saving them works
contact.spec.tsContact page loads, form validation fires in the browser
seo.spec.tsMeta title, description, and Open Graph tags are present

Some things are not covered. Test these by hand or in staging: Stripe webhooks (use the Stripe CLI), email sending, Google OAuth, and file uploads (these need R2/S3 connectivity).

Adding a test

Drop a new *.spec.ts file into e2e/. Playwright picks it up, and chromium runs it with the saved admin session already loaded, so you never sign in by hand:

import { expect, test } from "@playwright/test";

test("my page loads", async ({ page }) => {
  await page.goto("/my-page");
  await expect(page.getByRole("heading", { name: /my page/i })).toBeVisible();
});

To test a signed-out flow inside the authenticated project, clear the saved login for that block (as auth.spec.ts does):

test.describe("unauthenticated", () => {
  test.use({ storageState: { cookies: [], origins: [] } });
  // ...goes to /dashboard, expects redirect to /auth/sign-in
});

How route protection works (what the tests check)

There is no Next.js auth middleware. Access checks happen in Server Components, which is why the redirect tests pass:

  • app/dashboard/layout.tsx calls getSession(). No session redirects to /auth/sign-in. A session with onboardingComplete set to false redirects to /onboarding.
  • app/dashboard/(admin)/layout.tsx redirects to /dashboard when the user's role is not "admin".
  • Individual pages also call getSession() as a second layer of defense.

So a signed-out request to a protected page gets a server-side redirect to /auth/sign-in.

Gitignored artifacts

These are all gitignored, so never commit them:

  • playwright/.auth/: the saved session file
  • test-results/: output from failed runs
  • playwright-report/ and blob-report/: generated HTML reports

On this page