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 console template redirects to /console/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 console.
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 it with 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, testUser }, use) => {
await signIn(page, testUser, { onboarded: true });
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​