Day 6: Testing React Components
What You'll Learn Today
- What React Testing Library is and its guiding philosophy
- Rendering components with
renderand querying the DOM withscreen - Simulating user interactions with
fireEventanduserEvent - Understanding
getBy/queryBy/findByquery types - Testing forms and controlled inputs
- Testing custom hooks with
renderHook - Building a complete test suite for a Todo list component
Why React Testing Library?
Traditional UI testing focused on testing component internals β state values, lifecycle methods, and implementation details. React Testing Library (RTL) takes a different approach: test your components the way users interact with them.
"The more your tests resemble the way your software is used, the more confidence they can give you." β Kent C. Dodds, creator of Testing Library
flowchart LR
subgraph Old["Enzyme (Implementation-focused)"]
A1["Access component state"]
A2["Check lifecycle methods"]
A3["Shallow rendering"]
end
subgraph New["RTL (User-focused)"]
B1["Query by text/role"]
B2["Simulate clicks/typing"]
B3["Assert visible output"]
end
Old -->|"Shift"| New
style Old fill:#ef4444,color:#fff
style New fill:#22c55e,color:#fff
| Aspect | Enzyme | React Testing Library |
|---|---|---|
| Philosophy | Test internals | Test behavior |
| Queries | By component/prop | By role/text/label |
| State access | Direct | Not available |
| Refactor resilience | Low | High |
| Recommended by React | No | Yes |
If you're new to React, check out "Learn React in 10 Days" in this series for a comprehensive introduction to components, state, and hooks.
Setting Up
Install the required packages:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
Add the custom matchers to your test setup:
// jest.setup.js
require('@testing-library/jest-dom');
TypeScript version:
// jest.setup.ts
import '@testing-library/jest-dom';
Then reference it in your Jest config:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterSetup: ['./jest.setup.js'],
};
Rendering and Querying: render & screen
The two most fundamental APIs in RTL are render and screen.
// Greeting.jsx
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
TypeScript version:
// Greeting.tsx
interface GreetingProps {
name: string;
}
function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
// Greeting.test.jsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('renders greeting with name', () => {
render(<Greeting name="Alice" />);
// Query the rendered DOM
const heading = screen.getByText('Hello, Alice!');
expect(heading).toBeInTheDocument();
expect(heading.tagName).toBe('H1');
});
TypeScript test version:
// Greeting.test.tsx
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('renders greeting with name', () => {
render(<Greeting name="Alice" />);
const heading = screen.getByText('Hello, Alice!');
expect(heading).toBeInTheDocument();
expect(heading.tagName).toBe('H1');
});
flowchart TB
subgraph Flow["render & screen Flow"]
R["render(<Component />)"] --> VDOM["Creates virtual DOM"]
VDOM --> DOM["Attaches to jsdom"]
DOM --> S["screen.getByXxx()"]
S --> ASSERT["expect(...).toBeInTheDocument()"]
end
style Flow fill:#3b82f6,color:#fff
Common Custom Matchers from jest-dom
| Matcher | Purpose |
|---|---|
toBeInTheDocument() |
Element exists in the DOM |
toBeVisible() |
Element is visible to the user |
toHaveTextContent(text) |
Element contains text |
toHaveAttribute(attr, value) |
Element has an attribute |
toBeDisabled() |
Element is disabled |
toHaveClass(className) |
Element has a CSS class |
toHaveValue(value) |
Input has a value |
Query Types: getBy / queryBy / findBy
RTL provides three variants of every query, each suited for different scenarios.
| Variant | Returns | Throws on no match | Use case |
|---|---|---|---|
getBy |
Element | Yes | Element should exist |
queryBy |
Element or null |
No | Assert element does NOT exist |
findBy |
Promise<Element> | Yes (rejects) | Wait for async element |
// StatusMessage.jsx
import { useState, useEffect } from 'react';
function StatusMessage({ userId }) {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}/status`)
.then(res => res.json())
.then(data => {
setStatus(data.status);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <p>Status: {status}</p>;
}
export default StatusMessage;
TypeScript version:
// StatusMessage.tsx
import { useState, useEffect } from 'react';
interface StatusMessageProps {
userId: number;
}
function StatusMessage({ userId }: StatusMessageProps) {
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}/status`)
.then(res => res.json())
.then(data => {
setStatus(data.status);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading...</p>;
return <p>Status: {status}</p>;
}
export default StatusMessage;
// StatusMessage.test.jsx
import { render, screen } from '@testing-library/react';
import StatusMessage from './StatusMessage';
beforeEach(() => {
// Mock the fetch API
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ status: 'online' }),
})
);
});
test('shows loading then status', async () => {
render(<StatusMessage userId={1} />);
// getBy β element should exist right now
expect(screen.getByText('Loading...')).toBeInTheDocument();
// findBy β wait for async update
const statusEl = await screen.findByText('Status: online');
expect(statusEl).toBeInTheDocument();
// queryBy β assert element is gone
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
flowchart TB
subgraph Queries["Query Decision Tree"]
Q["Need to find an element?"]
Q -->|"Must exist now"| GET["getBy\nThrows if missing"]
Q -->|"Might not exist"| QUERY["queryBy\nReturns null"]
Q -->|"Will appear async"| FIND["findBy\nAwaits appearance"]
end
style GET fill:#22c55e,color:#fff
style QUERY fill:#f59e0b,color:#fff
style FIND fill:#8b5cf6,color:#fff
Query Suffixes
Each variant has multiple query methods based on how you search:
| Query | Searches by |
|---|---|
ByRole |
ARIA role (button, heading, textbox) |
ByLabelText |
Associated label text |
ByPlaceholderText |
Placeholder attribute |
ByText |
Visible text content |
ByDisplayValue |
Current input value |
ByAltText |
Image alt text |
ByTitle |
Title attribute |
ByTestId |
data-testid attribute |
Priority: Prefer
ByRole>ByLabelText>ByText>ByTestId. UseByTestIdonly as a last resort.
User Interactions: fireEvent vs userEvent
fireEvent β Basic Events
fireEvent dispatches DOM events directly. It is simple but does not fully simulate real user behavior.
// Counter.jsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
export default Counter;
// Counter.test.jsx (fireEvent)
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('increments count on button click', () => {
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
fireEvent.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
userEvent β Realistic Interactions
userEvent simulates full user interaction sequences (focus, keydown, keyup, input, etc.), giving more realistic test behavior.
// Counter.test.jsx (userEvent)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
TypeScript test version:
// Counter.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
test('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: 'Increment' });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
| Feature | fireEvent | userEvent |
|---|---|---|
| Event dispatch | Single event | Full interaction sequence |
| Focus handling | Manual | Automatic |
| Typing | Sets value directly | Types character by character |
| Realism | Low | High |
| Async | No | Yes (returns Promise) |
Recommendation: Always prefer
userEventoverfireEvent. It catches more real-world bugs.
Testing Forms
Forms involve multiple interactive elements β inputs, selects, checkboxes, and submission. Here is a complete example.
// LoginForm.jsx
import { useState } from 'react';
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!email || !password) {
setError('All fields are required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setError('');
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Sign In</button>
</form>
);
}
export default LoginForm;
TypeScript version:
// LoginForm.tsx
import { useState, FormEvent } from 'react';
interface LoginData {
email: string;
password: string;
}
interface LoginFormProps {
onSubmit: (data: LoginData) => void;
}
function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!email || !password) {
setError('All fields are required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setError('');
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p role="alert">{error}</p>}
<button type="submit">Sign In</button>
</form>
);
}
export default LoginForm;
// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
const mockSubmit = jest.fn();
beforeEach(() => {
mockSubmit.mockClear();
});
test('submits form with valid data', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
// Fill in the form using labels
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'securepass');
// Submit the form
await user.click(screen.getByRole('button', { name: 'Sign In' }));
// Verify submission
expect(mockSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'securepass',
});
expect(mockSubmit).toHaveBeenCalledTimes(1);
});
test('shows error when fields are empty', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'All fields are required'
);
expect(mockSubmit).not.toHaveBeenCalled();
});
test('shows error for short password', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'short');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByRole('alert')).toHaveTextContent(
'Password must be at least 8 characters'
);
expect(mockSubmit).not.toHaveBeenCalled();
});
});
flowchart TB
subgraph FormTest["Form Testing Strategy"]
FILL["Fill inputs\n(user.type)"]
SELECT["Select options\n(user.selectOptions)"]
CHECK["Toggle checkboxes\n(user.click)"]
SUBMIT["Click submit\n(user.click)"]
VERIFY["Assert callback / error"]
end
FILL --> SUBMIT
SELECT --> SUBMIT
CHECK --> SUBMIT
SUBMIT --> VERIFY
style FormTest fill:#8b5cf6,color:#fff
Testing Custom Hooks with renderHook
Custom hooks cannot be called outside of a component. The renderHook utility from RTL solves this by wrapping the hook in a test component.
// useCounter.js
import { useState, useCallback } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
export default useCounter;
TypeScript version:
// useCounter.ts
import { useState, useCallback } from 'react';
interface UseCounterReturn {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
function useCounter(initialValue: number = 0): UseCounterReturn {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initialValue), [initialValue]);
return { count, increment, decrement, reset };
}
export default useCounter;
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('starts with initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('defaults to 0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('increments the count', () => {
const { result } = renderHook(() => useCounter());
// Wrap state updates in act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements the count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
TypeScript test version:
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('starts with initial value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments the count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Key point: Always wrap state-updating calls in
act()when usingrenderHook. This ensures React processes all updates before you assert.
Practical Example: Todo List Component
Let's bring everything together by building and testing a full Todo list component.
The Component
// TodoList.jsx
import { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
const text = input.trim();
if (!text) return;
setTodos([...todos, { id: Date.now(), text, completed: false }]);
setInput('');
};
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h1>Todo List</h1>
<div>
<label htmlFor="new-todo">New Todo</label>
<input
id="new-todo"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="What needs to be done?"
/>
<button onClick={addTodo}>Add</button>
</div>
{todos.length === 0 ? (
<p>No todos yet. Add one above!</p>
) : (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
</label>
<button onClick={() => deleteTodo(todo.id)} aria-label={`Delete ${todo.text}`}>
Delete
</button>
</li>
))}
</ul>
)}
<p>{todos.filter(t => !t.completed).length} items remaining</p>
</div>
);
}
export default TodoList;
TypeScript version:
// TodoList.tsx
import { useState } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState('');
const addTodo = () => {
const text = input.trim();
if (!text) return;
setTodos([...todos, { id: Date.now(), text, completed: false }]);
setInput('');
};
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
<h1>Todo List</h1>
<div>
<label htmlFor="new-todo">New Todo</label>
<input
id="new-todo"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="What needs to be done?"
/>
<button onClick={addTodo}>Add</button>
</div>
{todos.length === 0 ? (
<p>No todos yet. Add one above!</p>
) : (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
</label>
<button onClick={() => deleteTodo(todo.id)} aria-label={`Delete ${todo.text}`}>
Delete
</button>
</li>
))}
</ul>
)}
<p>{todos.filter(t => !t.completed).length} items remaining</p>
</div>
);
}
export default TodoList;
The Tests
// TodoList.test.jsx
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from './TodoList';
describe('TodoList', () => {
test('renders empty state', () => {
render(<TodoList />);
expect(screen.getByText('Todo List')).toBeInTheDocument();
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
expect(screen.getByText('0 items remaining')).toBeInTheDocument();
});
test('adds a new todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Type into the input
await user.type(screen.getByLabelText('New Todo'), 'Buy groceries');
await user.click(screen.getByRole('button', { name: 'Add' }));
// Verify the todo appears
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.getByText('1 items remaining')).toBeInTheDocument();
// Input should be cleared
expect(screen.getByLabelText('New Todo')).toHaveValue('');
});
test('does not add empty todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
});
test('adds multiple todos', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add first todo
await user.type(screen.getByLabelText('New Todo'), 'Task 1');
await user.click(screen.getByRole('button', { name: 'Add' }));
// Add second todo
await user.type(screen.getByLabelText('New Todo'), 'Task 2');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.getByText('Task 2')).toBeInTheDocument();
expect(screen.getByText('2 items remaining')).toBeInTheDocument();
});
test('toggles a todo as completed', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add a todo
await user.type(screen.getByLabelText('New Todo'), 'Learn Jest');
await user.click(screen.getByRole('button', { name: 'Add' }));
// Toggle completion
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(screen.getByText('0 items remaining')).toBeInTheDocument();
// Toggle back
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
expect(screen.getByText('1 items remaining')).toBeInTheDocument();
});
test('deletes a todo', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add a todo
await user.type(screen.getByLabelText('New Todo'), 'Temporary task');
await user.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByText('Temporary task')).toBeInTheDocument();
// Delete it
await user.click(screen.getByRole('button', { name: 'Delete Temporary task' }));
// Verify it is gone
expect(screen.queryByText('Temporary task')).not.toBeInTheDocument();
expect(screen.getByText('No todos yet. Add one above!')).toBeInTheDocument();
});
test('handles a full workflow', async () => {
const user = userEvent.setup();
render(<TodoList />);
// Add three todos
const input = screen.getByLabelText('New Todo');
for (const task of ['Write tests', 'Review PR', 'Deploy app']) {
await user.type(input, task);
await user.click(screen.getByRole('button', { name: 'Add' }));
}
expect(screen.getByText('3 items remaining')).toBeInTheDocument();
// Complete one
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
expect(screen.getByText('2 items remaining')).toBeInTheDocument();
// Delete another
await user.click(screen.getByRole('button', { name: 'Delete Review PR' }));
expect(screen.queryByText('Review PR')).not.toBeInTheDocument();
expect(screen.getByText('1 items remaining')).toBeInTheDocument();
});
});
flowchart TB
subgraph TodoTests["Todo List Test Coverage"]
E["Empty state"]
ADD["Add todo"]
EMPTY["Reject empty input"]
MULTI["Multiple todos"]
TOGGLE["Toggle completion"]
DEL["Delete todo"]
FLOW["Full workflow"]
end
E --> ADD --> MULTI
ADD --> EMPTY
MULTI --> TOGGLE
MULTI --> DEL
TOGGLE --> FLOW
DEL --> FLOW
style TodoTests fill:#3b82f6,color:#fff
Summary
| Concept | Description |
|---|---|
| React Testing Library | Tests components from the user's perspective |
render |
Renders a component into a virtual DOM |
screen |
Provides queries to find elements |
getBy |
Finds an element (throws if missing) |
queryBy |
Finds an element (returns null if missing) |
findBy |
Waits for an element to appear (async) |
fireEvent |
Dispatches single DOM events |
userEvent |
Simulates realistic user interactions |
renderHook |
Tests custom hooks in isolation |
act |
Wraps state updates in hook tests |
Key Takeaways
- Test behavior, not implementation β query by role and text, not by class names or component internals
- Use
userEventoverfireEventfor more realistic interaction simulation - Choose the right query variant:
getByfor present elements,queryByfor absent ones,findByfor async - Wrap hook state updates in
act()when usingrenderHook - Structure tests around user workflows for maximum confidence
Exercises
Exercise 1: Basics
Create a Toggle component that switches between "ON" and "OFF" when a button is clicked. Write tests verifying the initial state and toggling behavior.
function Toggle() {
// Implement: show "OFF" by default, toggle to "ON" on click
}
Exercise 2: Form Testing
Build a SignupForm component with fields for name, email, and password. Write tests that:
- Submit successfully with valid data
- Show an error when name is empty
- Show an error when password is less than 6 characters
Exercise 3: Async Component
Create a UserProfile component that fetches user data from /api/users/:id and displays the name and email. Write tests using findBy queries and mocked fetch.
Challenge
Write a custom hook useLocalStorage(key, initialValue) that persists state to localStorage. Test it with renderHook, verifying:
- It returns the initial value when localStorage is empty
- It reads an existing value from localStorage
- It updates localStorage when the value changes
References
- React Testing Library - Introduction
- Testing Library - Queries
- Testing Library - User Event
- Testing Library - renderHook
- Jest - jest-dom Matchers
- Kent C. Dodds - Common Mistakes with RTL
Next up: In Day 7, we'll explore Snapshot Testing β how to capture and compare component output automatically, when to use snapshots vs explicit assertions, and best practices for keeping snapshots maintainable!