Skip to content

Test Authoring Patterns & Automation

FieldValue
TypeSkill Resource
Source~/.copilot/skills/quality/references/test-authoring.md
DescriptionNot 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 TypeLine CoverageFocus Areas
Startup/MVP60%+Core business logic, auth, payments
Growing Product75%+Business-critical paths, error handling
Enterprise85%+Mission-critical flows, security, compliance
Safety Critical95%+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:

  1. Is it pure logic with no side effects? → Unit test with Vitest.
  2. Does it make API calls or use context? → Integration test with MSW mocking.
  3. Is it a critical user flow? → E2E test with Playwright.
  4. Is it a visual component with many states? → Storybook + Lost Pixel visual regression.
  5. 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.

e2e/pages/LoginPage.ts
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.

__tests__/factories/userFactory.ts
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.

__tests__/utils/renderWithProviders.tsx
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.

__tests__/mocks/handlers.ts
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.

utils/formatPrice.test.ts
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.

components/LoginForm.test.tsx
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:

  1. Role queries (getByRole, queryByRole, findByRole) — accessible to assistive tech
  2. Label queries (getByLabelText) — mirrors user behavior
  3. Placeholder text (getByPlaceholderText) — common user-facing text
  4. Text queries (getByText) — visible text
  5. Test ID (getByTestId) — last resort
// Good - uses role first
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('textbox', { name: 'Email' });
// Acceptable - uses label
screen.getByLabelText('Password');
// Last resort - test ID only when other queries don't work
screen.getByTestId('special-icon');

Playwright E2E Testing

Project Setup

playwright.config.ts
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:

.github/workflows/test-e2e.yml
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:

e2e/visual/components.spec.ts
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

Terminal window
# Generate coverage report
npm test -- --coverage
# Key metrics to watch
# - Lines: actual code lines tested
# - Branches: if/else, ternary, logical operators
# - Functions: declared functions
# - Statements: executable statements

Prioritizing What to Test Next

  1. Find untested branches in business-critical code
  2. Estimate effort (5 min, 1 hour, 1 day)
  3. Estimate risk if untested (low, medium, high)
  4. Prioritize high-risk, low-effort gaps

Analyzing Coverage Gaps

scripts/analyze-coverage.js
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:

__tests__/helpers/assertions.ts
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);
}
// Usage
it('shows loading state', () => {
render(<DataList />);
expectLoadingState(screen.getByTestId('data-list'));
});

Debugging Failed Tests

Jest debugging:

Terminal window
# Run single test by name
npx jest -t "should validate email"
# Run with Node inspector
node --inspect-brk node_modules/.bin/jest --runInBand
# Verbose output
npx jest --verbose --no-coverage

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

Terminal window
# Debug mode - opens browser with inspector
npx playwright test --debug
# UI mode - visual test runner
npx playwright test --ui
# Headed mode - see browser
npx playwright test --headed

Summary

  1. Know the pyramid — 60% unit, 30% integration, 10% E2E
  2. Use factories for consistent test data
  3. Mock at the edge with MSW; avoid mocking your own code
  4. Write testable code — inject dependencies, use pure functions, separate concerns
  5. Prefer integration tests for components; unit tests for logic
  6. Add a11y + visual checks where they belong (jest-axe, Lost Pixel)
  7. Keep E2E under 10 min with sharding and parallelism
  8. Quarantine flaky tests as defects; never retry-til-green
  9. Measure coverage but don’t game it — direct coverage at risk
  10. Maintain tests by reducing duplication and updating with code changes