Apply Red-Green-Refactor to React components using React Testing Library: write component tests before JSX exists, drive props and state through failing tests, and TDD custom hooks.
TDD for React components feels different from TDD for pure functions because there is JSX to write, not just logic. But the core discipline is identical: write a failing test that specifies what the user should see or be able to do, then write the minimum JSX and logic to make it pass.
React Testing Library's philosophy — test what users see, not implementation details — aligns perfectly with TDD. Tests query by ARIA role and text, so they specify user-observable behaviour. This lesson applies the Red-Green-Refactor cycle to a real component, a form with validation, and a custom hook.
Start with what the user sees. The first test specifies the component's rendered output before a single line of JSX exists.
1// TDD session: CourseCard component
2// Test list:
3// [ ] renders course title as heading
4// [ ] renders instructor name
5// [ ] renders price formatted as currency
6// [ ] renders 'FREE' badge when price is 0
7// [ ] renders level badge
8// [ ] calls onEnrol when Enrol button is clicked
9// [ ] button is disabled when already enrolled
10
11import { render, screen } from '@testing-library/react';
12import userEvent from '@testing-library/user-event';
13import { CourseCard } from './CourseCard';
14
15const course = { id: '1', title: 'TypeScript Fundamentals', instructor: 'Alice', price: 49.99, level: 'BEGINNER' as const };
16
17// CYCLE 1: Red
18test('renders course title as a heading', () => {
19 render(<CourseCard course={course} onEnrol={jest.fn()} />);
20 expect(screen.getByRole('heading', { name: 'TypeScript Fundamentals' })).toBeInTheDocument();
21});
22// Green: export function CourseCard({ course }) { return <article><h2>{course.title}</h2></article>; }
23
24// CYCLE 2: Red
25test('renders instructor name', () => {
26 render(<CourseCard course={course} onEnrol={jest.fn()} />);
27 expect(screen.getByText('Alice')).toBeInTheDocument();
28});
29// Green: add <p>{course.instructor}</p>
30
31// CYCLE 3: Red — formatted price
32test('renders price formatted as USD currency', () => {
33 render(<CourseCard course={course} onEnrol={jest.fn()} />);
34 expect(screen.getByText('$49.99')).toBeInTheDocument();
35});
36// Green: add <span>{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(course.price)}</span>
37
38// CYCLE 4: Red — free badge
39test('renders FREE badge when price is 0', () => {
40 render(<CourseCard course={{ ...course, price: 0 }} onEnrol={jest.fn()} />);
41 expect(screen.getByText('FREE')).toBeInTheDocument();
42 expect(screen.queryByText('$0.00')).not.toBeInTheDocument();
43});
44// Green: conditional render based on price === 0
45
46// CYCLE 5: Red — click handler
47test('calls onEnrol with course id when Enrol button is clicked', async () => {
48 const onEnrol = jest.fn();
49 const user = userEvent.setup();
50 render(<CourseCard course={course} onEnrol={onEnrol} />);
51 await user.click(screen.getByRole('button', { name: 'Enrol Now' }));
52 expect(onEnrol).toHaveBeenCalledWith('1');
53});
54// Green: <button onClick={() => onEnrol(course.id)}>Enrol Now</button>
55
56// CYCLE 6: Red — disabled state
57test('shows Continue Learning button when already enrolled', () => {
58 render(<CourseCard course={course} onEnrol={jest.fn()} enrolled />);
59 expect(screen.getByRole('button', { name: 'Continue Learning' })).toBeInTheDocument();
60 expect(screen.queryByRole('button', { name: 'Enrol Now' })).not.toBeInTheDocument();
61});
62// Green: conditional button based on enrolled propForm TDD drives the validation rules through failing tests before the validation logic exists.
1// TDD session: LoginForm
2// Test list:
3// [ ] renders email and password inputs
4// [ ] submit button is disabled when fields are empty
5// [ ] submit button becomes enabled when both fields are filled
6// [ ] shows error message for invalid email format
7// [ ] calls onSubmit with email and password on valid submission
8// [ ] shows loading state while submitting
9// [ ] shows error when onSubmit rejects
10
11import { render, screen, waitFor } from '@testing-library/react';
12import userEvent from '@testing-library/user-event';
13import { LoginForm } from './LoginForm';
14
15// CYCLE 1: Red
16test('renders email and password inputs with a submit button', () => {
17 render(<LoginForm onSubmit={jest.fn()} />);
18 expect(screen.getByLabelText('Email')).toBeInTheDocument();
19 expect(screen.getByLabelText('Password')).toBeInTheDocument();
20 expect(screen.getByRole('button', { name: 'Sign in' })).toBeInTheDocument();
21});
22// Green: return the form JSX
23
24// CYCLE 2: Red
25test('submit button is disabled when fields are empty', () => {
26 render(<LoginForm onSubmit={jest.fn()} />);
27 expect(screen.getByRole('button', { name: 'Sign in' })).toBeDisabled();
28});
29// Green: disabled={!email || !password} state
30
31// CYCLE 3: Red
32test('submit button becomes enabled when both fields are filled', async () => {
33 const user = userEvent.setup();
34 render(<LoginForm onSubmit={jest.fn()} />);
35 await user.type(screen.getByLabelText('Email'), '[email protected]');
36 await user.type(screen.getByLabelText('Password'), 'Pass1!');
37 expect(screen.getByRole('button', { name: 'Sign in' })).toBeEnabled();
38});
39
40// CYCLE 4: Red — email validation
41test('shows error when email format is invalid after blur', async () => {
42 const user = userEvent.setup();
43 render(<LoginForm onSubmit={jest.fn()} />);
44 await user.type(screen.getByLabelText('Email'), 'notanemail');
45 await user.tab(); // blur the email field
46 expect(screen.getByRole('alert')).toHaveTextContent('Please enter a valid email');
47});
48
49// CYCLE 5: Red — submit
50test('calls onSubmit with email and password on valid submission', async () => {
51 const onSubmit = jest.fn().mockResolvedValue(undefined);
52 const user = userEvent.setup();
53 render(<LoginForm onSubmit={onSubmit} />);
54 await user.type(screen.getByLabelText('Email'), '[email protected]');
55 await user.type(screen.getByLabelText('Password'), 'Pass1!');
56 await user.click(screen.getByRole('button', { name: 'Sign in' }));
57 expect(onSubmit).toHaveBeenCalledWith('[email protected]', 'Pass1!');
58});
59
60// CYCLE 6: Red — error state
61test('shows error message when onSubmit rejects', async () => {
62 const onSubmit = jest.fn().mockRejectedValue(new Error('Invalid credentials'));
63 const user = userEvent.setup();
64 render(<LoginForm onSubmit={onSubmit} />);
65 await user.type(screen.getByLabelText('Email'), '[email protected]');
66 await user.type(screen.getByLabelText('Password'), 'Pass1!');
67 await user.click(screen.getByRole('button', { name: 'Sign in' }));
68 expect(await screen.findByRole('alert')).toHaveTextContent('Invalid credentials');
69});Components with loading/error/empty/populated states benefit from TDD because each state is a separate test cycle.
1// TDD session: CourseList component
2// Test list:
3// [ ] shows loading skeleton while fetching
4// [ ] shows error message when fetch fails
5// [ ] shows empty state when no courses returned
6// [ ] shows list of courses when data is available
7// [ ] each course item shows title and price
8
9import { render, screen, waitFor } from '@testing-library/react';
10import { CourseList } from './CourseList';
11
12// Mock the hook/API used by CourseList
13jest.mock('./useCourses');
14import { useCourses } from './useCourses';
15const mockUseCourses = useCourses as jest.Mock;
16
17beforeEach(() => jest.clearAllMocks());
18
19// CYCLE 1: Red — loading state
20test('shows loading skeleton while courses are being fetched', () => {
21 mockUseCourses.mockReturnValue({ courses: [], loading: true, error: null });
22 render(<CourseList />);
23 expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
24 expect(screen.queryByRole('article')).not.toBeInTheDocument();
25});
26// Green: if (loading) return <div data-testid='loading-skeleton'>...</div>
27
28// CYCLE 2: Red — error state
29test('shows error message when fetch fails', () => {
30 mockUseCourses.mockReturnValue({ courses: [], loading: false, error: 'Network error' });
31 render(<CourseList />);
32 expect(screen.getByRole('alert')).toHaveTextContent('Network error');
33});
34// Green: if (error) return <div role='alert'>{error}</div>
35
36// CYCLE 3: Red — empty state
37test('shows empty state when no courses are returned', () => {
38 mockUseCourses.mockReturnValue({ courses: [], loading: false, error: null });
39 render(<CourseList />);
40 expect(screen.getByText('No courses available')).toBeInTheDocument();
41 expect(screen.queryByRole('article')).not.toBeInTheDocument();
42});
43// Green: if (!loading && courses.length === 0) return <p>No courses available</p>
44
45// CYCLE 4: Red — populated state
46test('renders one article per course', () => {
47 mockUseCourses.mockReturnValue({
48 courses: [
49 { id: '1', title: 'TypeScript', price: 49 },
50 { id: '2', title: 'React', price: 59 },
51 ],
52 loading: false,
53 error: null,
54 });
55 render(<CourseList />);
56 expect(screen.getAllByRole('article')).toHaveLength(2);
57 expect(screen.getByText('TypeScript')).toBeInTheDocument();
58 expect(screen.getByText('React')).toBeInTheDocument();
59});
60// Green: return courses.map(c => <article key={c.id}><h2>{c.title}</h2></article>)Custom hooks encapsulate logic that is independently testable with renderHook, using the same TDD cycle.
1// TDD session: useDebounce hook
2import { renderHook, act } from '@testing-library/react';
3import { useDebounce } from './useDebounce';
4
5beforeEach(() => jest.useFakeTimers());
6afterEach(() => jest.useRealTimers());
7
8// CYCLE 1: Red — initial value
9test('returns the initial value immediately', () => {
10 const { result } = renderHook(() => useDebounce('hello', 300));
11 expect(result.current).toBe('hello');
12});
13// Green: function useDebounce<T>(value: T) { return value; } — too simple!
14
15// CYCLE 2: Red — value does not update before delay
16test('does not update the debounced value before the delay elapses', () => {
17 const { result, rerender } = renderHook(
18 ({ value }) => useDebounce(value, 300),
19 { initialProps: { value: 'initial' } }
20 );
21
22 rerender({ value: 'updated' });
23 jest.advanceTimersByTime(299);
24
25 expect(result.current).toBe('initial'); // still initial — delay not elapsed
26});
27// Green: useEffect with setTimeout, setState
28
29// CYCLE 3: Red — value updates after delay
30test('updates the debounced value after the delay elapses', () => {
31 const { result, rerender } = renderHook(
32 ({ value }) => useDebounce(value, 300),
33 { initialProps: { value: 'initial' } }
34 );
35
36 rerender({ value: 'updated' });
37 act(() => jest.advanceTimersByTime(300));
38
39 expect(result.current).toBe('updated');
40});
41
42// CYCLE 4: Red — timer resets on new value
43test('resets the timer when the value changes before delay elapses', () => {
44 const { result, rerender } = renderHook(
45 ({ value }) => useDebounce(value, 300),
46 { initialProps: { value: 'initial' } }
47 );
48
49 rerender({ value: 'first' });
50 act(() => jest.advanceTimersByTime(200));
51 rerender({ value: 'second' }); // new value resets timer
52 act(() => jest.advanceTimersByTime(300));
53
54 expect(result.current).toBe('second'); // not 'first'
55});
56
57// Final implementation (emerged from 4 cycles):
58export function useDebounce<T>(value: T, delay: number): T {
59 const [debounced, setDebounced] = React.useState<T>(value);
60
61 React.useEffect(() => {
62 const timer = setTimeout(() => setDebounced(value), delay);
63 return () => clearTimeout(timer); // cleanup resets timer on new value
64 }, [value, delay]);
65
66 return debounced;
67}
68// 7 lines. All 4 tests pass.The biggest challenge in React TDD is the temptation to write the JSX first and 'see what it looks like'. Resist this — write the test first.
1// ANTI-PATTERN: write JSX first, then write tests to match it
2export function PriceDisplay({ price }: { price: number }) {
3 return (
4 <div className="price-container">
5 {price === 0 ? (
6 <span className="badge badge-free">FREE</span>
7 ) : (
8 <span className="price-amount">
9 ${price.toFixed(2)}
10 </span>
11 )}
12 </div>
13 );
14}
15
16// Tests written after: test what the code does, not what it should do
17test('has class badge-free when price is 0', () => {
18 render(<PriceDisplay price={0} />);
19 expect(document.querySelector('.badge-free')).toBeInTheDocument();
20});
21// This test is coupled to the implementation (CSS class names)
22// If you refactor to a different component library, the test breaks
23// Even though the user experience is unchanged
24
25// CORRECT: test first, specifies user-observable behaviour
26test('shows FREE text when price is 0', () => {
27 render(<PriceDisplay price={0} />);
28 expect(screen.getByText('FREE')).toBeInTheDocument();
29});
30
31test('shows dollar-formatted price for non-zero price', () => {
32 render(<PriceDisplay price={49.99} />);
33 expect(screen.getByText('$49.99')).toBeInTheDocument();
34});
35
36// These tests survive:
37// - CSS class name changes
38// - Component library migrations (Bootstrap → Tailwind)
39// - DOM structure refactoring
40// - Server components refactoring
41// Because they test what users see, not how it is implementedA complete TDD session building a SearchInput component with debounced onChange, loading indicator, and clear button through 8 test cycles.
1// TDD session: SearchInput component
2// Test list:
3// [ ] renders search input with placeholder
4// [ ] calls onChange with input value (debounced)
5// [ ] shows clear button when input has text
6// [ ] clear button resets input to empty
7// [ ] shows loading indicator when isLoading is true
8// [ ] does not call onChange while delay has not elapsed
9
10import { render, screen, act } from '@testing-library/react';
11import userEvent from '@testing-library/user-event';
12import { SearchInput } from './SearchInput';
13
14beforeEach(() => jest.useFakeTimers());
15afterEach(() => jest.useRealTimers());
16
17test('renders a search input with the given placeholder', () => {
18 render(<SearchInput onChange={jest.fn()} placeholder="Search courses..." />);
19 expect(screen.getByPlaceholderText('Search courses...')).toBeInTheDocument();
20});
21
22test('calls onChange with typed value after 300ms debounce', async () => {
23 const onChange = jest.fn();
24 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
25 render(<SearchInput onChange={onChange} debounceMs={300} />);
26 await user.type(screen.getByRole('searchbox'), 'TypeScript');
27 act(() => jest.advanceTimersByTime(300));
28 expect(onChange).toHaveBeenCalledWith('TypeScript');
29});
30
31test('does not call onChange before debounce delay elapses', async () => {
32 const onChange = jest.fn();
33 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
34 render(<SearchInput onChange={onChange} debounceMs={300} />);
35 await user.type(screen.getByRole('searchbox'), 'TypeScript');
36 act(() => jest.advanceTimersByTime(299));
37 expect(onChange).not.toHaveBeenCalled();
38});
39
40test('shows clear button when input has content', async () => {
41 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
42 render(<SearchInput onChange={jest.fn()} />);
43 await user.type(screen.getByRole('searchbox'), 'React');
44 expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument();
45});
46
47test('does not show clear button when input is empty', () => {
48 render(<SearchInput onChange={jest.fn()} />);
49 expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument();
50});
51
52test('clicking clear button resets input and calls onChange with empty string', async () => {
53 const onChange = jest.fn();
54 const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
55 render(<SearchInput onChange={onChange} debounceMs={300} />);
56 await user.type(screen.getByRole('searchbox'), 'TypeScript');
57 await user.click(screen.getByRole('button', { name: 'Clear search' }));
58 expect(screen.getByRole('searchbox')).toHaveValue('');
59 expect(onChange).toHaveBeenLastCalledWith('');
60});
61
62test('shows loading indicator when isLoading is true', () => {
63 render(<SearchInput onChange={jest.fn()} isLoading />);
64 expect(screen.getByRole('status', { name: /searching/i })).toBeInTheDocument();
65});
66
67// FINAL IMPLEMENTATION (emerged from 7 cycles):
68import React, { useState, useCallback } from 'react';
69
70interface SearchInputProps {
71 onChange: (value: string) => void;
72 placeholder?: string;
73 debounceMs?: number;
74 isLoading?: boolean;
75}
76
77export function SearchInput({ onChange, placeholder = 'Search...', debounceMs = 300, isLoading = false }: SearchInputProps) {
78 const [value, setValue] = useState('');
79 const [timer, setTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
80
81 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
82 const newValue = e.target.value;
83 setValue(newValue);
84 if (timer) clearTimeout(timer);
85 const t = setTimeout(() => onChange(newValue), debounceMs);
86 setTimer(t);
87 };
88
89 const handleClear = () => {
90 setValue('');
91 if (timer) clearTimeout(timer);
92 onChange('');
93 };
94
95 return (
96 <div>
97 <input type="search" value={value} onChange={handleChange} placeholder={placeholder} />
98 {value && <button onClick={handleClear} aria-label="Clear search">×</button>}
99 {isLoading && <span role="status" aria-label="Searching">...</span>}
100 </div>
101 );
102}Each of the 7 tests drove one piece of the component: placeholder renders, onChange callback, debounce delay, clear button visibility, clear button functionality, and loading indicator. The debounce implementation (using clearTimeout and setTimeout in state) emerged from the 'does not call onChange before delay' test — without that test, a developer might have implemented a simpler (but broken) version. The tests written first produced a component with all the right behaviours and no implementation speculation.
The next lesson covers TDD for Vue and Angular components: applying the same Red-Green-Refactor discipline using Vue Test Utils and Angular TestBed respectively.
Your progress is saved in this browser, on this device only.