Test Authoring Patterns & Automation
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/quality/references/test-authoring.md |
| Description | Not specified |
Source Content
Test Authoring Patterns & Automation
This reference covers test automation patterns, test architecture strategies, coverage targets by project type, CI/CD integration, and the full QA workflow — from strategy through to maintenance.
Testing Strategies & Risk-First Coverage
The Testing Pyramid
Follow the classic pyramid structure for optimal ROI:
- Unit tests (60–70%) — fast, pinpoint failures, run on every commit (Vitest)
- Integration tests (20–30%) — balance coverage and cost, run on every PR (React Testing Library + MSW)
- E2E tests (5–10%) — essential but expensive, run on staging (Playwright, framework-agnostic)
Coverage Targets by Project Type
| Project Type | Line Coverage | Focus Areas |
|---|---|---|
| Startup/MVP | 60%+ | Core business logic, auth, payments |
| Growing Product | 75%+ | Business-critical paths, error handling |
| Enterprise | 85%+ | Mission-critical flows, security, compliance |
| Safety Critical | 95%+ | Everything, with detailed audit trails |
Direct coverage at risk, not at arbitrary percentages:
- High coverage priority (80%+): Business logic, state management, API handlers, form validation, authentication/authorization, payment processing.
- Medium coverage priority (60–80%): UI components, utility functions, data transformers, custom hooks.
- Lower coverage priority (40–60%): Static pages, simple wrappers, configuration files, types/interfaces.
Testing Decision Framework
When deciding whether to write a test and which layer:
- Is it pure logic with no side effects? → Unit test with Vitest.
- Does it make API calls or use context? → Integration test with MSW mocking.
- Is it a critical user flow? → E2E test with Playwright.
- Is it a visual component with many states? → Storybook + Lost Pixel visual regression.
- Is it a simple wrapper or pass-through? → Skip it.
Test ROI Matrix
| Test Type | Write Time | Run Time | Maintenance | Confidence | |---|---|---|---|---|---| | Unit | Low | Very Fast | Low | Medium | | Integration | Medium | Fast | Medium | High | | E2E | High | Slow | High | Very High | | Visual | Low | Medium | Medium | High (UI) |
Test Automation Patterns
Page Object Model (Playwright E2E)
The POM pattern encapsulates page interactions into reusable classes, reducing test maintenance.
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage { readonly page: Page; readonly emailInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly errorMessage: Locator;
constructor(page: Page) { this.page = page; this.emailInput = page.getByLabel('Email'); this.passwordInput = page.getByLabel('Password'); this.submitButton = page.getByRole('button', { name: 'Sign in' }); this.errorMessage = page.getByRole('alert'); }
async goto() { await this.page.goto('/login'); }
async login(email: string, password: string) { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); }
async expectError(message: string) { await expect(this.errorMessage).toContainText(message); }
async expectRedirectToDashboard() { await expect(this.page).toHaveURL('/dashboard'); }}Usage in tests:
import { test } from '@playwright/test';import { LoginPage } from './pages/LoginPage';
test('successful login redirects to dashboard', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'password123'); await loginPage.expectRedirectToDashboard();});Test Data Factories
Create consistent test data with sensible defaults.
export function createUser(overrides: Partial<User> = {}): User { return { id: `user-${Date.now()}`, email: `user${Date.now()}@example.com`, name: 'Test User', role: 'user', createdAt: new Date('2024-01-01'), preferences: { theme: 'light', notifications: true, }, ...overrides, };}
export function createAdmin(overrides: Partial<User> = {}): User { return createUser({ role: 'admin', ...overrides });}Usage:
const user = createUser({ email: 'john@example.com' });const admin = createAdmin();Render with Providers (Custom Test Utilities)
Wrap component renders with necessary providers and context.
import React, { ReactElement } from 'react';import { render, RenderOptions } from '@testing-library/react';import { QueryClientProvider, QueryClient } from '@tanstack/react-query';import { AuthProvider } from '../../src/contexts/AuthContext';
export function renderWithProviders( ui: ReactElement, { initialUser = null, ...renderOptions } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, });
function Wrapper({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> <AuthProvider initialUser={initialUser}> {children} </AuthProvider> </QueryClientProvider> ); }
return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient, };}
export * from '@testing-library/react';export { renderWithProviders as render };MSW (Mock Service Worker) Patterns
Intercept network requests at the service worker level.
import { rest } from 'msw';
export const handlers = [ rest.get('/api/users/:id', (req, res, ctx) => { return res(ctx.json({ id: req.params.id, name: 'John Doe' })); }),
rest.post('/api/orders', async (req, res, ctx) => { const body = await req.json(); return res( ctx.status(201), ctx.json({ id: `order-${Date.now()}`, ...body, status: 'pending', }) ); }),
rest.get('/api/error', (req, res, ctx) => { return res(ctx.status(500), ctx.json({ error: 'Server error' })); }),];Override handlers per test:
it('shows error state on API failure', async () => { server.use( rest.get('/api/products', (req, res, ctx) => { return res(ctx.status(500)); }) );
render(<ProductList />); await waitFor(() => { expect(screen.getByText(/error/i)).toBeInTheDocument(); });});Vitest & React Testing Library
Unit Testing (Vitest)
Test pure functions and logic in isolation.
describe('formatPrice', () => { it('formats cents to USD by default', () => { expect(formatPrice(1999)).toBe('$19.99'); });
it('handles zero', () => { expect(formatPrice(0)).toBe('$0.00'); });
it('supports different currencies', () => { expect(formatPrice(1999, 'EUR')).toContain('€'); });});Component Testing (React Testing Library + userEvent)
Test components through their public behavior.
import { render, screen, waitFor } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { LoginForm } from './LoginForm';
describe('LoginForm', () => { it('submits form with user input', async () => { const user = userEvent.setup(); const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
// Arrange - form is rendered expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
// Act - user fills and submits await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.type(screen.getByLabelText(/password/i), 'password123'); await user.click(screen.getByRole('button', { name: /sign in/i }));
// Assert - submission succeeded await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com', password: 'password123', }); }); });
it('shows validation error for empty email', async () => { const user = userEvent.setup(); render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => { expect(screen.getByText(/email is required/i)).toBeInTheDocument(); }); });});Query Priority (RTL)
Use accessible queries in order of preference:
- Role queries (
getByRole,queryByRole,findByRole) — accessible to assistive tech - Label queries (
getByLabelText) — mirrors user behavior - Placeholder text (
getByPlaceholderText) — common user-facing text - Text queries (
getByText) — visible text - Test ID (
getByTestId) — last resort
// Good - uses role firstscreen.getByRole('button', { name: 'Submit' });screen.getByRole('textbox', { name: 'Email' });
// Acceptable - uses labelscreen.getByLabelText('Password');
// Last resort - test ID only when other queries don't workscreen.getByTestId('special-icon');Playwright E2E Testing
Project Setup
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html',
use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', },
projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, ],
webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, },});Sharding for CI
Split tests across multiple machines to keep wall-clock time under 10 minutes:
e2e: strategy: matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npx playwright install --with-deps - run: npm run build - run: npx playwright test --shard=${{ matrix.shard }}/4 - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report-${{ matrix.shard }} path: playwright-report/Accessibility Testing (jest-axe)
Add jest-axe to every component test:
import { render } from '@testing-library/react';import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Button a11y', () => { it('has no accessibility violations', async () => { const { container } = render(<Button>Click me</Button>); const results = await axe(container); expect(results).toHaveNoViolations(); });});For Playwright E2E:
import AxeBuilder from '@axe-core/playwright';
test('homepage has no a11y violations', async ({ page }) => { await page.goto('/'); const results = await new AxeBuilder({ page }).analyze(); expect(results.violations).toEqual([]);});Visual Regression Testing (Lost Pixel)
Use Lost Pixel (not Chromatic) for visual diffs:
import { test, expect } from '@playwright/test';
test('button variants render correctly', async ({ page }) => { await page.goto('/storybook/button'); await expect(page).toHaveScreenshot('button-variants.png');});
test('responsive header', async ({ page }) => { // Desktop await page.setViewportSize({ width: 1280, height: 720 }); await page.goto('/'); await expect(page.locator('header')).toHaveScreenshot('header-desktop.png');
// Mobile await page.setViewportSize({ width: 375, height: 667 }); await expect(page.locator('header')).toHaveScreenshot('header-mobile.png');});Coverage Analysis & Continuous Improvement
Reading Coverage Reports
# Generate coverage reportnpm test -- --coverage
# Key metrics to watch# - Lines: actual code lines tested# - Branches: if/else, ternary, logical operators# - Functions: declared functions# - Statements: executable statementsPrioritizing What to Test Next
- Find untested branches in business-critical code
- Estimate effort (5 min, 1 hour, 1 day)
- Estimate risk if untested (low, medium, high)
- Prioritize high-risk, low-effort gaps
Analyzing Coverage Gaps
const coverage = require('./coverage/coverage-summary.json');
const gapsByRisk = Object.entries(coverage) .filter(([file, data]) => data.lines.pct < 80) .map(([file, data]) => ({ file, coverage: data.lines.pct, uncoveredLines: data.lines.skipped, })) .sort((a, b) => a.coverage - b.coverage);
console.log('Coverage gaps (sorted by lowest first):');gapsByRisk.forEach(({ file, coverage }) => { console.log(` ${file}: ${coverage}%`);});Test Maintenance
When to Delete Tests
- Redundant coverage — multiple tests testing the same thing
- Testing implementation — tests that break on harmless refactors
- Obsolete features — tests for removed functionality
- Flaky beyond repair — tests that can’t be stabilized
Reducing Duplication
Create helper functions for common assertions:
export function expectLoadingState(container: HTMLElement) { expect(within(container).getByRole('progressbar')).toBeInTheDocument();}
export function expectErrorState(container: HTMLElement, message: string) { expect(within(container).getByRole('alert')).toHaveTextContent(message);}
// Usageit('shows loading state', () => { render(<DataList />); expectLoadingState(screen.getByTestId('data-list'));});Debugging Failed Tests
Jest debugging:
# Run single test by namenpx jest -t "should validate email"
# Run with Node inspectornode --inspect-brk node_modules/.bin/jest --runInBand
# Verbose outputnpx jest --verbose --no-coverageReact Testing Library debugging:
it('renders user profile', () => { render(<UserProfile userId="123" />);
// Print current DOM screen.debug();
// Log accessible roles screen.logTestingPlaygroundURL();
// Print specific element screen.debug(screen.getByRole('heading'));});Playwright debugging:
# Debug mode - opens browser with inspectornpx playwright test --debug
# UI mode - visual test runnernpx playwright test --ui
# Headed mode - see browsernpx playwright test --headedSummary
- Know the pyramid — 60% unit, 30% integration, 10% E2E
- Use factories for consistent test data
- Mock at the edge with MSW; avoid mocking your own code
- Write testable code — inject dependencies, use pure functions, separate concerns
- Prefer integration tests for components; unit tests for logic
- Add a11y + visual checks where they belong (jest-axe, Lost Pixel)
- Keep E2E under 10 min with sharding and parallelism
- Quarantine flaky tests as defects; never retry-til-green
- Measure coverage but don’t game it — direct coverage at risk
- Maintain tests by reducing duplication and updating with code changes