| name | testing |
| description | Comprehensive testing strategies for web UI — unit, integration, E2E, property-based, accessibility, visual regression, and performance testing with Vitest, Playwright, fast-check, and axe-core. Use when writing tests, debugging failures, improving coverage, or setting up test infrastructure. Triggers: "test", "vitest", "unit", "integration", "e2e", "coverage", "mock", "snapshot", "axe", "playwright", "fast-check", "tdd".
|
| category | web-ui |
Skill: testing
Test Pyramid (2026)
| Layer | Target | Tool | What to test | Speed |
|---|
| Unit | 70% | Vitest | Pure functions, utils, formatters | <100ms |
| Integration | 20% | Vitest + happy-dom | Components, DOM, user events | <500ms |
| E2E | 10% | Playwright | Critical user paths, flows | <30s |
| Property | Any | fast-check | Invariants, fuzzing pure functions | <1s |
| Accessibility | 100% | axe-core | WCAG 2.2 AA compliance | <1s |
| Visual | Key pages | Playwright screenshots | Layout regression | <10s |
Vitest Configuration
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['tests/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
Unit Testing Patterns
Pure Functions
import { describe, test, expect } from 'vitest';
import { formatCount, escapeHtml } from '../../src/utils/format.js';
describe('formatCount', () => {
test('formats millions', () => {
expect(formatCount(1_000_000)).toBe('1M');
expect(formatCount(2_500_000)).toBe('2.5M');
});
test('formats thousands', () => {
expect(formatCount(1_000)).toBe('1K');
expect(formatCount(84_231)).toBe('84.2K');
});
test('passes through small numbers', () => {
expect(formatCount(0)).toBe('0');
expect(formatCount(42)).toBe('42');
});
});
EscapeHtml (Security Critical)
describe('escapeHtml', () => {
test('escapes all dangerous characters', () => {
expect(escapeHtml('<script>alert("x")</script>'))
.toBe('<script>alert("x")</script>');
});
test('escapes ampersands first', () => {
expect(escapeHtml('<')).toBe('&lt;');
});
test('never produces unsafe output', () => {
const nasty = `<img src=x onerror=alert(1)>`;
expect(escapeHtml(nasty)).not.toMatch(/<img/);
});
});
Integration Testing Patterns
DOM Rendering
import { describe, test, expect } from 'vitest';
import { videoCard, renderVideoList } from '../../src/components/videos.js';
const sample = {
id: 'v1', title: 'Test & Specials <html>',
description: 'A test', duration: '3:45',
views: 12345, likes: 678, publishedAt: '2025-01-01',
channel: 'Test', quality: 'SD',
thumbnail: 'data:image/svg+xml;utf8,<svg />',
};
describe('videoCard', () => {
test('produces output with title', () => {
const html = videoCard(sample);
expect(html).toContain('Test');
expect(html).toContain('<a');
});
test('escapes dangerous characters', () => {
const html = videoCard(sample);
expect(html).toContain('&');
expect(html).not.toContain('<html>');
});
});
describe('renderVideoList', () => {
test('handles empty list', () => {
const el = { innerHTML: '', setAttribute: () => {} };
renderVideoList(el, []);
expect(el.innerHTML).toContain('No videos');
});
});
User Events
import { fireEvent } from '@testing-library/dom';
test('button click toggles state', () => {
const btn = document.createElement('button');
btn.setAttribute('aria-pressed', 'false');
btn.addEventListener('click', () => {
const pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!pressed));
});
fireEvent.click(btn);
expect(btn.getAttribute('aria-pressed')).toBe('true');
});
Property-Based Testing (fast-check)
import { describe, test, expect } from 'vitest';
import fc from 'fast-check';
import { formatCount, escapeHtml } from '../../src/utils/format.js';
describe('formatCount properties', () => {
test('output is always non-empty string', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 1e9 }), (n) => {
const out = formatCount(n);
expect(out.length).toBeGreaterThan(0);
expect(out).toMatch(/[KM\d]$/);
})
);
});
});
describe('escapeHtml properties', () => {
test('safe strings pass through unchanged', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 50 })
.filter(s => !/[<>&"']/.test(s)),
(s) => { expect(escapeHtml(s)).toBe(s); }
)
);
});
test('output never contains raw HTML', () => {
fc.assert(
fc.property(fc.string({ maxLength: 200 }), (s) => {
const out = escapeHtml(s);
expect(out).not.toMatch(/</);
expect(out).not.toMatch(/>/);
})
);
});
});
Accessibility Testing
import { describe, test, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import { axe } from 'axe-core';
describe('Home page a11y', () => {
test('has no axe violations', async () => {
const html = fs.readFileSync('examples/simple/index.html', 'utf8');
const dom = new JSDOM(html, { url: 'http://localhost' });
const results = await axe(dom.window.document);
expect(results.violations).toHaveLength(0);
});
});
Visual Regression Testing
import { test, expect } from '@playwright/test';
test('home page matches baseline', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixelRatio: 0.01,
});
});
E2E Testing (Playwright)
Best Practices
import { test, expect } from '@playwright/test';
test('displays hero section', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#main')).toBeVisible();
await expect(page.locator('h1')).toContainText('Build for');
});
test('navigation links work', async ({ page }) => {
await page.goto('/');
await page.click('a[href="#features"]');
await expect(page.locator('#features')).toBeInViewport();
});
test('CTA button links to GitHub', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Get Started on GitHub' })).toBeVisible();
});
test.describe('React App', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('feature cards are displayed', async ({ page }) => {
const cards = page.locator('#features .group');
await expect(cards).toHaveCount(6);
});
});
Page Object Models
import { expect, type Locator, type Page } from '@playwright/test';
export class HomePage {
readonly page: Page;
readonly hero: Locator;
readonly features: Locator;
readonly stats: Locator;
readonly skills: Locator;
readonly ctaButton: Locator;
constructor(page: Page) {
this.page = page;
this.hero = page.locator('#main');
this.features = page.locator('#features');
this.stats = page.locator('#stats');
this.skills = page.locator('#skills');
this.ctaButton = page.getByRole('link', { name: 'Get Started on GitHub' });
}
async goto() {
await this.page.goto('/');
}
async scrollToFeatures() {
await this.features.scrollIntoViewIfNeeded();
}
}
import { test, expect } from '@playwright/test';
import { HomePage } from './models/HomePage';
test('hero is visible', async ({ page }) => {
const home = new HomePage(page);
await home.goto();
await expect(home.hero).toBeVisible();
});
Accessibility Testing
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 })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
Mocking Strategies
Module Mocks
import { vi, describe, test, expect } from 'vitest';
import { loadData } from './data.js';
vi.mock('./api.js', () => ({
fetch: vi.fn().mockResolvedValue({ ok: true, json: () => ({}) }),
}));
test('loadData handles success', async () => {
const result = await loadData();
expect(result).toBeDefined();
});
Spy on Console
test('logs error on failure', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
await failingOperation();
expect(spy).toHaveBeenCalledWith(expect.stringContaining('Error'));
spy.mockRestore();
});
Coverage Commands
bun vitest run --coverage
open coverage/index.html
bun vitest run --coverage --reporter=text
Test File Conventions
| Location | Type | Naming |
|---|
tests/unit/ | Unit | <name>.test.js |
tests/integration/ | Integration | <name>.test.js |
tests/e2e/ | E2E | <name>.spec.js |
tests/property/ | Property | <name>.property.test.js |
tests/a11y/ | Accessibility | <name>.a11y.test.js |
tests/visual/ | Visual regression | <name>.spec.js |
CI Integration
- name: Run tests
run: bun vitest run --coverage
- name: Check coverage thresholds
run: bun vitest run --coverage --reporter=text
- name: Run E2E tests
run: npx playwright test
Common Pitfalls
| Pitfall | Fix |
|---|
jest.fn in Vitest | Use vi.fn instead |
| Async without await | Always await async operations |
| Flaky E2E tests | Use web-first assertions, not manual .isVisible() |
| Missing cleanup | Use beforeEach/afterEach for state reset |
| Testing implementation | Test behavior, not internals |
| No error cases | Always test error paths too |
| Skipping a11y | Run axe-core on every page |
| CSS selectors in Playwright | Use getByRole, getByLabel, getByText |
| Manual visibility checks | Use await expect(locator).toBeVisible() |
| Testing third-party APIs | Mock with page.route() |
Rules
- Write tests before implementation (TDD)
- All critical paths must have tests
- Property tests for all pure functions
- Accessibility tests for all pages
- Coverage thresholds: 80% minimum
- No
jest.fn — use vi.fn
- Mock external dependencies, not internal modules
- Test error states, not just happy paths
- Use
data-testid for E2E selectors (not CSS classes)