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 goes through the development sign-in seam rather than AuthKit's hosted page,
so the suite needs no WorkOS tenant, no secrets, and no network. The Playwright config sets
AUTH_DEV_LOGIN_ENABLED=true; the route is refused outright when ENV=production.
Everything past sign-in — the proxy, onboarding, resolvers, permissions — is the production code path. Only the credential differs.
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, roles, or multiple users, call the helper yourself:
import { signInWithDevLogin, uniqueMockUser } from "../mocks/auth-mock";
// A fresh user who has not onboarded yet.
await signInWithDevLogin(page, uniqueMockUser("my-test"));
// A member-role session, useful for asserting a permission is refused.
await signInWithDevLogin(page, uniqueMockUser("my-test-member"), {
role: "member",
onboarded: true,
});
uniqueMockUser(slug) derives an address from the slug and RUN_ID, so parallel tests and
repeated runs stay isolated. Give each one a distinct slug.
Test Organization
File Naming
- Test files:
*.spec.ts - Fixtures:
*-fixtures.ts - Mocks:
*-mock.ts
Best Practices
- Test User Flows: Focus on complete user journeys, not individual components
- Use Semantic Selectors: Prefer role-based and accessible selectors
- Keep Tests Independent: Each test should be self-contained
- Mock External Services: Use mocks for OAuth, APIs, and external dependencies
- Wait for State: Use Playwright's auto-waiting features
- Clean Test Data: Ensure tests don't leave persistent state
Selector Priority
getByRole()- Best for accessibilitygetByLabel()- Good for form fieldsgetByPlaceholder()- Alternative for inputsgetByText()- For visible text contentgetByTestId()- 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
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:
- Install Playwright browsers
- Build the application
- Run tests in headless mode
- 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_URLis correct
Element Not Found
- Use
waitForSelectoror 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