Testing - Browser/E2E
End-to-end tests using Playwright to verify complete user flows in real browsers.
Quick Reference
Run all:
npx playwright testUI mode:
npx playwright test --uiDebug:
npx playwright test --debugReport:
npx playwright show-reportCore Principles
✓ Do
- Test critical user journeys
- Use role-based selectors (getByRole)
- Wait for network idle before assertions
- Test across multiple viewports
- Isolate tests - each should be independent
✗ Don't
- Test every edge case in E2E
- Use fragile CSS selectors
- Rely on fixed timeouts
- Share state between tests
- Test implementation details
Playwright Selectors
Playwright provides powerful built-in locators. Use them in this order:
| Priority | Locator | Example |
|---|---|---|
| 1st | getByRole | page.getByRole('button', { name: /submit/i }) |
| 2nd | getByLabel | page.getByLabel('Email') |
| 3rd | getByPlaceholder | page.getByPlaceholder('Search...') |
| 4th | getByText | page.getByText('Welcome back') |
| Last | locator | page.locator('[data-testid="submit"]') |
Example: StandaloneDashboard E2E Tests
Complete Playwright test file demonstrating E2E testing best practices.
tests/e2e/standalone-dashboard.spec.tsView on GitHub →
import { test, expect } from '@playwright/test';
test.describe('Standalone Dashboard', () => {
// ===========================================================================
// SETUP
// ===========================================================================
test.beforeEach(async ({ page }) => {
await page.goto('/examples/standalone-dashboard');
await page.waitForLoadState('networkidle');
});
// ===========================================================================
// PAGE LOAD TESTS
// ===========================================================================
test.describe('page loading', () => {
test('should load the dashboard page', async ({ page }) => {
await expect(page).toHaveTitle(/standalone dashboard/i);
});
test('should display the dashboard header', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /research dashboard/i })
).toBeVisible();
});
test('should display all stats cards', async ({ page }) => {
await expect(page.getByText('Active Experiments')).toBeVisible();
await expect(page.getByText('Compounds in Pipeline')).toBeVisible();
await expect(page.getByText('Assays Running')).toBeVisible();
});
});
// ===========================================================================
// TASK QUEUE TESTS
// ===========================================================================
test.describe('task queue', () => {
test('should display task queue sections', async ({ page }) => {
await expect(page.getByText('Task Queue')).toBeVisible();
await expect(page.getByText('Requires Your Action')).toBeVisible();
await expect(page.getByText('In Progress')).toBeVisible();
await expect(page.getByText('Blocked')).toBeVisible();
});
test('should show tasks requiring action with buttons', async ({ page }) => {
await expect(page.getByText('Review HTS screening results')).toBeVisible();
await expect(
page.getByRole('button', { name: /review results/i })
).toBeVisible();
});
test('should show blocked tasks with reasons', async ({ page }) => {
await expect(
page.getByText(/waiting for structural analysis/i)
).toBeVisible();
});
});
// ===========================================================================
// INTERACTION TESTS
// ===========================================================================
test.describe('interactions', () => {
test('refresh button should be clickable', async ({ page }) => {
const refreshBtn = page.getByRole('button', { name: /refresh/i });
await refreshBtn.click();
await expect(refreshBtn).toBeVisible();
});
test('quick action buttons should be enabled', async ({ page }) => {
const newExperimentBtn = page.getByRole('button', {
name: /new experiment/i
});
await expect(newExperimentBtn).toBeEnabled();
});
});
// ===========================================================================
// RESPONSIVE TESTS
// ===========================================================================
test.describe('responsive design', () => {
test('should display correctly on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/examples/standalone-dashboard');
await page.waitForLoadState('networkidle');
await expect(
page.getByRole('heading', { name: /research dashboard/i })
).toBeVisible();
});
});
// ===========================================================================
// ACCESSIBILITY TESTS
// ===========================================================================
test.describe('accessibility', () => {
test('should have proper heading structure', async ({ page }) => {
const h1 = page.getByRole('heading', { level: 1 });
await expect(h1).toBeVisible();
await expect(h1).toHaveText(/research dashboard/i);
});
test('should be keyboard navigable', async ({ page }) => {
await page.keyboard.press('Tab');
const focusedElement = page.locator(':focus');
await expect(focusedElement).toBeVisible();
});
});
});Common Test Patterns
Pattern: Page Navigation
test('navigates to detail page on row click', async ({ page }) => {
await page.goto('/experiments');
await page.waitForLoadState('networkidle');
// Click on a table row
await page.getByText('Experiment A').click();
// Verify navigation
await expect(page).toHaveURL(/\/experiments\/\d+/);
await expect(page.getByRole('heading', { name: 'Experiment A' })).toBeVisible();
});Pattern: Form Submission
test('submits login form successfully', async ({ page }) => {
await page.goto('/login');
// Fill form
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
// Submit
await page.getByRole('button', { name: /sign in/i }).click();
// Verify redirect
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText(/welcome/i)).toBeVisible();
});Pattern: API Mocking
test('displays data from API', async ({ page }) => {
// Mock API response
await page.route('/api/experiments', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mock Experiment' }
]),
});
});
await page.goto('/experiments');
await expect(page.getByText('Mock Experiment')).toBeVisible();
});
test('handles API errors gracefully', async ({ page }) => {
await page.route('/api/experiments', async (route) => {
await route.fulfill({ status: 500 });
});
await page.goto('/experiments');
await expect(page.getByText(/error loading/i)).toBeVisible();
});Pattern: Visual Regression
test('matches visual snapshot', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Full page screenshot
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true,
});
});
test('matches component snapshot', async ({ page }) => {
await page.goto('/dashboard');
const statsCard = page.locator('[data-testid="stats-card"]').first();
await expect(statsCard).toHaveScreenshot('stats-card.png');
});
test('matches dark mode snapshot', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard-dark.png');
});Pattern: Waiting Strategies
// Wait for network idle (recommended for page loads)
await page.waitForLoadState('networkidle');
// Wait for specific element
await page.waitForSelector('[data-loaded="true"]');
// Wait for response
await page.waitForResponse('/api/data');
// Wait for navigation
await Promise.all([
page.waitForNavigation(),
page.click('a[href="/next-page"]'),
]);
// Auto-waiting (built into expect)
await expect(page.getByText('Loaded')).toBeVisible(); // waits automaticallyPlaywright Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4321',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:4321',
reuseExistingServer: !process.env.CI,
},
});CI/CD Integration
GitHub Actions Workflow
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
- name: Upload report
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7Debugging Tips
Debug Mode
# Run with debugger
npx playwright test --debug
# Run specific test with debugger
npx playwright test dashboard.spec.ts --debug
# Pause execution in test
await page.pause();Trace Viewer
# Record trace
npx playwright test --trace on
# View trace
npx playwright show-trace trace.zip
# Trace on failure only (recommended)
use: { trace: 'on-first-retry' }