Initial Release help-frontend

Dr. Frontend

Testing - Browser/E2E

End-to-end tests using Playwright to verify complete user flows in real browsers.

Quick Reference

Run all:npx playwright test
UI mode:npx playwright test --ui
Debug:npx playwright test --debug
Report:npx playwright show-report

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

PriorityLocatorExample
1stgetByRolepage.getByRole('button', { name: /submit/i })
2ndgetByLabelpage.getByLabel('Email')
3rdgetByPlaceholderpage.getByPlaceholder('Search...')
4thgetByTextpage.getByText('Welcome back')
Lastlocatorpage.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 automatically

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

Debugging 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' }