← Back to Skills

QA Automation

product

Write effective automated tests including E2E, visual regression, accessibility, and performance testing.

QA Automation

Guidelines for building comprehensive automated testing that catches bugs before production.

When to Activate

E2E Testing with Playwright

Page Object Model

// pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

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

  async login(email: string, password: string) {
    await this.page.fill('[data-testid="email"]', email);
    await this.page.fill('[data-testid="password"]', password);
    await this.page.click('[data-testid="submit"]');
  }

  async expectError(message: string) {
    await expect(this.page.locator('[data-testid="error"]'))
      .toContainText(message);
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test.describe('Login', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('user@example.com', 'password123');
    
    await expect(page).toHaveURL('/dashboard');
  });

  test('invalid credentials show error', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('wrong@email.com', 'wrong');
    
    await loginPage.expectError('Invalid credentials');
  });
});

Use data-testid for selectors

// GOOD - stable selectors
<button data-testid="submit-order">Place Order</button>
<input data-testid="email-input" type="email" />
<div data-testid="order-confirmation">Order #123</div>

// Test
await page.click('[data-testid="submit-order"]');
await expect(page.locator('[data-testid="order-confirmation"]'))
  .toBeVisible();

// BAD - fragile selectors
await page.click('.btn.btn-primary.submit');
await page.click('button:has-text("Place Order")');

Test realistic user flows

test('complete checkout flow', async ({ page }) => {
  // Start from real entry point
  await page.goto('/');
  
  // Add product to cart
  await page.click('[data-testid="product-card"]:first-child');
  await page.click('[data-testid="add-to-cart"]');
  
  // Go to checkout
  await page.click('[data-testid="cart-icon"]');
  await page.click('[data-testid="checkout-button"]');
  
  // Fill shipping
  await page.fill('[data-testid="address"]', '123 Main St');
  await page.fill('[data-testid="city"]', 'São Paulo');
  await page.click('[data-testid="continue-to-payment"]');
  
  // Fill payment
  await page.fill('[data-testid="card-number"]', '4242424242424242');
  await page.fill('[data-testid="expiry"]', '12/28');
  await page.fill('[data-testid="cvv"]', '123');
  
  // Complete order
  await page.click('[data-testid="place-order"]');
  
  // Verify success
  await expect(page.locator('[data-testid="confirmation"]'))
    .toContainText('Order confirmed');
});

Visual Regression Testing

Playwright visual comparisons

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

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');
  
  // Full page screenshot
  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    maxDiffPixels: 100, // Allow minor differences
  });
});

test('component visual states', async ({ page }) => {
  await page.goto('/storybook/button');
  
  // Default state
  await expect(page.locator('[data-testid="button"]'))
    .toHaveScreenshot('button-default.png');
  
  // Hover state
  await page.hover('[data-testid="button"]');
  await expect(page.locator('[data-testid="button"]'))
    .toHaveScreenshot('button-hover.png');
  
  // Disabled state
  await page.goto('/storybook/button?disabled=true');
  await expect(page.locator('[data-testid="button"]'))
    .toHaveScreenshot('button-disabled.png');
});

Configure for CI

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01, // 1% difference allowed
    },
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'mobile',
      use: { ...devices['iPhone 13'] },
    },
  ],
});

Accessibility Testing

Automated a11y checks with axe-core

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

test.describe('Accessibility', () => {
  test('homepage has no a11y violations', async ({ page }) => {
    await page.goto('/');
    
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();
    
    expect(results.violations).toEqual([]);
  });

  test('form has proper labels', async ({ page }) => {
    await page.goto('/signup');
    
    const results = await new AxeBuilder({ page })
      .include('form')
      .analyze();
    
    expect(results.violations).toEqual([]);
  });
});

Test keyboard navigation

test('can complete form with keyboard only', async ({ page }) => {
  await page.goto('/contact');
  
  // Tab through form
  await page.keyboard.press('Tab');
  await expect(page.locator('[data-testid="name"]')).toBeFocused();
  
  await page.keyboard.type('John Doe');
  await page.keyboard.press('Tab');
  await expect(page.locator('[data-testid="email"]')).toBeFocused();
  
  await page.keyboard.type('john@example.com');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab'); // Skip to submit
  await page.keyboard.press('Enter');
  
  await expect(page.locator('[data-testid="success"]')).toBeVisible();
});

Screen reader testing

test('important content has ARIA labels', async ({ page }) => {
  await page.goto('/dashboard');
  
  // Check navigation has label
  const nav = page.locator('nav');
  await expect(nav).toHaveAttribute('aria-label', 'Main navigation');
  
  // Check images have alt text
  const images = page.locator('img');
  for (const img of await images.all()) {
    const alt = await img.getAttribute('alt');
    expect(alt).toBeTruthy();
  }
  
  // Check buttons have accessible names
  const buttons = page.locator('button');
  for (const button of await buttons.all()) {
    const name = await button.getAttribute('aria-label') 
      || await button.textContent();
    expect(name?.trim()).toBeTruthy();
  }
});

Performance Testing

Lighthouse in CI

import { test } from '@playwright/test';
import { playAudit } from 'playwright-lighthouse';

test('homepage performance meets budget', async ({ page }) => {
  await page.goto('/');
  
  const results = await playAudit({
    page,
    thresholds: {
      performance: 80,
      accessibility: 90,
      'best-practices': 80,
      seo: 90,
    },
    port: 9222,
  });
  
  expect(results.lhr.categories.performance.score * 100)
    .toBeGreaterThan(80);
});

Custom performance metrics

test('page loads within budget', async ({ page }) => {
  await page.goto('/');
  
  // Measure Core Web Vitals
  const metrics = await page.evaluate(() => ({
    lcp: performance.getEntriesByType('largest-contentful-paint')[0]?.startTime,
    fid: performance.getEntriesByType('first-input')[0]?.processingStart,
    cls: performance.getEntriesByType('layout-shift')
      .reduce((sum, e) => sum + e.value, 0),
  }));
  
  expect(metrics.lcp).toBeLessThan(2500); // 2.5s
  expect(metrics.cls).toBeLessThan(0.1);
});

Test Organization

Follow AAA pattern

test('user can update profile', async ({ page }) => {
  // Arrange - setup preconditions
  await loginAsUser(page, testUser);
  await page.goto('/settings/profile');
  
  // Act - perform the action
  await page.fill('[data-testid="display-name"]', 'New Name');
  await page.click('[data-testid="save-profile"]');
  
  // Assert - verify outcomes
  await expect(page.locator('[data-testid="success-toast"]'))
    .toContainText('Profile updated');
  await expect(page.locator('[data-testid="display-name"]'))
    .toHaveValue('New Name');
});

Use fixtures for common setup

// fixtures.ts
import { test as base } from '@playwright/test';

type Fixtures = {
  loggedInPage: Page;
  adminPage: Page;
};

export const test = base.extend<Fixtures>({
  loggedInPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@test.com');
    await page.fill('[data-testid="password"]', 'password');
    await page.click('[data-testid="submit"]');
    await page.waitForURL('/dashboard');
    await use(page);
  },
  
  adminPage: async ({ page }, use) => {
    await page.goto('/admin/login');
    await page.fill('[data-testid="email"]', 'admin@test.com');
    await page.fill('[data-testid="password"]', 'adminpass');
    await page.click('[data-testid="submit"]');
    await use(page);
  },
});

// Usage
test('user sees their orders', async ({ loggedInPage }) => {
  await loggedInPage.goto('/orders');
  await expect(loggedInPage.locator('[data-testid="orders-list"]'))
    .toBeVisible();
});

CI Integration

Playwright in GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    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
        
      - name: Run E2E tests
        run: npx playwright test
        
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/