Standards & Reference
Test-Driven Development
This document outlines the Test-Driven Development process following the Red-Green-Refactor cycle.
Table of Contents
- What is TDD?
- The Red-Green-Refactor Cycle
- Project Setup
- TDD Workflow Examples
- Testing Patterns
- Component TDD
- Hook TDD
- Service/Utility TDD
- Integration with E2E Tests
- Best Practices
- Common Pitfalls
What is TDD?
Test-Driven Development is a software development approach where tests are written before the implementation code. This methodology ensures:
- Intentional Design: Forces you to think about the API and behavior before coding
- High Test Coverage: Every feature has corresponding tests
- Living Documentation: Tests describe what the code should do
- Confidence in Refactoring: Tests catch regressions immediately
- Reduced Debugging: Bugs are caught early in small, focused increments
The Three Laws of TDD
- Write no production code until you have written a failing test
- Write only enough of a test to demonstrate a failure
- Write only enough production code to make the failing test pass
The Red-Green-Refactor Cycle
The TDD cycle consists of three distinct phases:
┌─────────────────────────────────────────────────────────┐
│ │
│ ┌───────┐ ┌───────┐ ┌──────────┐ │
│ │ RED │ ───▶ │ GREEN │ ───▶ │ REFACTOR │ ───┐ │
│ └───────┘ └───────┘ └──────────┘ │ │
│ ▲ │ │
│ └─────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
🔴 RED Phase - Write a Failing Test
Goal: Write a test that fails for the right reason
- Think about the next small piece of functionality needed
- Write a test that describes the expected behavior
- Run the test and watch it fail
- Verify the failure message makes sense
// Example: Testing a new utility function
describe('formatCurrency', () => {
it('formats a number as USD currency', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});
});
// Run: npm test
// Result: ❌ ReferenceError: formatCurrency is not defined
Key Points:
- The test should fail because the feature doesn't exist yet
- If it passes immediately, either the test is wrong or the feature already exists
- Keep tests small and focused on one behavior
🟢 GREEN Phase - Make the Test Pass
Goal: Write the minimum code necessary to pass the test
- Implement just enough code to make the test pass
- Don't worry about elegance or optimization
- Take shortcuts if needed (hardcoding is acceptable temporarily)
- Run the test and verify it passes
// Minimum implementation to pass
export function formatCurrency(amount: number): string {
return '$1,234.56'; // Hardcoded - we'll fix this in refactor
}
// Run: npm test
// Result: ✅ Test passed
Key Points:
- Resist the urge to write more code than needed
- "Fake it till you make it" is valid in this phase
- The goal is to get to green as quickly as possible
🔵 REFACTOR Phase - Improve the Code
Goal: Clean up the implementation while keeping tests green
- Remove duplication
- Improve naming and readability
- Extract functions/components if needed
- Optimize performance if necessary
- Run tests after each change to ensure they still pass
// Refactored implementation
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}
// Run: npm test
// Result: ✅ Test still passes
Key Points:
- Never refactor on red - always start from green
- Make small, incremental changes
- Run tests frequently during refactoring
- This is where code quality improves
Project Setup
Install Testing Dependencies
# Unit testing with Vitest (recommended for Vite projects)
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
# Optional: Coverage reporting
npm install -D @vitest/coverage-v8
Configure Vitest
Create vitest.config.ts:
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: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'**/index.ts',
],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Create Test Setup File
Create src/test/setup.ts:
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Cleanup after each test
afterEach(() => {
cleanup();
});
Add npm Scripts
Update package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch"
}
}
Directory Structure
src/
├── components/
│ ├── EventCard/
│ │ ├── EventCard.tsx
│ │ ├── EventCard.test.tsx # Component tests
│ │ └── index.ts
│ └── ui/
│ └── button.tsx
├── hooks/
│ ├── useEvents.ts
│ └── useEvents.test.ts # Hook tests
├── lib/
│ ├── utils.ts
│ └── utils.test.ts # Utility tests
├── services/
│ ├── eventService.ts
│ └── eventService.test.ts # Service tests
└── test/
├── setup.ts
├── mocks/ # Mock data and handlers
│ ├── handlers.ts
│ └── data.ts
└── utils.tsx # Test utilities
TDD Workflow Examples
Example 1: Building a Utility Function
Feature: Create a function that calculates event progress percentage
🔴 RED - Write the failing test
// src/lib/eventUtils.test.ts
import { describe, it, expect } from 'vitest';
import { calculateProgress } from './eventUtils';
describe('calculateProgress', () => {
it('returns 0 when no deliverables exist', () => {
expect(calculateProgress(0, 0)).toBe(0);
});
});
npm test
# ❌ FAIL: calculateProgress is not defined
🟢 GREEN - Minimal implementation
// src/lib/eventUtils.ts
export function calculateProgress(completed: number, total: number): number {
return 0;
}
npm test
# ✅ PASS
🔴 RED - Add next test case
// src/lib/eventUtils.test.ts
describe('calculateProgress', () => {
it('returns 0 when no deliverables exist', () => {
expect(calculateProgress(0, 0)).toBe(0);
});
it('calculates percentage correctly', () => {
expect(calculateProgress(5, 10)).toBe(50);
});
});
npm test
# ❌ FAIL: expected 50, received 0
🟢 GREEN - Update implementation
// src/lib/eventUtils.ts
export function calculateProgress(completed: number, total: number): number {
if (total === 0) return 0;
return (completed / total) * 100;
}
npm test
# ✅ PASS
🔴 RED - Add edge case
describe('calculateProgress', () => {
// ... previous tests
it('rounds to nearest integer', () => {
expect(calculateProgress(1, 3)).toBe(33);
});
it('caps at 100 percent', () => {
expect(calculateProgress(15, 10)).toBe(100);
});
});
🟢 GREEN - Handle edge cases
export function calculateProgress(completed: number, total: number): number {
if (total === 0) return 0;
const percentage = (completed / total) * 100;
return Math.min(Math.round(percentage), 100);
}
🔵 REFACTOR - Clean up (if needed)
// Final version with documentation
/**
* Calculates the completion percentage of deliverables
* @param completed - Number of completed deliverables
* @param total - Total number of deliverables
* @returns Percentage (0-100) rounded to nearest integer
*/
export function calculateProgress(completed: number, total: number): number {
if (total === 0) return 0;
const percentage = (completed / total) * 100;
return Math.min(Math.round(percentage), 100);
}
Example 2: Building a React Component
Feature: Create a StatusBadge component
🔴 RED - Write the failing test
// src/components/StatusBadge/StatusBadge.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatusBadge } from './StatusBadge';
describe('StatusBadge', () => {
it('renders the status text', () => {
render(<StatusBadge status="on-track" />);
expect(screen.getByText('On Track')).toBeInTheDocument();
});
});
npm test
# ❌ FAIL: Cannot find module './StatusBadge'
🟢 GREEN - Minimal implementation
// src/components/StatusBadge/StatusBadge.tsx
export function StatusBadge({ status }: { status: string }) {
return <span>On Track</span>;
}
npm test
# ✅ PASS
🔴 RED - Test different statuses
describe('StatusBadge', () => {
it('renders the status text', () => {
render(<StatusBadge status="on-track" />);
expect(screen.getByText('On Track')).toBeInTheDocument();
});
it('renders at-risk status', () => {
render(<StatusBadge status="at-risk" />);
expect(screen.getByText('At Risk')).toBeInTheDocument();
});
it('renders delayed status', () => {
render(<StatusBadge status="delayed" />);
expect(screen.getByText('Delayed')).toBeInTheDocument();
});
});
🟢 GREEN - Handle all statuses
type Status = 'on-track' | 'at-risk' | 'delayed';
const statusLabels: Record<Status, string> = {
'on-track': 'On Track',
'at-risk': 'At Risk',
'delayed': 'Delayed',
};
export function StatusBadge({ status }: { status: Status }) {
return <span>{statusLabels[status]}</span>;
}
🔴 RED - Test styling
describe('StatusBadge', () => {
// ... previous tests
it('applies success styling for on-track', () => {
render(<StatusBadge status="on-track" />);
const badge = screen.getByText('On Track');
expect(badge).toHaveClass('bg-green-100');
});
it('applies warning styling for at-risk', () => {
render(<StatusBadge status="at-risk" />);
const badge = screen.getByText('At Risk');
expect(badge).toHaveClass('bg-yellow-100');
});
it('applies error styling for delayed', () => {
render(<StatusBadge status="delayed" />);
const badge = screen.getByText('Delayed');
expect(badge).toHaveClass('bg-red-100');
});
});
🟢 GREEN - Add styling
type Status = 'on-track' | 'at-risk' | 'delayed';
const statusConfig: Record<Status, { label: string; className: string }> = {
'on-track': {
label: 'On Track',
className: 'bg-green-100 text-green-800',
},
'at-risk': {
label: 'At Risk',
className: 'bg-yellow-100 text-yellow-800',
},
'delayed': {
label: 'Delayed',
className: 'bg-red-100 text-red-800',
},
};
export function StatusBadge({ status }: { status: Status }) {
const config = statusConfig[status];
return (
<span className={`px-2 py-1 rounded-full text-sm font-medium ${config.className}`}>
{config.label}
</span>
);
}
🔵 REFACTOR - Extract types and improve
// src/components/StatusBadge/StatusBadge.tsx
import { cn } from '@/lib/utils';
export type EventStatus = 'on-track' | 'at-risk' | 'delayed';
interface StatusBadgeProps {
status: EventStatus;
className?: string;
}
const statusConfig: Record<EventStatus, { label: string; className: string }> = {
'on-track': {
label: 'On Track',
className: 'bg-green-100 text-green-800 border-green-200',
},
'at-risk': {
label: 'At Risk',
className: 'bg-yellow-100 text-yellow-800 border-yellow-200',
},
'delayed': {
label: 'Delayed',
className: 'bg-red-100 text-red-800 border-red-200',
},
};
export function StatusBadge({ status, className }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
config.className,
className
)}
>
{config.label}
</span>
);
}
Example 3: Building a Custom Hook
Feature: Create a useFilter hook for event filtering
🔴 RED - Write the failing test
// src/hooks/useFilter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useFilter } from './useFilter';
describe('useFilter', () => {
it('returns all items when filter is "all"', () => {
const items = [
{ id: 1, status: 'on-track' },
{ id: 2, status: 'at-risk' },
{ id: 3, status: 'delayed' },
];
const { result } = renderHook(() => useFilter(items, 'status'));
expect(result.current.filteredItems).toEqual(items);
expect(result.current.activeFilter).toBe('all');
});
});
🟢 GREEN - Minimal implementation
// src/hooks/useFilter.ts
export function useFilter<T>(items: T[], filterKey: keyof T) {
return {
filteredItems: items,
activeFilter: 'all',
};
}
🔴 RED - Test filter functionality
describe('useFilter', () => {
// ... previous test
it('filters items by status', () => {
const items = [
{ id: 1, status: 'on-track' },
{ id: 2, status: 'at-risk' },
{ id: 3, status: 'on-track' },
];
const { result } = renderHook(() => useFilter(items, 'status'));
act(() => {
result.current.setFilter('on-track');
});
expect(result.current.filteredItems).toHaveLength(2);
expect(result.current.activeFilter).toBe('on-track');
});
});
🟢 GREEN - Add filter state
import { useState, useMemo } from 'react';
export function useFilter<T>(items: T[], filterKey: keyof T) {
const [activeFilter, setActiveFilter] = useState<string>('all');
const filteredItems = useMemo(() => {
if (activeFilter === 'all') return items;
return items.filter((item) => item[filterKey] === activeFilter);
}, [items, filterKey, activeFilter]);
return {
filteredItems,
activeFilter,
setFilter: setActiveFilter,
};
}
🔴 RED - Test filter counts
describe('useFilter', () => {
// ... previous tests
it('provides count for each filter option', () => {
const items = [
{ id: 1, status: 'on-track' },
{ id: 2, status: 'at-risk' },
{ id: 3, status: 'on-track' },
{ id: 4, status: 'delayed' },
];
const { result } = renderHook(() => useFilter(items, 'status'));
expect(result.current.counts).toEqual({
all: 4,
'on-track': 2,
'at-risk': 1,
'delayed': 1,
});
});
});
🟢 GREEN - Add counts
import { useState, useMemo } from 'react';
export function useFilter<T>(items: T[], filterKey: keyof T) {
const [activeFilter, setActiveFilter] = useState<string>('all');
const filteredItems = useMemo(() => {
if (activeFilter === 'all') return items;
return items.filter((item) => item[filterKey] === activeFilter);
}, [items, filterKey, activeFilter]);
const counts = useMemo(() => {
const result: Record<string, number> = { all: items.length };
items.forEach((item) => {
const value = String(item[filterKey]);
result[value] = (result[value] || 0) + 1;
});
return result;
}, [items, filterKey]);
return {
filteredItems,
activeFilter,
setFilter: setActiveFilter,
counts,
};
}
🔵 REFACTOR - Add types and optimize
// src/hooks/useFilter.ts
import { useState, useMemo, useCallback } from 'react';
export interface UseFilterResult<T> {
filteredItems: T[];
activeFilter: string;
setFilter: (filter: string) => void;
counts: Record<string, number>;
resetFilter: () => void;
}
export function useFilter<T extends Record<string, unknown>>(
items: T[],
filterKey: keyof T
): UseFilterResult<T> {
const [activeFilter, setActiveFilter] = useState<string>('all');
const filteredItems = useMemo(() => {
if (activeFilter === 'all') return items;
return items.filter((item) => item[filterKey] === activeFilter);
}, [items, filterKey, activeFilter]);
const counts = useMemo(() => {
const result: Record<string, number> = { all: items.length };
for (const item of items) {
const value = String(item[filterKey]);
result[value] = (result[value] || 0) + 1;
}
return result;
}, [items, filterKey]);
const resetFilter = useCallback(() => {
setActiveFilter('all');
}, []);
return {
filteredItems,
activeFilter,
setFilter: setActiveFilter,
counts,
resetFilter,
};
}
Testing Patterns
Arrange-Act-Assert (AAA)
Structure every test with three clear sections:
it('filters events by status', () => {
// Arrange - Set up test data and conditions
const events = [
{ id: 1, name: 'Event A', status: 'on-track' },
{ id: 2, name: 'Event B', status: 'at-risk' },
];
// Act - Execute the code under test
const result = filterEvents(events, 'on-track');
// Assert - Verify the expected outcome
expect(result).toHaveLength(1);
expect(result[0].name).toBe('Event A');
});
Given-When-Then (BDD Style)
describe('EventCard', () => {
describe('given an event with delayed status', () => {
const event = createEvent({ status: 'delayed' });
describe('when rendered', () => {
it('then displays a red border', () => {
render(<EventCard event={event} />);
const card = screen.getByTestId('event-card');
expect(card).toHaveClass('border-l-red-500');
});
it('then shows "Delayed" badge', () => {
render(<EventCard event={event} />);
expect(screen.getByText('Delayed')).toBeInTheDocument();
});
});
});
});
Test Doubles
// Mock function
const mockOnClick = vi.fn();
render(<Button onClick={mockOnClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(mockOnClick).toHaveBeenCalledTimes(1);
// Mock module
vi.mock('@/services/eventService', () => ({
fetchEvents: vi.fn().mockResolvedValue([{ id: 1, name: 'Test Event' }]),
}));
// Spy on existing function
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// ... test code
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
Component TDD
Testing Component Rendering
describe('EventCard', () => {
const defaultProps = {
name: 'Test Event',
date: 'Jan 15, 2026',
location: 'Seattle, WA',
status: 'on-track' as const,
progress: 75,
};
it('renders event name', () => {
render(<EventCard {...defaultProps} />);
expect(screen.getByText('Test Event')).toBeInTheDocument();
});
it('renders event date with calendar icon', () => {
render(<EventCard {...defaultProps} />);
expect(screen.getByText('Jan 15, 2026')).toBeInTheDocument();
});
it('renders progress bar with correct value', () => {
render(<EventCard {...defaultProps} />);
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toHaveAttribute('aria-valuenow', '75');
});
});
Testing User Interactions
describe('FilterBar', () => {
it('calls onFilterChange when filter button is clicked', async () => {
const handleFilterChange = vi.fn();
const user = userEvent.setup();
render(<FilterBar onFilterChange={handleFilterChange} />);
await user.click(screen.getByRole('button', { name: /at risk/i }));
expect(handleFilterChange).toHaveBeenCalledWith('at-risk');
});
it('highlights active filter button', async () => {
const user = userEvent.setup();
render(<FilterBar onFilterChange={vi.fn()} />);
const atRiskButton = screen.getByRole('button', { name: /at risk/i });
await user.click(atRiskButton);
expect(atRiskButton).toHaveAttribute('data-state', 'active');
});
});
Testing Async Components
describe('EventList', () => {
it('shows loading state while fetching', () => {
render(<EventList />);
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
it('renders events after loading', async () => {
render(<EventList />);
await waitFor(() => {
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
expect(screen.getByText('Event 1')).toBeInTheDocument();
expect(screen.getByText('Event 2')).toBeInTheDocument();
});
it('shows error message on fetch failure', async () => {
server.use(
http.get('/api/events', () => {
return HttpResponse.error();
})
);
render(<EventList />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
});
});
Hook TDD
Testing Custom Hooks
// src/hooks/useToggle.test.ts
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
describe('useToggle', () => {
it('starts with initial value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
});
it('toggles value when toggle is called', () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current[1](); // toggle function
});
expect(result.current[0]).toBe(true);
});
it('sets value directly', () => {
const { result } = renderHook(() => useToggle(false));
act(() => {
result.current[2](true); // setValue function
});
expect(result.current[0]).toBe(true);
});
});
Testing Hooks with Context
// Create a wrapper with providers
const createWrapper = () => {
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
{children}
</AuthProvider>
</QueryClientProvider>
);
};
};
describe('useUser', () => {
it('returns current user from context', () => {
const { result } = renderHook(() => useUser(), {
wrapper: createWrapper(),
});
expect(result.current.user).toBeDefined();
expect(result.current.isAuthenticated).toBe(true);
});
});
Service/Utility TDD
Testing API Services
// src/services/eventService.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { eventService } from './eventService';
const server = setupServer();
beforeEach(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('eventService', () => {
describe('getEvents', () => {
it('fetches events successfully', async () => {
const mockEvents = [
{ id: '1', name: 'Event 1' },
{ id: '2', name: 'Event 2' },
];
server.use(
http.get('/api/events', () => {
return HttpResponse.json(mockEvents);
})
);
const result = await eventService.getEvents();
expect(result).toEqual(mockEvents);
});
it('throws error on network failure', async () => {
server.use(
http.get('/api/events', () => {
return HttpResponse.error();
})
);
await expect(eventService.getEvents()).rejects.toThrow();
});
});
describe('createEvent', () => {
it('creates event and returns with id', async () => {
const newEvent = { name: 'New Event', date: '2026-01-15' };
server.use(
http.post('/api/events', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '123', ...body }, { status: 201 });
})
);
const result = await eventService.createEvent(newEvent);
expect(result.id).toBe('123');
expect(result.name).toBe('New Event');
});
});
});
Testing Utility Functions
// src/lib/dateUtils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, isOverdue, getDaysUntil } from './dateUtils';
describe('dateUtils', () => {
describe('formatDate', () => {
it('formats date in readable format', () => {
expect(formatDate('2026-01-15')).toBe('Jan 15, 2026');
});
it('handles date objects', () => {
expect(formatDate(new Date(2026, 0, 15))).toBe('Jan 15, 2026');
});
it('returns empty string for invalid date', () => {
expect(formatDate('invalid')).toBe('');
});
});
describe('isOverdue', () => {
it('returns true for past dates', () => {
expect(isOverdue('2020-01-01')).toBe(true);
});
it('returns false for future dates', () => {
expect(isOverdue('2030-01-01')).toBe(false);
});
});
describe('getDaysUntil', () => {
it('calculates days until future date', () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 5);
expect(getDaysUntil(futureDate)).toBe(5);
});
it('returns negative for past dates', () => {
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 3);
expect(getDaysUntil(pastDate)).toBe(-3);
});
});
});
Integration with E2E Tests
Test Pyramid
┌───────────┐
/ E2E \ Few, slow, high confidence
/ Tests \
├───────────────┤
/ Integration \ Some, medium speed
/ Tests \
├─────────────────────┤
/ Unit Tests \ Many, fast, focused
└─────────────────────────┘
When to Use Each Test Type
| Test Type | Use For | TDD Phase |
|---|---|---|
| Unit | Functions, utilities, isolated components | Primary TDD cycle |
| Integration | Component interactions, hooks with context | After unit tests pass |
| E2E | Critical user journeys, full workflows | After feature complete |
TDD Flow Across Test Types
// 1. Start with unit test
// src/lib/validation.test.ts
it('validates email format', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('invalid')).toBe(false);
});
// 2. Add component test
// src/components/EmailInput.test.tsx
it('shows error for invalid email', async () => {
render(<EmailInput />);
await userEvent.type(screen.getByRole('textbox'), 'invalid');
expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});
// 3. Add E2E test for full flow
// e2e/tests/registration.spec.ts
test('shows validation error for invalid email', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Email').fill('invalid');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText(/invalid email/i)).toBeVisible();
});
Best Practices
1. Write Tests First, Always
// ❌ Bad: Writing implementation first
function calculateTotal(items) { /* ... */ }
// Then writing tests after
// ✅ Good: Test first
it('calculates total price of items', () => {
const items = [{ price: 10 }, { price: 20 }];
expect(calculateTotal(items)).toBe(30);
});
// Then implement to make it pass
2. Keep Tests Small and Focused
// ❌ Bad: Testing multiple behaviors
it('handles event operations', () => {
const event = createEvent();
expect(event.name).toBe('Test');
event.setStatus('delayed');
expect(event.status).toBe('delayed');
event.addDeliverable({ name: 'Doc' });
expect(event.deliverables).toHaveLength(1);
});
// ✅ Good: One assertion per test
it('creates event with default name', () => {
const event = createEvent();
expect(event.name).toBe('Test');
});
it('updates event status', () => {
const event = createEvent();
event.setStatus('delayed');
expect(event.status).toBe('delayed');
});
it('adds deliverable to event', () => {
const event = createEvent();
event.addDeliverable({ name: 'Doc' });
expect(event.deliverables).toHaveLength(1);
});
3. Use Descriptive Test Names
// ❌ Bad: Vague names
it('works', () => { /* ... */ });
it('handles error', () => { /* ... */ });
// ✅ Good: Describes behavior and context
it('displays error message when API returns 500', () => { /* ... */ });
it('disables submit button while form is submitting', () => { /* ... */ });
it('filters events to show only delayed items when delayed filter is active', () => { /* ... */ });
4. Test Behavior, Not Implementation
// ❌ Bad: Testing implementation details
it('calls setState with new value', () => {
const setState = vi.fn();
vi.spyOn(React, 'useState').mockReturnValue([false, setState]);
// ...
expect(setState).toHaveBeenCalledWith(true);
});
// ✅ Good: Testing observable behavior
it('shows menu when toggle button is clicked', async () => {
render(<Navigation />);
await userEvent.click(screen.getByRole('button', { name: /menu/i }));
expect(screen.getByRole('navigation')).toBeVisible();
});
5. Don't Test Framework Code
// ❌ Bad: Testing React's useState
it('updates state when setValue is called', () => {
const [value, setValue] = useState(0);
setValue(1);
expect(value).toBe(1); // Testing React, not your code
});
// ✅ Good: Testing your component's behavior
it('increments counter when plus button is clicked', async () => {
render(<Counter />);
await userEvent.click(screen.getByRole('button', { name: '+' }));
expect(screen.getByText('1')).toBeInTheDocument();
});
6. Use Test Factories
// src/test/factories.ts
export function createEvent(overrides: Partial<Event> = {}): Event {
return {
id: '1',
name: 'Test Event',
date: '2026-01-15',
location: 'Seattle, WA',
status: 'on-track',
progress: 50,
...overrides,
};
}
// In tests
it('shows delayed styling for delayed events', () => {
const event = createEvent({ status: 'delayed' });
render(<EventCard event={event} />);
expect(screen.getByTestId('event-card')).toHaveClass('border-red-500');
});
7. Maintain Test Independence
// ❌ Bad: Tests depend on each other
let counter = 0;
it('increments counter', () => {
counter++;
expect(counter).toBe(1);
});
it('counter is still incremented', () => {
expect(counter).toBe(1); // Depends on previous test
});
// ✅ Good: Each test is independent
it('increments counter', () => {
const counter = createCounter();
counter.increment();
expect(counter.value).toBe(1);
});
it('decrements counter', () => {
const counter = createCounter();
counter.decrement();
expect(counter.value).toBe(-1);
});
Common Pitfalls
1. Writing Too Much Code Before Testing
Problem: Writing the entire feature then adding tests
Solution: Take tiny steps - one test, one small implementation
// Take the smallest possible step
it('returns empty array for empty input', () => {
expect(filterEvents([])).toEqual([]);
});
2. Testing Implementation Details
Problem: Tests break when refactoring even though behavior is correct
Solution: Test the public API and observable behavior
// ❌ Tests internal state
expect(component.state.isOpen).toBe(true);
// ✅ Tests what user sees
expect(screen.getByRole('dialog')).toBeVisible();
3. Ignoring the Refactor Phase
Problem: Code works but accumulates technical debt
Solution: Always take time to refactor after green
// After tests pass, ask:
// - Can I remove duplication?
// - Are names clear?
// - Is there a simpler way?
// - Should I extract a function/component?
4. Writing Tests That Are Too Large
Problem: Tests are hard to debug when they fail
Solution: Each test should verify one specific behavior
5. Not Running Tests Frequently
Problem: Long feedback cycles make debugging harder
Solution: Run tests after every small change
# Use watch mode
npm run test:watch
6. Skipping Tests When "It's Simple"
Problem: "Simple" code often has edge cases
Solution: Everything gets tested - no exceptions
Quick Reference
TDD Cycle Checklist
□ RED
□ Write a failing test
□ Run it and see it fail
□ Verify failure message makes sense
□ GREEN
□ Write minimal code to pass
□ Run test and see it pass
□ Don't add extra functionality
□ REFACTOR
□ Clean up the code
□ Run tests after each change
□ Commit when satisfied
Command Cheat Sheet
npm test # Run tests in watch mode
npm run test:run # Run tests once
npm run test:coverage # Run with coverage report
npm run test:ui # Open Vitest UI
Test Structure Template
describe('ComponentName', () => {
// Setup
const defaultProps = { /* ... */ };
beforeEach(() => {
// Reset mocks, etc.
});
describe('when condition', () => {
it('should expected behavior', () => {
// Arrange
// Act
// Assert
});
});
});
Cross-Language Testing Gotchas
Python: Async Mock Methods
When adding new async method calls to existing code (e.g., await redis.zadd()), tests using MagicMock() will fail because MagicMock doesn't support await by default.
Problem:
# Code added: await redis_client.zadd(key, ...)
# Test uses: mock_redis = MagicMock()
# Result: TypeError: object MagicMock can't be used in 'await' expression
Solution:
from unittest.mock import MagicMock, AsyncMock
mock_redis = MagicMock()
mock_redis.zadd = AsyncMock() # Add AsyncMock for new async methods
mock_redis.zrem = AsyncMock()
GitHub Actions & Fork PRs
GitHub Actions does not provide secrets to fork PRs for security reasons. This means:
| PR Type | Secrets Available | Impact |
|---|---|---|
| Same-repo PR | ✅ Yes | All secret-dependent tests pass |
| Fork PR | ❌ No | Tests requiring API keys fail with 401/403 |
What to do when reviewing fork PRs:
- Check if test failures are secret-related (look for auth errors)
- Focus on test failures from code changes, not missing secrets
- Secret-dependent tests will pass after merge to main