ZenoZENO

E2E testing

End-to-end testing with Playwright

Zeno uses Playwright for end-to-end tests. The @zeno-lib/e2e package provides a shared Playwright config preset and a dependency checker, so a single packages/e2e package can cover every app in your monorepo.

Keeping the whole suite in one package means one Playwright install, one set of browser binaries, one config, and one task-runner entry point. It also lets one test visit more than one app. For tests that live next to the code they cover, see Unit testing instead.

What you get

Adding @zeno-lib/e2e (with its @playwright/test peer dependency) gives you a config preset, a dependency checker and an API sign-in helper. Import the runtime helpers (test, expect, defineConfig, devices, …) directly from @playwright/test:

ImportWhat it provides
@zeno-lib/e2e/configThe baseConfig preset you spread into your own Playwright config
@zeno-lib/e2e/verify-depsverifyAppDeps(), the dependency check as a function
@zeno-lib/e2e/authsignInViaApi(), which signs a Supabase user in without driving the sign-in form
zeno-e2e (bin)The zeno-e2e verify-deps command

Installation

The examples below use two placeholders: @yourorg for your monorepo's npm scope, and webapp for one of the apps you test. Swap in your own names.

1. Create the package

Create the package with this package.json:

packages/e2e/package.json
{
  "name": "@yourorg/e2e",
  "type": "module",
  "private": true,
  "scripts": {
    "e2e": "playwright test",
    "e2e:watch": "playwright test --ui",
    "verify-deps": "zeno-e2e verify-deps"
  },
  "devDependencies": {
    "@yourorg/webapp": "workspace:*"
  }
}

private: true keeps this test-only package from being published. The scripts do the following:

  • e2e runs the suite once. The playwright CLI ships with @playwright/test.
  • e2e:watch opens Playwright's UI mode, for watching and debugging a run.
  • verify-deps checks that every app you test is listed as a dependency. See Keeping app dependencies in sync.

devDependencies holds one entry per app you write tests for. Listing an app there is what puts it in your task runner's build graph, so the task runner rebuilds it before the tests run. See Wiring it into Turborepo.

Then, from inside packages/e2e, add the preset and its @playwright/test peer dependency:

npm install --save-dev @zeno-lib/e2e @playwright/test

That installs the test runner but not the browsers it drives, so download those once:

npx playwright install --with-deps

2. Create a Playwright config

baseConfig is an ordinary Playwright config object holding the browser, reporter, retry and timeout defaults every Zeno suite shares, listed in full below. It has no webServer, because only you know how to start your apps.

So spread it, then add one webServer entry per app you test, each on the port that app serves. For each entry, Playwright starts the app and waits for its url to respond before the first test runs:

packages/e2e/playwright.config.ts
import { defineConfig } from "@playwright/test"
import { baseConfig } from "@zeno-lib/e2e/config"

export default defineConfig({
  ...baseConfig,
  // Lets specs use relative paths. Drop it if you test several apps
  use: { ...baseConfig.use, baseURL: "http://localhost:5002" },
  webServer: [
    {
      // Whatever your app's own start script takes to pick a port
      command: "pnpm --filter @yourorg/webapp start -- --port 5002",
      url: "http://localhost:5002",
      reuseExistingServer: !process.env.CI,
      // A cold production start can exceed Playwright's 60s default
      timeout: 120_000,
    },
  ],
})

With a single app, baseURL lets every spec navigate with a relative path, so you set the port in one place. With several apps on several ports, leave it out and have each spec target its own app's absolute URL. Spread baseConfig.use alongside it, as above, or you drop the preset's trace setting, as Overriding the defaults explains.

reuseExistingServer: !process.env.CI means that locally Playwright attaches to whatever already listens on the port instead of starting the app itself. That is usually a dev server with HMR rather than the production build, so stop it before you trust a local pass.

3. Write your first test

baseConfig looks for specs in ./tests and leaves testMatch at Playwright's default. A file counts as a spec once its name ends in .spec.ts or .test.ts (the .tsx, .js, .jsx, .mjs and .cjs variants work too). Put each one under tests/<app>/, where <app> matches the app's folder name under apps/. verify-deps reads that pairing to work out which apps need to be dependencies:

packages/e2e/
├── playwright.config.ts     # extends @zeno-lib/e2e's baseConfig
├── package.json             # depends on @zeno-lib/e2e and each tested app
└── tests/
    └── webapp/              # tests for apps/webapp
        └── home.spec.ts
packages/e2e/tests/webapp/home.spec.ts
import { expect, test } from "@playwright/test"

test("home page loads", async ({ page }) => {
  const response = await page.goto("/")
  expect(response?.status()).toBe(200)
})

From here on it is plain Playwright. See Writing tests for locators, actions and assertions, and Best practices for why to assert on what a user can see rather than on your markup.

4. Run it

pnpm e2e

Wiring it into Turborepo

pnpm e2e tests whatever build happens to be on disk. Turborepo can rebuild the apps first, using the app devDependencies and the verify-deps script from step 1:

turbo.json
{
  "tasks": {
    "e2e": {
      "dependsOn": ["^build", "@yourorg/e2e#verify-deps"]
    },
    "verify-deps": {
      "cache": false
    }
  }
}

^build builds everything the e2e package depends on, which is why every tested app has to be listed there. verify-deps fails the task when one is missing. Now turbo run e2e builds the apps, checks the app dependencies, and runs the tests.

The config preset

@zeno-lib/e2e/config exports baseConfig, a plain Playwright config object you spread and override. Four of its settings depend on whether CI is set:

FieldLocallyIn CI
forbidOnlyoffon (a stray test.only fails the build)
retries03
timeout120s30s
reporterlisthtml

Those four read process.env.CI when the config loads, so they resolve in whatever environment the tests run in, without you wiring anything up. E2E in CI covers what that means for a pipeline.

The rest is the same everywhere: testDir is ./tests, projects is a single chromium project (Desktop Chrome), fullyParallel is true, and use.trace is on-first-retry so a retried failure leaves a trace to open. webServer is the one field it leaves to you.

Everything else you need (defineConfig, devices, test, expect) comes straight from @playwright/test.

Overriding the defaults

baseConfig is a plain object, so any key you set after the spread overrides the preset's value:

packages/e2e/playwright.config.ts
import { defineConfig } from "@playwright/test"
import { baseConfig } from "@zeno-lib/e2e/config"

export default defineConfig({
  ...baseConfig,
  // Our specs share one database, so don't run them in parallel
  workers: 1,
  use: { ...baseConfig.use, screenshot: "only-on-failure" },
  // baseURL and webServer as in step 2
})

Spreading is shallow, so a nested object like use replaces the preset's whole use object rather than merging into it. Spread baseConfig.use alongside your own keys, as above, or you drop trace: "on-first-retry" without noticing.

Overriding timeout, retries, reporter or forbidOnly also throws away their CI-aware behaviour, so keep the process.env.CI check if you still want one value locally and another in the pipeline.

Playwright's configuration guide covers the common options, and the TestConfig reference lists every field you can set.

Keeping app dependencies in sync

Every app with a tests/<app>/ folder must be a dependency of the e2e package. Otherwise Turborepo doesn't rebuild it, and the tests run against a stale build. zeno-e2e verify-deps (installed with @zeno-lib/e2e) enforces this.

See Wiring it into Turborepo to run it on every e2e task. It also works on its own:

zeno-e2e verify-deps

It matches each tests/<app>/ folder to apps/<app>. If an app is missing, it prints the exact devDependencies block to paste in, then exits non-zero. Listing an app under dependencies satisfies it too, since it checks the union of both fields. The defaults suit a packages/e2e package, and a flag overrides each one:

FlagDefaultWhat it does
--apps-dir../../appsDirectory that contains the apps
--tests-dir./testsDirectory with one folder per tested app
--package-json./package.jsonpackage.json whose dependencies must list the apps
-h, --helpn/aPrint the options and exit

Those relative paths resolve from wherever you run the command, not from the package, so run it from the e2e package (as pnpm verify-deps does) or pass absolute paths.

Checking from a script

For a custom CI step or a codemod, @zeno-lib/e2e/verify-deps exports the same check as a function that returns the result instead of ending the process:

import { verifyAppDeps } from "@zeno-lib/e2e/verify-deps"

const { ok, checked, missing } = verifyAppDeps({
  appsDir: "../../apps",
  testsDir: "./tests",
  packageJsonPath: "./package.json",
})

ok is true when nothing is missing, checked counts the apps it looked at, and missing lists the package names to add, sorted. Exiting is the CLI's job, so the function never calls process.exit. It throws only when a package.json can't be read or parsed, or when a tested app has no name field to depend on.

Signing in through the API

Most specs need a signed-in user, not a test of the sign-in form. Mount a test-only route in the app with createTestSignInRoute from @zeno-lib/supabase/next-test-sign-in, gated on your own environment variable:

apps/webapp/app/api/test/sign-in/route.ts
import { createTestSignInRoute } from "@zeno-lib/supabase/next-test-sign-in"

export const POST = createTestSignInRoute({
  isEnabled: () => ["development", "test"].includes(process.env.NEXT_PUBLIC_ENVIRONMENT ?? ""),
})

Then sign in from the spec. signInViaApi posts the credentials, sets the Supabase SSR auth cookie from the session the route returns, and broadcasts SIGNED_IN to any Supabase browser client already open on the page:

packages/e2e/tests/webapp/dashboard.spec.ts
import { expect, test } from "@playwright/test"
import { signInViaApi } from "@zeno-lib/e2e/auth"

test.beforeEach(async ({ page }) => {
  await signInViaApi({
    email: "admin@example.test",
    page,
    password: process.env.TEST_USER_PASSWORD ?? "",
    url: "http://localhost:3000/api/test/sign-in",
  })
})

The user must already exist, so seed it with your fixtures. When the route is disabled, it answers 404, and signInViaApi throws with that status. Exempt the route's path from your middleware's auth redirect (publicPaths), since the helper calls it without a session.

Commands

# Run the whole suite
pnpm e2e

# Run a single spec
pnpm e2e tests/webapp/home.spec.ts

# UI mode: watch, re-run and step through
pnpm e2e:watch

In a monorepo, scope the run to one package with Turborepo's --filter:

pnpm turbo run e2e --filter <package-name>

To debug a failure, see Playwright's Debugging tests and its Trace viewer, which opens the trace use.trace records on a retry. Playwright writes those traces to test-results/, and the CI html reporter writes playwright-report/, so add both to your .gitignore.

Common mistakes

  • Skipping npx playwright install. npm install installs the test runner but not the browsers, so every run fails at launch with "Executable doesn't exist".
  • Forgetting to list a tested app as a dependency. Turborepo then doesn't rebuild it, and the tests run against a stale build. zeno-e2e verify-deps catches this.
  • Naming a test folder something other than the app's directory. verify-deps pairs tests/<app>/ with apps/<app> by name, so it skips a mismatched folder and never reports its app as a missing dependency.
  • Trusting a local pass on a timing-sensitive test. The timeout is 120s locally but 30s in CI, so a spec that waits on a slow page can pass on your machine and time out in the pipeline.
  • Giving two webServer entries the same port. Each app needs a port of its own. If two entries share one, Playwright either reuses the first app's server for the second entry, or in CI fails to start it at all.