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
- 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
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:
- 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