docs/testing/e2e-testing.md
E2E Testing
End-to-end testing with Playwright.
Configuration
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'on-failure' }]],
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'real-integration',
use: { ...devices['Desktop Chrome'] },
testMatch: /api-integration/,
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Test Structure
e2e/
├── api-integration.spec.ts # API endpoint tests
├── site-flows.spec.ts # Page navigation tests
├── engagement.spec.ts # User interaction tests
└── fixtures/ # Test utilities
Writing Tests
Page Navigation
import { test, expect } from '@playwright/test';
test('home page loads', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Portfolio/);
});
test('navigate to projects', async ({ page }) => {
await page.goto('/');
await page.click('text=Projects');
await expect(page).toHaveURL('/projects');
});
API Testing
import { test, expect } from '@playwright/test';
test('blog posts API returns data', async ({ request }) => {
const response = await request.get('/api/posts');
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.posts).toBeInstanceOf(Array);
});
Form Interactions
test('contact form submission', async ({ page }) => {
await page.goto('/contact');
await page.fill('input[name="name"]', 'Test User');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('textarea[name="message"]', 'Test message');
await page.click('button[type="submit"]');
await expect(page.locator('.success-message')).toBeVisible();
});
Test Fixtures
Using Mock Data
import { test } from '@playwright/test';
import { BLOG_TEST_FIXTURES } from '@portfolio/test-support/fixtures';
test('displays mock blog posts', async ({ page }) => {
// Fixtures enabled via environment variable
await page.goto('/blog');
const firstPost = BLOG_TEST_FIXTURES.posts[0];
await expect(page.locator('h2')).toContainText(firstPost.title);
});
Custom Fixtures
// e2e/fixtures/auth.ts
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Set up authentication
await page.goto('/api/auth/signin');
// ... authentication logic
await use(page);
},
});
Running Tests
Basic Commands
# Run all tests
pnpm test
# Run specific file
pnpm test e2e/site-flows.spec.ts
# Run tests matching pattern
pnpm test -g "blog"
Visual Mode
# Open Playwright UI
pnpm test:ui
Headed Mode
# Show browser during tests
pnpm test:headed
Debug Mode
# Step through tests
pnpm test:debug
Test Modes
Fixture Mode (Default)
# Uses mock data
BLOG_TEST_FIXTURES=true PORTFOLIO_TEST_FIXTURES=true pnpm test
Real API Mode
# Against deployed app
PLAYWRIGHT_SKIP_WEBSERVER=true E2E_USE_REAL_APIS=true pnpm test:real-api
Hybrid Mode
# Local server with real APIs
E2E_USE_REAL_APIS=true pnpm test:real-api:dev
Environment Variables
| Variable | Description |
|----------|-------------|
| PLAYWRIGHT_SKIP_WEBSERVER | Don't start dev server |
| PLAYWRIGHT_TEST_BASE_URL | Override base URL |
| E2E_USE_REAL_APIS | Use real vs mock APIs |
| E2E_API_BASE_URL | Target API URL |
| BLOG_TEST_FIXTURES | Enable blog fixtures |
| PORTFOLIO_TEST_FIXTURES | Enable portfolio fixtures |
CI Configuration
GitHub Actions
- name: Run E2E Tests
env:
CI: true
BLOG_TEST_FIXTURES: 'true'
PORTFOLIO_TEST_FIXTURES: 'true'
run: pnpm test
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Artifacts
Failed tests generate:
- Screenshots
- Videos
- Traces
View with:
pnpm test:report
Best Practices
Test Isolation
Each test should be independent:
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('test 1', async ({ page }) => {
// Independent test
});
test('test 2', async ({ page }) => {
// Independent test
});
Reliable Selectors
Prefer data attributes:
// Good
await page.click('[data-testid="submit-button"]');
// Avoid
await page.click('.btn-primary');
Wait for Conditions
// Wait for element
await expect(page.locator('.loading')).toBeHidden();
// Wait for network
await page.waitForResponse('/api/data');
Error Handling
test('handles API errors', async ({ page }) => {
// Mock error response
await page.route('/api/data', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' }),
});
});
await page.goto('/');
await expect(page.locator('.error-message')).toBeVisible();
});
Debugging
Traces
Enable traces for debugging:
use: {
trace: 'on', // Always capture
}
View traces:
npx playwright show-trace trace.zip
Screenshots
test('visual test', async ({ page }) => {
await page.goto('/');
await page.screenshot({ path: 'screenshot.png' });
});
Console Logs
page.on('console', (msg) => {
console.log('Browser log:', msg.text());
});
Related Documentation
- Testing Overview - Testing strategy
- Chat Evals - Chat testing
- CI/CD - Automation
