Skip to main content

End-to-End (E2E) Testing Guide

Substrate 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

Use the authenticatedPage fixture for tests that require authentication:

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

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

Custom Mock User

Customize the mock user for specific test scenarios:

test.use({
mockUser: {
id: "custom-id",
name: "Custom User",
},
});

test("test with custom user", async ({ authenticatedPage }) => {
// Test runs with custom user data
});

Test Organization

File Naming

  • Test files: *.spec.ts
  • Fixtures: *-fixtures.ts
  • Mocks: *-mock.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. Mock External Services: Use mocks for OAuth, APIs, and external dependencies
  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 setupAuthenticatedUser(page, mockUser);
await use(page);
},
});

Mocking

OAuth Mocking

The OAuth mock bypasses real authentication:

// mocks/auth-mock.ts
export async function mockAuthSession(page: Page, user: MockUser) {
await page.addInitScript((mockUser) => {
window.fetch = async function (...args) {
const [url] = args;
if (typeof url === "string" && url.includes("/api/auth/session")) {
return new Response(
JSON.stringify({
user: mockUser,
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
return originalFetch.apply(this, args);
};
}, user);
}

API Mocking

Mock GraphQL and REST API calls:

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

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

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