| name | frontend-tdd-workflow |
| description | Test-driven development workflow with Vitest, React Testing Library, MSW v2, and Playwright |
| activation | MUST load when writing tests, fixing bugs, or refactoring TypeScript/React code |
TDD Workflow — Testing Strategy
Overview
Enforces red-green-refactor TDD cycle with comprehensive testing strategy:
- Vitest + jsdom for component/unit tests
- React Testing Library for user-centric testing
- MSW v2 for API mocking
- Playwright for E2E critical paths
- Testing Trophy approach (pyramid inverted)
- 80%+ coverage requirement
Testing Trophy
/\
/E2E\ 5-10% — Playwright critical paths
/------\
/ Integr. \ Largest layer — RTL + MSW
/ ation \ "Write tests. Not too many. Mostly integration."
/--------------\
/ Unit tests \ Hooks, utils, store logic, pure functions
/------------------\
/ Static (TypeScript) \ Foundation — errors caught at compile time
/________________________\
Layer Breakdown
Static Analysis (Foundation)
- TypeScript strict mode catches type errors at compile time
- ESLint/Biome catch code smells and incorrect patterns
- No runtime cost
Unit Tests (10-15%)
- Test pure functions:
formatDate(), calculateTotal(), parseQueryString()
- Test hook logic:
useAuth(), useFormData() without rendering
- Test store logic: Zustand selectors, state updates
- Run fast, isolated, deterministic
Integration Tests (Largest Layer — 60-75%)
- Test features end-to-end with user interactions
- Use React Testing Library to test behavior, not implementation
- Mock external APIs with MSW
- Test user workflows: login, create item, update, delete
E2E Tests (5-10%)
- Test critical user paths: signup → email verification → dashboard
- Test multi-step flows: checkout process, complex workflows
- Run in real browser with Playwright
- Slow and expensive — only critical paths
Hard Rules
These rules are NON-NEGOTIABLE. Violating any of them is a bug.
- NEVER write implementation before tests — TDD: Red → Green → Refactor
- NEVER use
fireEvent — ALWAYS use userEvent
- NEVER use
getByText as first choice — prefer screen.getByRole
- NEVER use
getByTestId without strong justification
- NEVER test implementation details (state, internal methods, class names)
- NEVER use manual
act() wrapping around render() or userEvent — React Testing Library handles it
- NEVER share mutable state in
beforeEach — inline setup in each test
- NEVER mock fetch/axios with
vi.mock() — ALWAYS use MSW for API mocking
- NEVER skip tests or commit with
xit, skip, pending
- ALWAYS aim for 80%+ coverage before completion
- ALWAYS keep tests co-located with code (
Button.tsx + Button.test.tsx)
- ALWAYS run full suite (
pnpm test) not just individual files during development
File Organization
Structure
src/
features/auth/
components/
LoginForm.tsx
LoginForm.test.tsx # co-located
hooks/
useAuth.ts
useAuth.test.ts # co-located
api/
authApi.ts
authApi.test.ts # co-located
mocks/
handlers.ts # MSW handlers (centralized)
server.ts # MSW server setup
test/
setup.ts # Vitest setup file
test-utils.tsx # Custom render with providers
e2e/
pages/
LoginPage.ts # Page Object Model
DashboardPage.ts
tests/
auth.spec.ts # E2E tests
checkout.spec.ts
File Naming Convention
| Test Type | Pattern | Location |
|---|
| Unit / Integration | *.test.{ts,tsx} | Co-located in src/ |
| E2E | *.spec.ts | e2e/tests/ |
| Page Objects | *Page.ts | e2e/pages/ |
Custom Render (REQUIRED)
Every project must have src/test/test-utils.tsx with custom render wrapping components in all required providers.
Example:
import type { ReactElement } from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RenderOptions, render } from '@testing-library/react';
import type { Router } from '@tanstack/react-router';
import { RouterProvider } from '@tanstack/react-router';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
queryClient?: QueryClient;
router?: Router;
}
const Wrapper = ({
children,
queryClient,
}: {
children: ReactElement;
queryClient: QueryClient;
}): ReactElement => (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
export function renderWithProviders(
ui: ReactElement,
{
queryClient = createTestQueryClient(),
...renderOptions
}: ExtendedRenderOptions = {},
) {
return render(ui, {
wrapper: (props) => <Wrapper {...props} queryClient={queryClient} />,
...renderOptions,
});
}
export * from '@testing-library/react';
export { renderWithProviders as render };
Always import from test-utils:
import { render } from '@testing-library/react';
import { render } from '@/test/test-utils';
MSW v2 — API Mocking
Handler Setup
src/mocks/handlers.ts — centralized API mocks:
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
const { id } = params as { id: string };
return HttpResponse.json({ id, name: 'John Doe' });
}),
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'test@example.com') {
return HttpResponse.json(
{ token: 'mock-token', user: { id: '1', email: body.email } },
{ status: 200 }
);
}
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}),
];
Server Setup
src/mocks/server.ts — Vitest integration:
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
Vitest Configuration
vitest.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
src/test/setup.ts:
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from '@/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup();
});
afterAll(() => server.close());
Per-Test API Overrides
Override handlers for specific tests:
import { server } from '@/mocks/server';
import { http, HttpResponse } from 'msw';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@/test/test-utils';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('shows error on failed login', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
})
);
render(<LoginForm />);
});
});
Test Patterns
Unit Test — Pure Function
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
import { describe, it, expect } from 'vitest';
import { formatDate } from './formatDate';
describe('formatDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-12-25');
expect(formatDate(date)).toBe('December 25, 2024');
});
it('handles different dates', () => {
expect(formatDate(new Date('2024-01-01'))).toBe('January 1, 2024');
});
});
Unit Test — Hook (without rendering)
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
describe('useAuth', () => {
it('returns null user on init', () => {
const { result } = renderHook(() => useAuth());
expect(result.current.user).toBeNull();
});
it('logs in user', async () => {
const { result } = renderHook(() => useAuth());
await waitFor(() => {
result.current.login('test@example.com', 'password');
});
expect(result.current.user?.email).toBe('test@example.com');
});
});
Integration Test — Component with API
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/test-utils';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('logs in user on valid credentials', async () => {
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
const emailInput = screen.getByRole('textbox', { name: /email/i });
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /sign in/i });
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password');
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/logged in/i)).toBeInTheDocument();
});
});
it('shows error on invalid credentials', async () => {
server.use(
http.post('/api/auth/login', () =>
HttpResponse.json({ error: 'Invalid' }, { status: 401 })
)
);
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
await user.type(screen.getByRole('textbox', { name: /email/i }), 'wrong@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrong');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
});
});
Query Selectors — Priority
Use this priority for selecting elements:
screen.getByRole() — most semantic, encourages accessible code
screen.getByLabelText() — form inputs with labels
screen.getByPlaceholderText() — inputs with placeholders
screen.getByText() — last resort for non-interactive text
screen.getByTestId() — only when others impossible (rare)
Example:
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
screen.getByLabelText(/password/i);
screen.getByText('Click me');
screen.getByTestId('submit-button');
E2E Testing with Playwright
Page Object Model
e2e/pages/LoginPage.ts:
import type { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto(): Promise<void> {
await this.page.goto('/login');
}
async login(email: string, password: string): Promise<void> {
await this.page.fill('input[type="email"]', email);
await this.page.fill('input[type="password"]', password);
await this.page.click('button[type="submit"]');
}
async hasError(): Promise<boolean> {
return this.page.isVisible('[role="alert"]');
}
async isLoggedIn(): Promise<boolean> {
return this.page.isVisible('[data-testid="user-menu"]');
}
}
E2E Test
e2e/tests/auth.spec.ts:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('Auth Flow', () => {
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'password');
expect(await loginPage.isLoggedIn()).toBe(true);
});
test('shows error on invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@example.com', 'wrong');
expect(await loginPage.hasError()).toBe(true);
});
});
Playwright Configuration
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});
TDD Cycle (7 Steps)
- Write user story — understand what user wants
- Generate test cases — happy path, edge cases, error states
- Run tests — MUST fail (Red phase)
- Implement minimal code — just enough to pass (Green phase)
- Run tests — MUST pass, all must pass
- Refactor — improve code quality with confidence
- Verify coverage — ensure >= 80%
Quality Gates (MANDATORY — max 3 iterations each)
Gate 1: TypeScript Strict Check
pnpm typecheck
No errors, no warnings. Zero any, !, as (except as const).
Gate 2: Full Test Suite
pnpm test
All tests passing. Zero failures. Coverage >= 80%.
Gate 3: Linting
pnpm lint
Zero warnings. Zero errors. Automatic fixes with --fix acceptable.
If any gate fails after 3 attempts: Record remaining failures and proceed.
Coverage Requirements
- Lines: >= 80%
- Functions: >= 80%
- Branches: >= 80%
- Statements: >= 80%
Check coverage:
pnpm test --coverage
Coverage Exclusions
Never exclude code from coverage. If coverage is hard to reach:
- Consider refactoring to make code more testable
- Add integration tests to reach branches
- Only skip error paths if truly unreachable
Common Mistakes
❌ Testing Implementation Details
it('stores user in state', () => {
render(<LoginForm />);
expect(component.state.user).toBeDefined();
});
it('shows user name after login', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(...);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
❌ Using fireEvent
fireEvent.click(button);
fireEvent.change(input, { target: { value: 'text' } });
await userEvent.click(button);
await userEvent.type(input, 'text');
❌ Shared Mutable State
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient();
});
it('test 1', () => {
const queryClient = new QueryClient();
});
❌ Mocking with vi.mock()
vi.mock('node-fetch');
server.use(http.get('/api/users', () => HttpResponse.json([])));
Summary
TDD ensures:
- ✅ Tests written BEFORE implementation
- ✅ Coverage >= 80% (real testing, not coverage gaming)
- ✅ All tests passing before quality gates
- ✅ API mocked with MSW (never vi.mock)
- ✅ Testing user behavior, not implementation
- ✅ Fast feedback loop: Red → Green → Refactor