# Testing Best Practices (2025) Modern testing patterns using Vitest, React Testing Library, Playwright, and accessibility testing. ## Testing Stack ### Current Tools (2025) - **Unit/Integration**: Vitest (not Jest) - **Component Testing**: React Testing Library - **E2E Testing**: Playwright - **Accessibility**: axe-core + @axe-core/playwright - **Coverage**: Vitest with v8 provider ### Deprecated Tools to Flag - [ERROR] Jest (replaced by Vitest) - [ERROR] Enzyme (replaced by React Testing Library) - [ERROR] Karma, Jasmine (outdated) ## Vitest Configuration ### [OK] Modern: vitest.config.ts ```typescript import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'path' export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './test/setup.ts', coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'test/', '**/*.config.{ts,js}', '**/*.d.ts', ], }, }, resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, }) ``` ### [ERROR] Deprecated: jest.config.js ```javascript // OLD PATTERN - Don't use Jest module.exports = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['/jest.setup.js'], } ``` ## Unit Testing Patterns ### [OK] Modern: Vitest Imports ```typescript import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' describe('validateEntity', () => { it('validates entity with required fields', () => { const result = validateEntity({ name: 'Character', type: 'character' }) expect(result.valid).toBe(true) }) it('rejects entity with missing name', () => { const result = validateEntity({ type: 'character' }) expect(result.valid).toBe(false) expect(result.errors).toContain('Name is required') }) }) ``` ### Test Organization ```typescript // Good structure: Arrange, Act, Assert (AAA) describe('CharacterService', () => { describe('createCharacter', () => { it('creates character with valid data', async () => { // Arrange const characterData = { name: 'Aria', class: 'Rogue', } // Act const result = await createCharacter(characterData) // Assert expect(result.success).toBe(true) expect(result.character).toMatchObject(characterData) }) }) }) ``` ## Component Testing with RTL ### [OK] Modern: React Testing Library ```typescript import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi } from 'vitest' import { CharacterForm } from './CharacterForm' describe('CharacterForm', () => { it('renders form fields', () => { render() expect(screen.getByLabelText(/name/i)).toBeInTheDocument() expect(screen.getByLabelText(/class/i)).toBeInTheDocument() }) it('submits form with valid data', async () => { const user = userEvent.setup() const onSubmit = vi.fn() render() await user.type(screen.getByLabelText(/name/i), 'Aria') await user.selectOptions(screen.getByLabelText(/class/i), 'rogue') await user.click(screen.getByRole('button', { name: /submit/i })) await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ name: 'Aria', class: 'rogue', }) }) }) it('shows validation errors', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /submit/i })) expect(await screen.findByText(/name is required/i)).toBeInTheDocument() }) }) ``` ### [ERROR] Deprecated: Enzyme ```typescript // OLD PATTERN - Don't use Enzyme import { shallow } from 'enzyme' const wrapper = shallow() wrapper.find('input').simulate('change') ``` ## Custom Render Function ### [OK] Create Test Utils ```typescript // test/utils/render.tsx import { render, RenderOptions } from '@testing-library/react' import { ReactElement } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }) function AllProviders({ children }: { children: React.ReactNode }) { return ( {children} ) } export function renderWithProviders( ui: ReactElement, options?: Omit ) { return render(ui, { wrapper: AllProviders, ...options }) } export * from '@testing-library/react' export { renderWithProviders as render } ``` ## Mocking Patterns ### [OK] Vitest Mocks ```typescript import { vi } from 'vitest' // Mock module vi.mock('@/lib/db', () => ({ db: { character: { findMany: vi.fn(), create: vi.fn(), }, }, })) // Mock function const mockFetch = vi.fn() global.fetch = mockFetch // Mock implementation mockFetch.mockResolvedValue({ ok: true, json: async () => ({ name: 'Test' }), }) // Spy on method const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) // Cleanup afterEach(() => { vi.clearAllMocks() }) ``` ### [ERROR] Deprecated: Jest Mocks ```typescript // OLD PATTERN jest.mock('./module') jest.fn() jest.spyOn() ``` ## Playwright E2E Testing ### [OK] Modern: Playwright Config ```typescript // playwright.config.ts import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './test/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', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, }, ], webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, }) ``` ### [OK] E2E Test Patterns ```typescript import { test, expect } from '@playwright/test' test.describe('Character Creation', () => { test('creates new character', async ({ page }) => { await page.goto('/characters') // Navigate to create form await page.getByRole('button', { name: /create character/i }).click() // Fill form await page.getByLabel(/name/i).fill('Aria Shadowblade') await page.getByLabel(/class/i).selectOption('rogue') await page.getByLabel(/level/i).fill('5') // Submit await page.getByRole('button', { name: /save/i }).click() // Verify success await expect(page.getByText('Aria Shadowblade')).toBeVisible() await expect(page).toHaveURL(/\/characters\/\d+/) }) test('shows validation errors', async ({ page }) => { await page.goto('/characters/create') await page.getByRole('button', { name: /save/i }).click() await expect(page.getByText(/name is required/i)).toBeVisible() }) }) ``` ## Accessibility Testing ### [OK] Component-Level A11y ```typescript import { render } from '@testing-library/react' import { axe, toHaveNoViolations } from 'jest-axe' import { CharacterCard } from './CharacterCard' expect.extend(toHaveNoViolations) describe('CharacterCard Accessibility', () => { it('has no accessibility violations', async () => { const { container } = render( ) const results = await axe(container) expect(results).toHaveNoViolations() }) }) ``` ### [OK] E2E A11y with Playwright ```typescript import { test, expect } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test.describe('Accessibility', () => { test('homepage meets WCAG 2.1 AA', async ({ page }) => { await page.goto('/') const accessibilityScanResults = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']) .analyze() expect(accessibilityScanResults.violations).toEqual([]) }) test('character form is accessible', async ({ page }) => { await page.goto('/characters/create') const results = await new AxeBuilder({ page }) .exclude('#third-party-widget') // Exclude external widgets .analyze() expect(results.violations).toEqual([]) }) }) ``` ## Test Coverage ### [OK] Coverage Configuration ```typescript // vitest.config.ts export default defineConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], exclude: [ 'node_modules/', 'test/', '**/*.config.{ts,js}', '**/*.d.ts', '**/types.ts', ], thresholds: { lines: 80, functions: 80, branches: 80, statements: 80, }, }, }, }) ``` ### Running Coverage ```bash # Run with coverage vitest run --coverage # Watch mode with coverage vitest --coverage --watch # Coverage for specific files vitest run --coverage --changed ``` ## Testing Best Practices ### 1. Query Priority (React Testing Library) Use queries in this priority order: 1. **Accessible to everyone**: - `getByRole` - `getByLabelText` - `getByPlaceholderText` - `getByText` 2. **Semantic queries**: - `getByAltText` - `getByTitle` 3. **Test IDs** (last resort): - `getByTestId` ```typescript // [OK] Good screen.getByRole('button', { name: /submit/i }) screen.getByLabelText(/email/i) // [ERROR] Avoid screen.getByTestId('submit-button') ``` ### 2. Async Testing ```typescript import { waitFor, screen } from '@testing-library/react' // [OK] Use waitFor for async assertions await waitFor(() => { expect(screen.getByText(/success/i)).toBeInTheDocument() }) // [OK] Use findBy queries (combines getBy + waitFor) const element = await screen.findByText(/success/i) // [ERROR] Don't use arbitrary timeouts await new Promise(resolve => setTimeout(resolve, 1000)) ``` ### 3. User Interactions ```typescript import userEvent from '@testing-library/user-event' // [OK] Use userEvent (more realistic) const user = userEvent.setup() await user.type(input, 'text') await user.click(button) // [ERROR] Avoid fireEvent import { fireEvent } from '@testing-library/react' fireEvent.click(button) ``` ### 4. Test Independence ```typescript // [OK] Each test is independent describe('CharacterList', () => { beforeEach(() => { // Fresh data for each test mockCharacters = [...] }) it('displays characters', () => { render() // Test logic }) it('filters characters', () => { render() // Test logic }) }) // [ERROR] Tests depend on each other let sharedState it('creates character', () => { sharedState = createCharacter() }) it('updates character', () => { updateCharacter(sharedState) // Depends on previous test }) ``` ### 5. What to Test **[OK] Do Test:** - User interactions and workflows - Component rendering with different props - Conditional logic and edge cases - Form validation - Error handling - Accessibility **[ERROR] Don't Test:** - Implementation details - Third-party library internals - Exact CSS values - Component internal state - Trivial code ## Common Anti-Patterns ### [ERROR] Testing Implementation Details ```typescript // BAD: Testing state directly const { result } = renderHook(() => useCounter()) expect(result.current.count).toBe(0) // GOOD: Testing behavior render() expect(screen.getByText(/count: 0/i)).toBeInTheDocument() ``` ### [ERROR] Snapshot Testing Overuse ```typescript // BAD: Large snapshots expect(container).toMatchSnapshot() // GOOD: Specific assertions expect(screen.getByRole('heading')).toHaveTextContent('Characters') ``` ### [ERROR] Not Cleaning Up ```typescript // BAD: No cleanup afterEach(() => { // Missing cleanup }) // GOOD: Proper cleanup afterEach(() => { vi.clearAllMocks() cleanup() }) ``` ## Package Scripts ```json { "scripts": { "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:watch": "vitest --watch", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:a11y": "playwright test a11y.spec.ts" } } ``` ## Validation Checklist When reviewing testing skills, check for: - [ ] Uses Vitest (not Jest) - [ ] Uses React Testing Library (not Enzyme) - [ ] Uses Playwright for E2E - [ ] Includes accessibility testing with axe-core - [ ] Proper query priority (role, label, text) - [ ] userEvent instead of fireEvent - [ ] Async testing with waitFor/findBy - [ ] Proper mocking with vi.* - [ ] Coverage configuration - [ ] Independent tests - [ ] Tests behavior, not implementation - [ ] Cleanup in afterEach - [ ] Descriptive test names - [ ] AAA pattern (Arrange, Act, Assert) ## Migration Guide ### Jest → Vitest ```typescript // Jest import { jest } from '@jest/globals' jest.fn() jest.spyOn() jest.mock() // Vitest import { vi } from 'vitest' vi.fn() vi.spyOn() vi.mock() ``` ### Update Imports ```typescript // Old import { describe, it, expect } from '@jest/globals' // New import { describe, it, expect } from 'vitest' ``` ### Update Config ```bash # Remove npm uninstall jest @types/jest # Install npm install -D vitest @vitejs/plugin-react jsdom ``` ## Quick Reference ### Must Use (Modern) - [OK] Vitest (not Jest) - [OK] React Testing Library - [OK] Playwright - [OK] axe-core for a11y - [OK] userEvent for interactions - [OK] waitFor/findBy for async - [OK] getByRole queries ### Must Avoid (Deprecated) - [ERROR] Jest - [ERROR] Enzyme - [ERROR] fireEvent - [ERROR] getByTestId (overuse) - [ERROR] Snapshot tests (overuse) - [ERROR] Testing implementation details - [ERROR] Arbitrary timeouts