Skill
Community

browser-testing

Install
1
Install the plugin
$
npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-core

Want just this skill?

Then install: npx claudepluginhub u/[userId]/[slug]

Description

This skill should be used when testing web UIs, browser automation, end-to-end testing with Playwright or Puppeteer, Chrome DevTools debugging, or visual regression testing.

Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

Browser Testing

This skill covers best practices for browser-based testing, including end-to-end testing with Playwright/Puppeteer, visual regression testing, accessibility testing, and Chrome DevTools automation.

Playwright Test Patterns

Test Structure

Playwright tests should follow a clear arrange-act-assert pattern:

import { test, expect } from '@playwright/test';

test('user can log in with valid credentials', async ({ page }) => {
  // Arrange: Navigate to login page
  await page.goto('/login');

  // Act: Fill in form and submit
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Log in' }).click();

  // Assert: Verify redirect and welcome message
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome back')).toBeVisible();
});

Test naming: Use descriptive names that read like specifications:

  • Good: 'user can log in with valid credentials'
  • Good: 'displays error message when email is invalid'
  • Bad: 'test login'
  • Bad: 'testValidation'

Test isolation: Each test should be independent and not rely on state from other tests:

test.beforeEach(async ({ page }) => {
  // Reset to known state before each test
  await page.goto('/');
  await resetDatabase(); // If needed
  await clearLocalStorage(); // If needed
});

Element Selection

Prefer semantic selectors over CSS selectors. Playwright provides locators that are more resilient to UI changes:

// Best: Role-based selectors (most resilient)
await page.getByRole('button', { name: 'Submit' });
await page.getByRole('textbox', { name: 'Email' });
await page.getByRole('heading', { name: 'Welcome' });

// Good: Label text (semantic and user-focused)
await page.getByLabel('Email address');
await page.getByLabel('Password');

// Good: Test IDs (explicit test hooks)
await page.getByTestId('submit-button');
await page.getByTestId('error-message');

// Avoid: CSS selectors (brittle, implementation-coupled)
await page.locator('.btn-primary');
await page.locator('#submit-btn');
await page.locator('div > form > button:nth-child(3)');

Use data-testid for elements without semantic roles:

<!-- Good: semantic HTML doesn't need test ID -->
<button type="submit">Submit</button>

<!-- Good: test ID for non-semantic elements -->
<div data-testid="user-profile-card">
  <span data-testid="username">John Doe</span>
</div>

Chain locators to narrow scope:

// Find button within a specific card
const userCard = page.getByTestId('user-card-123');
await userCard.getByRole('button', { name: 'Delete' }).click();

// Find input within a specific form
const loginForm = page.getByRole('form', { name: 'Login' });
await loginForm.getByLabel('Email').fill('user@example.com');

Waiting Strategies

Playwright auto-waits by default. Most actions automatically wait for elements to be ready:

// Automatically waits for button to be visible and enabled
await page.getByRole('button', { name: 'Submit' }).click();

// Automatically waits for input to be editable
await page.getByLabel('Email').fill('user@example.com');

Explicit waits for specific conditions:

// Wait for element to appear
await page.waitForSelector('[data-testid="success-message"]');

// Wait for navigation
await page.waitForURL('/dashboard');

// Wait for network request
await page.waitForResponse(
  (response) => response.url().includes('/api/users') && response.status() === 200
);

// Wait for element state
await page.getByTestId('spinner').waitFor({ state: 'hidden' });

Wait for multiple conditions:

// Wait for all promises to resolve
await Promise.all([
  page.waitForResponse('/api/user'),
  page.waitForResponse('/api/settings'),
  page.getByRole('button', { name: 'Submit' }).click(),
]);

Assertion Patterns

Use Playwright's built-in assertions which automatically retry until the condition is met or timeout:

// Visibility assertions
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByText('Loading')).toBeHidden();

// Text content assertions
await expect(page.getByRole('heading')).toHaveText('Welcome');
await expect(page.getByTestId('error')).toContainText('Invalid email');

// Attribute assertions
await expect(page.getByRole('button')).toBeDisabled();
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');

// URL assertions
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/users\/\d+/);

// Count assertions
await expect(page.getByRole('listitem')).toHaveCount(5);

Soft assertions for non-blocking checks:

test('dashboard displays all widgets', async ({ page }) => {
  await page.goto('/dashboard');

  // Continue test even if these fail
  await expect.soft(page.getByTestId('widget-sales')).toBeVisible();
  await expect.soft(page.getByTestId('widget-traffic')).toBeVisible();
  await expect.soft(page.getByTestId('widget-revenue')).toBeVisible();

  // Critical assertion (will stop test if fails)
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Page Object Model

Create page objects for reusable interactions. Page objects encapsulate page-specific knowledge and make tests more maintainable:

// pages/login.page.ts
export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Log in' }).click();
  }

  async getErrorMessage() {
    return this.page.getByTestId('error-message').textContent();
  }

  async isLoggedIn() {
    return this.page.getByText('Welcome back').isVisible();
  }
}

// Using the page object
test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');
  expect(await loginPage.isLoggedIn()).toBe(true);
});

Separate selectors from test logic:

export class DashboardPage {
  // Centralize selectors
  private selectors = {
    heading: () => this.page.getByRole('heading', { name: 'Dashboard' }),
    salesWidget: () => this.page.getByTestId('widget-sales'),
    addButton: () => this.page.getByRole('button', { name: 'Add Widget' }),
  };

  constructor(private page: Page) {}

  async clickAddWidget() {
    await this.selectors.addButton().click();
  }

  async getSalesValue() {
    return this.selectors.salesWidget().textContent();
  }
}

Compose page objects for complex flows:

test('complete checkout flow', async ({ page }) => {
  const cartPage = new CartPage(page);
  const checkoutPage = new CheckoutPage(page);
  const confirmationPage = new ConfirmationPage(page);

  await cartPage.goto();
  await cartPage.proceedToCheckout();

  await checkoutPage.fillShippingInfo({
    name: 'John Doe',
    address: '123 Main St',
  });
  await checkoutPage.fillPaymentInfo({
    cardNumber: '4111111111111111',
    expiry: '12/25',
    cvv: '123',
  });
  await checkoutPage.submitOrder();

  await expect(confirmationPage.orderNumber()).toBeVisible();
});

Screenshot and Visual Regression

Capture Screenshots at Key States

test('login page renders correctly', async ({ page }) => {
  await page.goto('/login');

  // Capture full page screenshot
  await page.screenshot({ path: 'screenshots/login-page.png', fullPage: true });

  // Capture specific element
  await page.getByTestId('login-form').screenshot({
    path: 'screenshots/login-form.png',
  });
});

Compare with Baselines

import { test, expect } from '@playwright/test';

test('dashboard matches baseline', async ({ page }) => {
  await page.goto('/dashboard');

  // Visual comparison (fails if pixels differ beyond threshold)
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixels: 100, // Allow up to 100 pixels difference
  });
});

Handle Dynamic Content

Mask elements that change frequently:

test('article page visual test', async ({ page }) => {
  await page.goto('/article/123');

  await expect(page).toHaveScreenshot({
    // Mask elements with dynamic content
    mask: [
      page.getByTestId('published-date'),
      page.getByTestId('view-count'),
      page.getByTestId('advertisement'),
    ],
  });
});

Hide animations before capturing:

test('modal visual test', async ({ page }) => {
  await page.goto('/');

  // Disable animations to prevent flaky visual tests
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        transition-duration: 0s !important;
      }
    `,
  });

  await page.getByRole('button', { name: 'Open Modal' }).click();
  await expect(page.getByRole('dialog')).toHaveScreenshot('modal.png');
});

Threshold Configuration

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 100, // Global threshold
      threshold: 0.2, // 20% difference allowed
    },
  },
});

// Override per test
test('hero section visual', async ({ page }) => {
  await page.goto('/');

  await expect(page.getByTestId('hero')).toHaveScreenshot('hero.png', {
    maxDiffPixelRatio: 0.05, // Allow 5% difference for this specific test
  });
});

Chrome DevTools Protocol

Network Request Interception

Capture network requests:

test('tracks API calls', async ({ page }) => {
  const requests: string[] = [];

  page.on('request', (request) => {
    if (request.url().includes('/api/')) {
      requests.push(request.url());
    }
  });

  await page.goto('/dashboard');

  expect(requests).toContain('https://api.example.com/api/users');
  expect(requests).toContain('https://api.example.com/api/settings');
});

Verify request headers:

test('sends authentication header', async ({ page }) => {
  page.on('request', (request) => {
    if (request.url().includes('/api/protected')) {
      expect(request.headers()['authorization']).toBe('Bearer token123');
    }
  });

  await page.goto('/protected-page');
});

Performance Profiling

Measure page load performance:

test('page loads within performance budget', async ({ page }) => {
  const startTime = Date.now();
  await page.goto('/');
  const loadTime = Date.now() - startTime;

  expect(loadTime).toBeLessThan(3000); // 3 second budget
});

Collect performance metrics:

test('performance metrics', async ({ page }) => {
  await page.goto('/');

  const metrics = await page.evaluate(() => {
    const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
    return {
      domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
      loadComplete: navigation.loadEventEnd - navigation.fetchStart,
      firstPaint: performance.getEntriesByType('paint')[0]?.startTime,
    };
  });

  expect(metrics.domContentLoaded).toBeLessThan(2000);
  expect(metrics.loadComplete).toBeLessThan(5000);
});

Console Log Capture

Capture console errors:

test('page has no console errors', async ({ page }) => {
  const errors: string[] = [];

  page.on('console', (msg) => {
    if (msg.type() === 'error') {
      errors.push(msg.text());
    }
  });

  await page.goto('/');

  expect(errors).toHaveLength(0);
});

Coverage Collection

Collect code coverage:

test('collect coverage', async ({ page }) => {
  await page.coverage.startJSCoverage();
  await page.goto('/');
  await page.getByRole('button', { name: 'Submit' }).click();
  const coverage = await page.coverage.stopJSCoverage();

  let totalBytes = 0;
  let usedBytes = 0;

  for (const entry of coverage) {
    totalBytes += entry.text.length;
    for (const range of entry.ranges) {
      usedBytes += range.end - range.start - 1;
    }
  }

  const coveragePercentage = (usedBytes / totalBytes) * 100;
  console.log(`Code coverage: ${coveragePercentage.toFixed(2)}%`);
});

Network Mocking

Intercept and Mock API Responses

test('displays user profile from mocked API', async ({ page }) => {
  // Intercept API call and return mock data
  await page.route('/api/user/profile', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: 1,
        name: 'Test User',
        email: 'test@example.com',
      }),
    });
  });

  await page.goto('/profile');
  await expect(page.getByText('Test User')).toBeVisible();
});

Simulate Errors and Slow Networks

test('handles API error gracefully', async ({ page }) => {
  // Simulate 500 error
  await page.route('/api/data', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/dashboard');
  await expect(page.getByText('Failed to load data')).toBeVisible();
});

test('handles slow network', async ({ page }) => {
  // Simulate slow response
  await page.route('/api/data', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 5000)); // 5s delay
    route.fulfill({
      status: 200,
      body: JSON.stringify({ data: 'slow response' }),
    });
  });

  await page.goto('/dashboard');
  // Verify loading state appears
  await expect(page.getByTestId('loading-spinner')).toBeVisible();
});

Record and Replay Network Traffic

// Record mode: capture real API responses
test('record API responses', async ({ page }) => {
  const responses = new Map();

  page.on('response', async (response) => {
    if (response.url().includes('/api/')) {
      responses.set(response.url(), await response.json());
    }
  });

  await page.goto('/dashboard');

  // Save responses to file
  fs.writeFileSync('fixtures/dashboard-api.json', JSON.stringify(Array.from(responses.entries())));
});

// Replay mode: use captured responses
test('replay API responses', async ({ page }) => {
  const fixtures = JSON.parse(fs.readFileSync('fixtures/dashboard-api.json', 'utf-8'));

  await page.route('/api/**', (route) => {
    const response = fixtures.find(([url]) => route.request().url().includes(url));
    if (response) {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify(response[1]),
      });
    } else {
      route.continue();
    }
  });

  await page.goto('/dashboard');
});

Accessibility Testing

Automated a11y Checks with axe-core

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('homepage has no accessibility violations', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page }).analyze();

  expect(results.violations).toEqual([]);
});

test('check specific component for a11y', async ({ page }) => {
  await page.goto('/login');

  const results = await new AxeBuilder({ page })
    .include('#login-form') // Only check specific element
    .exclude('.advertisement') // Exclude third-party content
    .analyze();

  expect(results.violations).toEqual([]);
});

Keyboard Navigation Testing

test('form is keyboard accessible', async ({ page }) => {
  await page.goto('/contact');

  // Tab through form fields
  await page.keyboard.press('Tab'); // Focus on first field
  await page.keyboard.type('John Doe');

  await page.keyboard.press('Tab'); // Next field
  await page.keyboard.type('john@example.com');

  await page.keyboard.press('Tab'); // Submit button
  await page.keyboard.press('Enter'); // Submit

  await expect(page.getByText('Thank you')).toBeVisible();
});

test('modal can be closed with Escape', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Open Modal' }).click();

  await expect(page.getByRole('dialog')).toBeVisible();

  await page.keyboard.press('Escape');

  await expect(page.getByRole('dialog')).toBeHidden();
});

Screen Reader Compatibility

test('buttons have accessible labels', async ({ page }) => {
  await page.goto('/');

  // Check for aria-label or visible text
  const deleteButton = page.getByRole('button', { name: 'Delete item' });
  await expect(deleteButton).toHaveAttribute('aria-label', 'Delete item');

  // Icons should have accessible names
  const closeButton = page.getByRole('button', { name: 'Close' });
  await expect(closeButton).toBeVisible();
});

test('form inputs have labels', async ({ page }) => {
  await page.goto('/signup');

  // All inputs should be associated with labels
  await expect(page.getByLabel('Email address')).toBeVisible();
  await expect(page.getByLabel('Password')).toBeVisible();
  await expect(page.getByLabel('Confirm password')).toBeVisible();
});

WCAG Compliance Validation

test('meets WCAG 2.1 Level AA', async ({ page }) => {
  await page.goto('/');

  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) // WCAG 2.1 Level AA
    .analyze();

  expect(results.violations).toEqual([]);
});

Mobile Viewport Testing

Configure Mobile Viewports

import { test, devices } from '@playwright/test';

test('mobile viewport', async ({ page }) => {
  // Use predefined device
  await page.setViewportSize(devices['iPhone 13'].viewport);

  await page.goto('/');
  await expect(page.getByTestId('mobile-menu')).toBeVisible();
});

test('custom viewport', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 });

  await page.goto('/');
});

Test Responsive Breakpoints

const viewports = [
  { name: 'mobile', width: 375, height: 667 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1920, height: 1080 },
];

for (const viewport of viewports) {
  test(`layout works on ${viewport.name}`, async ({ page }) => {
    await page.setViewportSize(viewport);
    await page.goto('/');

    await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
  });
}

Touch Event Simulation

test('swipe gesture on mobile', async ({ page }) => {
  await page.setViewportSize(devices['iPhone 13'].viewport);
  await page.goto('/gallery');

  // Simulate swipe
  await page.touchscreen.tap(200, 300);
  await page.touchscreen.swipe({ x: 200, y: 300 }, { x: 50, y: 300 });

  // Verify next image is shown
  await expect(page.getByTestId('image-2')).toBeVisible();
});

Orientation Testing

test('supports landscape orientation', async ({ browser }) => {
  const context = await browser.newContext({
    ...devices['iPhone 13'],
    // Rotate to landscape
    viewport: { width: 844, height: 390 },
  });

  const page = await context.newPage();
  await page.goto('/');

  await expect(page.getByTestId('landscape-layout')).toBeVisible();
});

Best Practices

Isolate Tests (No Shared State)

// Bad: Tests depend on execution order
test('create user', async ({ page }) => {
  await createUser('test@example.com');
});

test('login as user', async ({ page }) => {
  await login('test@example.com'); // Fails if previous test didn't run
});

// Good: Each test is independent
test('create user', async ({ page }) => {
  await createUser('test1@example.com');
});

test('login as user', async ({ page }) => {
  await createUser('test2@example.com'); // Creates its own user
  await login('test2@example.com');
});

Use Fixtures for Setup/Teardown

// fixtures/authenticated-user.ts
export const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    // Setup: login before test
    await page.goto('/login');
    await page.getByLabel('Email').fill('test@example.com');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Log in' }).click();
    await page.waitForURL('/dashboard');

    // Use the authenticated page in test
    await use(page);

    // Teardown: cleanup after test
    await page.getByRole('button', { name: 'Logout' }).click();
  },
});

// Using the fixture
test('authenticated user can view profile', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/profile');
  await expect(authenticatedPage.getByText('My Profile')).toBeVisible();
});

Retry Flaky Assertions, Not Entire Tests

// Bad: Retry entire test
test('data loads', async ({ page }) => {
  await page.goto('/dashboard');
  await page.reload(); // Manual retry
  await expect(page.getByText('Dashboard')).toBeVisible();
});

// Good: Let Playwright's auto-wait handle it
test('data loads', async ({ page }) => {
  await page.goto('/dashboard');
  // Automatically retries until visible or timeout
  await expect(page.getByText('Dashboard')).toBeVisible({ timeout: 10000 });
});

Parallelize Test Suites

// playwright.config.ts
export default defineConfig({
  workers: 4, // Run 4 tests in parallel
  fullyParallel: true,
});

// Disable parallel for specific tests
test.describe.configure({ mode: 'serial' });

test('step 1', async ({ page }) => {
  // Runs first
});

test('step 2', async ({ page }) => {
  // Runs after step 1
});

Use Meaningful Test Names

// Bad
test('test 1', async ({ page }) => {});
test('login works', async ({ page }) => {});

// Good
test('user with valid credentials can log in successfully', async ({ page }) => {});
test('displays error message when password is incorrect', async ({ page }) => {});
test('redirects to two-factor auth when 2FA is enabled', async ({ page }) => {});

Summary

Browser testing is most effective when:

  • Tests are focused, isolated, and independent
  • Selectors are semantic and resilient to changes
  • Visual regression catches unintended UI changes
  • Accessibility is validated automatically
  • Network behavior is controlled and predictable
  • Tests run fast and in parallel
  • Failures are easy to diagnose and reproduce

Follow these patterns to build a reliable, maintainable browser test suite.

Stats
Stars0
Forks0
Last CommitFeb 9, 2026

Similar Skills