Skip to main content

End-to-End (E2E) Testing Guide

u22a8 uses Playwright for end-to-end testing to ensure the application works correctly from a user's perspective.

Overview

E2E tests simulate real user interactions with the application in a browser environment. They validate complete workflows, including authentication, API calls, and UI updates.

Technology Stack

  • Test Framework: Playwright
  • Browser: Chromium (headless in CI, headed in development)
  • Test Runner: Nx integration
  • Mocking: Custom OAuth and API mocks

Test Location

E2E tests are located within each application's directory:

apps/web/
├── e2e/
│ ├── tests/ # Test files (*.spec.ts)
│ ├── fixtures/ # Reusable test fixtures
│ ├── mocks/ # Mock implementations
│ ├── playwright.config.ts
│ └── .gitignore

Running E2E Tests

The e2e test command automatically handles starting the required infrastructure (development server) before running tests. You don't need to manually start the server.

Run All E2E Tests

# Run e2e tests for web app
# This will automatically start the dev server, run tests, and stop the server
nx e2e web

# Run in CI mode (GitHub reporter)
nx e2e web --configuration=ci

Interactive UI Mode

For development and debugging, use UI mode:

nx e2e-ui web

This opens the Playwright UI where you can:

  • Run tests interactively
  • See test execution in real-time
  • Inspect test steps
  • Time travel through test execution

Debug Mode

Run tests with the Playwright Inspector:

nx e2e-debug web

This allows you to:

  • Step through tests line by line
  • Set breakpoints
  • Inspect page state
  • View console logs

Writing E2E Tests

Basic Test Structure

import { test, expect } from "../fixtures/test-fixtures";

test.describe("Feature Name", () => {
test("should perform action", async ({ page }) => {
await page.goto("/path");
await page.click('button[type="submit"]');
await expect(page.locator("text=Success")).toBeVisible();
});
});

Authenticated Tests

Authentication runs the real hosted flow against a local WorkOS emulator the Playwright config boots for each run (port 4101, seeded from a per-run copy of /workos-emulate.config.yaml) — no real tenant, no secrets, no external network. Sign-in drives the emulator's hosted page, and the callback, session bootstrap, and webhook sync are the production code path end to end.

Use the authenticatedPage fixture, which signs in a user unique to each test and marks them onboarded (otherwise the (app) template redirects to /onboarding):

import { test, expect } from "../fixtures/test-fixtures";
import en from "../../src/locales/en.json";

test("should access protected route", async ({ authenticatedPage }) => {
await authenticatedPage.goto("/profile");
await expect(authenticatedPage.locator("h1")).toContainText(en.web.profile.title);
});

Signing in directly

For tests that need to control onboarding state or multiple users, call the helper yourself:

import { signIn, uniqueUser } from "../helpers/auth";

// A fresh user who has not onboarded yet.
await signIn(page, uniqueUser("my-test"));

// An onboarded session that can reach the app shell.
await signIn(page, uniqueUser("my-test-onboarded"), { onboarded: true });

uniqueUser(slug) derives an address from the slug and RUN_ID, so parallel tests and repeated runs stay isolated. Give each one a distinct slug. Each signIn call starts a fresh session; the account is created at the emulator first, because the hosted page signs in existing users rather than registering new ones.

Roles come from memberships, not from sign-in options: to exercise a member-role session, invite the user into an organization through the API and accept the invitation via pendingInvitation/acceptInvitation from ../helpers/emulator — the membership then reaches the app through the same webhook path production uses.

Test Organization

File Naming

  • Test files: *.spec.ts
  • Fixtures: *-fixtures.ts
  • Helpers: helpers/*.ts

Best Practices

  1. Test User Flows: Focus on complete user journeys, not individual components
  2. Use Semantic Selectors: Prefer role-based and accessible selectors
  3. Keep Tests Independent: Each test should be self-contained
  4. Prefer Real Dependencies: the suite runs a real database and a real (emulated) identity provider; mock only what cannot run locally
  5. Wait for State: Use Playwright's auto-waiting features
  6. Clean Test Data: Ensure tests don't leave persistent state

Selector Priority

  1. getByRole() - Best for accessibility
  2. getByLabel() - Good for form fields
  3. getByPlaceholder() - Alternative for inputs
  4. getByText() - For visible text content
  5. getByTestId() - Last resort

Fixtures

Fixtures provide reusable test context:

// fixtures/test-fixtures.ts
export const test = base.extend<TestFixtures>({
authenticatedPage: async ({ page, mockUser }, use) => {
await signIn(page, mockUser, { onboarded: true, displayName: mockUser.name });
await use(page);
},
});

Mocking

The identity provider is not mocked — the emulator is a real one, running locally.

API Mocking

Rarely needed: the suite runs against real resolvers and a real (per-run, throwaway) SQLite database, which is what makes it worth having. Reach for this only to force a slow response or an error path you cannot produce otherwise.

await page.route('**/api/graphql', async (route) => {
const request = route.request();
const postData = request.postDataJSON();

if (postData?.query?.includes('viewer')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: { viewer: { ... } } }),
});
}
});

Configuration

Playwright Config

Each app's playwright.config.ts configures:

  • Test directory
  • Browser settings
  • Base URL
  • Timeouts
  • Reporters
  • Screenshot/video capture
  • Web Server: Automatically starts the dev server before tests and stops it after

The webServer configuration ensures that:

  • The development server is started before tests run
  • Tests wait for the server to be ready (health check on the URL)
  • The server is automatically stopped after tests complete
  • In local development, an already-running server can be reused
  • In CI, a fresh server is always started

Environment Variables

  • BASE_URL: Base URL for tests (default: http://localhost:3000)
  • CI: Enable CI mode (affects retries, parallelization, and reporting)

Debugging

View Test Results

After running tests, view the HTML report:

npx playwright show-report

Trace Viewer

Traces are automatically captured on first retry. View them with:

npx playwright show-trace trace.zip

Screenshots and Videos

Failed tests automatically capture screenshots and videos:

apps/web/test-results/e2e/
├── screenshots/
└── videos/

CI Integration

E2E tests run automatically in CI/CD pipelines:

  1. Install Playwright browsers
  2. Build the application
  3. Run tests in headless mode
  4. Upload test results and artifacts

Development Environment

Prerequisites

The devcontainer and GitHub Codespaces include all necessary dependencies:

  • Node.js 24
  • Playwright browsers (Chromium)
  • System dependencies for browser automation

Troubleshooting

Tests Timing Out

  • Increase timeout in test: test.setTimeout(60000)
  • Check if web server is starting correctly
  • Verify BASE_URL is correct

Element Not Found

  • Use waitForSelector or Playwright's auto-waiting
  • Check selectors with Playwright Inspector
  • Verify element exists in the page

Authentication Not Working

  • Check mock setup in fixtures/test-fixtures.ts
  • Verify cookie domain matches BASE_URL
  • Check browser console for errors in UI mode

Resources