Writing Your First Unit Tests (Part 2) #149
📖 Introduction
In Writing Your First Unit Tests (Part 1), we laid the groundwork for unit testing React components by understanding test structure, basic RTL queries, and assertions. This second part builds on that foundation by exploring how to test components that manage their own state, how to use more advanced RTL queries, and how to leverage Jest's mocking capabilities for simple functions. We'll also touch upon testing components that render lists.
📚 Prerequisites
Before we begin, ensure you have:
- Completed Part 1 and understand basic RTL queries (
getBy*,queryBy*) andjest-dommatchers. - A working Jest and React Testing Library setup.
- Familiarity with React functional components and the
useStateHook.
🎯 Article Outline: What You'll Master
In this article, you will learn:
- ✅ Testing Components with Internal State (
useState): Verifying state changes through UI updates. - ✅ More RTL Query Techniques: Using
*AllBy*queries, and understanding query options. - ✅ Testing List Rendering: Asserting on multiple items rendered from an array.
- ✅ Introduction to Jest Mocks (Simple Functions): Using
jest.fn()to mock callback props. - ✅ Testing Event Handler Props: Verifying that callback props are called correctly.
🧠 Section 1: Testing Components with Internal State (useState)
Many components manage their own internal state using the useState Hook. We don't test the state variable directly (that's an implementation detail). Instead, we test the effects of state changes on the rendered UI or component behavior.
Example: A Simple Counter Component
// src/components/Counter.jsx
import React, { useState } from 'react';
const Counter = ({ initialCount = 0 }) => {
const [count, setCount] = useState(initialCount);
return (
<div>
<h2>Counter</h2>
<p data-testid="count-display">Current count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)} disabled={count === 0}>Decrement</button>
<button onClick={() => setCount(initialCount)}>Reset</button>
</div>
);
};
export default Counter;
Test File for Counter.jsx:
// src/components/Counter.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; // For more realistic user interactions
import Counter from './Counter';
describe('Counter Component', () => {
test('renders with initial count of 0 by default', () => {
render(<Counter />);
// Using getByTestId for the count display for precision
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 0');
});
test('renders with a given initial count', () => {
render(<Counter initialCount={5} />);
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 5');
});
test('increments count when increment button is clicked', async () => {
const user = userEvent.setup(); // Setup user-event
render(<Counter />);
const incrementButton = screen.getByRole('button', { name: /increment/i });
await user.click(incrementButton); // userEvent actions are often async
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 1');
await user.click(incrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 2');
});
test('decrements count when decrement button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={3} />);
const decrementButton = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 2');
});
test('decrement button is disabled when count is 0', () => {
render(<Counter />); // Initial count is 0
const decrementButton = screen.getByRole('button', { name: /decrement/i });
expect(decrementButton).toBeDisabled();
});
test('decrement button becomes enabled after incrementing from 0', async () => {
const user = userEvent.setup();
render(<Counter />);
const decrementButton = screen.getByRole('button', { name: /decrement/i });
const incrementButton = screen.getByRole('button', { name: /increment/i });
expect(decrementButton).toBeDisabled(); // Initially disabled
await user.click(incrementButton); // Count becomes 1
expect(decrementButton).not.toBeDisabled(); // Should now be enabled
});
test('resets count to initial value when reset button is clicked', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
// Click increment a few times
const incrementButton = screen.getByRole('button', { name: /increment/i });
await user.click(incrementButton); // count = 6
await user.click(incrementButton); // count = 7
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 7');
const resetButton = screen.getByRole('button', { name: /reset/i });
await user.click(resetButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('Current count: 5');
});
});
Key Points:
@testing-library/user-event: We useuserEvent.click()for simulating clicks.user-eventprovides more realistic event simulation than RTL'sfireEvent(e.g., it handles hover/focus states that might affect button enabled/disabled status).userEventactions are generally asynchronous, so useasync/await.- Testing State via UI: We don't access
countstate directly. We click buttons and then assert that the text content of the "count-display" element updates as expected. - Testing Disabled State: We check the
disabledattribute of the decrement button. - Arrange, Act, Assert: Each test follows this pattern.
💻 Section 2: More RTL Query Techniques - *AllBy* and Query Options
2.1 - *AllBy* Queries
Sometimes, you expect multiple elements to match a query. For this, use the *AllBy* variants (e.g., getAllByRole, queryAllByText, findAllByRole). These return an array of matching DOM nodes.
getAllBy*: Returns an array of one or more elements. Throws an error if no elements are found.queryAllBy*: Returns an array of elements (can be empty if none are found). Does not throw if no elements are found.findAllBy*: Returns a Promise that resolves to an array of elements (can be empty). Rejects if elements aren't found after a timeout.
Example: Testing a List
// src/components/ItemList.jsx
import React from 'react';
const ItemList = ({ items }) => {
if (!items || items.length === 0) {
return <p>No items available.</p>;
}
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
};
export default ItemList;
// src/components/ItemList.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import ItemList from './ItemList';
describe('ItemList Component', () => {
test('renders "No items available." when items prop is empty or not provided', () => {
render(<ItemList items={[]} />);
expect(screen.getByText('No items available.')).toBeInTheDocument();
render(<ItemList />); // Test without items prop
expect(screen.getByText('No items available.')).toBeInTheDocument();
});
test('renders a list of items correctly', () => {
const mockItems = [
{ id: '1', name: 'Item Alpha' },
{ id: '2', name: 'Item Beta' },
{ id: '3', name: 'Item Gamma' },
];
render(<ItemList items={mockItems} />);
// Use getAllByRole to get all list items
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(mockItems.length); // Check number of items
// Check the content of each item
mockItems.forEach((item, index) => {
expect(listItems[index]).toHaveTextContent(item.name);
});
// Alternative: Check text content directly
expect(screen.getByText('Item Alpha')).toBeInTheDocument();
expect(screen.getByText('Item Beta')).toBeInTheDocument();
expect(screen.getByText('Item Gamma')).toBeInTheDocument();
});
});
2.2 - Query Options
Many query methods accept an options object as a second argument to refine the search.
name(forgetByRole): Matches elements by their "accessible name" (e.g., button text, aria-label). Can be a string or regex.screen.getByRole('button', { name: /submit form/i }); // Case-insensitive regexlevel(forgetByRole('heading', ...)): Specifies the heading level (1 for<h1>, 2 for<h2>, etc.).screen.getByRole('heading', { level: 1, name: 'Main Title' });exact(for text queries likegetByText): Defaults totrue. Iffalse, allows partial matches (though using regex is often more precise).screen.getByText('Welcome', { exact: false }); // Matches "Welcome User"hidden(for most queries): Defaults tofalse. Iftrue, queries will also find elements that are hidden via CSS (e.g.,display: noneorvisibility: hidden). Use this carefully, as users typically don't interact with hidden elements.// Find a hidden error message that might become visible later
const hiddenError = screen.getByText('Invalid input', { hidden: true });selector(forgetByText,queryByText,findByText): Restricts the search to elements matching a CSS selector. Use sparingly as it can make tests more brittle.screen.getByText('Save', { selector: 'button.primary' });
Always refer to the RTL documentation for the specific options available for each query type.
🛠️ Section 3: Introduction to Jest Mocks (Simple Functions)
Often, components receive functions as props (callbacks) that are called when certain events occur (e.g., onSubmit, onClick, onClose). When unit testing such a component, you don't want to test the implementation of that callback prop (that's the responsibility of the parent component or wherever the function is defined). Instead, you want to verify:
- That the callback prop is called when the expected event happens.
- That it's called with the correct arguments (if any).
Jest provides mock functions (often called "spies") for this purpose using jest.fn().
jest.fn():
- Creates a special mock function that records information about its calls (how many times it was called, what arguments it was called with, etc.).
- You can provide a mock implementation if the function needs to return a value or have side effects during the test.
Example: Testing a Button with an onClick Prop
// src/components/ClickableButton.jsx
import React from 'react';
const ClickableButton = ({ onClick, children, ...props }) => {
return (
<button onClick={() => onClick('some-data')} {...props}>
{children}
</button>
);
};
export default ClickableButton;
// src/components/ClickableButton.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ClickableButton from './ClickableButton';
describe('ClickableButton Component', () => {
test('calls the onClick prop with specific data when clicked', async () => {
const user = userEvent.setup();
// 1. Arrange: Create a Jest mock function
const mockOnClick = jest.fn(); // This is our spy
render(
<ClickableButton onClick={mockOnClick} data-testid="my-button">
Click Me
</ClickableButton>
);
const button = screen.getByTestId('my-button');
// 2. Act: Simulate a user click
await user.click(button);
// 3. Assert: Check if the mock function was called
expect(mockOnClick).toHaveBeenCalledTimes(1); // Was it called once?
expect(mockOnClick).toHaveBeenCalledWith('some-data'); // Was it called with the expected argument?
});
test('renders children correctly', () => {
const mockOnClick = jest.fn();
const buttonText = 'Submit Form';
render(<ClickableButton onClick={mockOnClick}>{buttonText}</ClickableButton>);
expect(screen.getByRole('button', { name: buttonText })).toBeInTheDocument();
});
});
Jest Mock Matchers:
expect(mockFn).toHaveBeenCalled(): Asserts the mock function was called at least once.expect(mockFn).toHaveBeenCalledTimes(number): Asserts how many times it was called.expect(mockFn).toHaveBeenCalledWith(arg1, arg2, ...): Asserts it was called with specific arguments.expect(mockFn).toHaveBeenLastCalledWith(arg1, arg2, ...): Checks the arguments of the most recent call.expect(mockFn).toHaveReturned()(if the mock has an implementation that returns).expect(mockFn).toHaveReturnedWith(value).
Mocking is a vast topic in Jest. We've only scratched the surface with jest.fn(). Jest also allows mocking entire modules, timers, and more, which we might touch upon in later advanced testing articles.
💡 Conclusion & Key Takeaways (Part 2)
In this article, we've expanded our unit testing capabilities by learning to test components with internal state, utilizing more advanced RTL queries like *AllBy*, and introducing Jest's jest.fn() for mocking and asserting on callback props. These techniques allow us to write more comprehensive and meaningful tests for a wider variety of components.
Key Takeaways:
- Test component state by observing its effects on the rendered UI or behavior, not by accessing state variables directly.
- Use
@testing-library/user-eventfor more realistic simulation of user interactions. *AllBy*queries are useful for asserting on multiple elements, like items in a list.- Query options (e.g.,
name,level,hidden) help refine element selection. jest.fn()creates mock functions to spy on callback props, allowing you to verify if and how they are called.
➡️ Next Steps
We've now covered the basics of testing component rendering and simple state. The next crucial aspect is testing how users interact with these components in more complex ways. In the upcoming article, "Testing User Interactions", we'll dive deeper into using @testing-library/user-event to simulate various user actions like typing, selecting options, and more complex event sequences.
Keep practicing these fundamentals, and you'll build a strong testing foundation!
glossary
userEvent(@testing-library/user-event): A library that simulates user interactions more realistically thanfireEvent, recommended for most interaction tests.*AllBy*Queries (RTL): Query variants (e.g.,getAllByRole) that return an array of all matching DOM nodes.- Jest Mock Function (
jest.fn()): A special function provided by Jest that records calls made to it, allowing assertions on its usage (times called, arguments, etc.). Also known as a "spy." - Callback Prop: A function passed as a prop to a component, intended to be called by that component in response to an event or condition.
Further Reading
- React Testing Library: Queries Cheatsheet
@testing-library/user-eventDocumentation- Jest: Mock Functions
- Testing React Components with internal state - Robin Wieruch