npx claudepluginhub jsamuelsen11/claude-config --plugin ccfg-coreWant just this skill?
Then install: npx claudepluginhub u/[userId]/[slug]
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.
This skill uses the workspace's default tool permissions.
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.
Similar Skills
You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.