Writing Your First Unit Tests (Part 1) #148
📖 Introduction
With our testing environment configured using Jest and React Testing Library (RTL), as covered in Setting up Jest and React Testing Library (Part 2), we're now ready to write some actual tests! This article, Part 1 of writing unit tests, will focus on the fundamentals: understanding test structure, RTL's query methods, basic assertions, and testing simple component rendering. We'll build upon the Greeting.test.js example from the previous article.
📚 Prerequisites
Before we begin, ensure you have:
- A working Jest and React Testing Library setup.
- The simple
Greeting.jsxcomponent andGreeting.test.jsfile from Article 147. - Basic understanding of JavaScript and React functional components.
🎯 Article Outline: What You'll Master
In this article, you will learn:
- ✅ Anatomy of a Test File:
describe,test/it,beforeEach,afterEach. - ✅ The AAA Pattern (Arrange, Act, Assert): A common structure for writing tests.
- ✅ RTL Queries In-Depth (Part 1): Understanding
getBy*,queryBy*, andfindBy*variants. - ✅ Common
screenQueries:getByText,getByRole,getByLabelText,getByTestId. - ✅ Basic Assertions with
expectandjest-domMatchers:toBeInTheDocument,toHaveTextContent,toBeVisible. - ✅ Testing Component Rendering Based on Props.
🧠 Section 1: Anatomy of a Test File (*.test.js)
Let's revisit the structure of a typical Jest test file:
// src/components/MyComponent.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import MyComponent from './MyComponent';
// 1. describe: Groups related tests into a "test suite"
describe('MyComponent Suite', () => {
// 2. beforeEach: Runs before each test case in this suite
beforeEach(() => {
// console.log('Setting up before a test...');
// Useful for resetting mocks, clearing data, etc.
});
// 3. afterEach: Runs after each test case in this suite
afterEach(() => {
// console.log('Cleaning up after a test...');
// Useful for cleanup tasks, e.g., jest.clearAllMocks();
});
// 4. test (or it): Defines an individual test case
test('should render correctly with default props', () => {
// Test logic here
render(<MyComponent />);
expect(screen.getByText('Default Text')).toBeInTheDocument();
});
it('should display the correct message when a specific prop is passed', () => {
// 'it' is an alias for 'test'
render(<MyComponent message="Custom Message" />);
expect(screen.getByText('Custom Message')).toBeInTheDocument();
});
// More test cases...
});
// You can have multiple describe blocks in one file
describe('MyComponent Advanced Features', () => {
// ... tests for advanced features
});
Key Jest Globals:
describe(name, fn): Creates a block that groups together several related tests. It's good for organizing tests by component or feature.test(name, fn, timeout)orit(name, fn, timeout):itis an alias fortest. This is where you define an individual test case. Thenameshould clearly state what the test is verifying.fncontains the actual test logic.timeout(optional) is in milliseconds if a test is expected to take longer.beforeEach(fn, timeout): Registers a function to run before each test case within itsdescribeblock (or globally if defined outside adescribe). Useful for setup common to multiple tests.afterEach(fn, timeout): Registers a function to run after each test case. Useful for cleanup.beforeAll(fn, timeout): Runs once before any tests in thedescribeblock (or file).afterAll(fn, timeout): Runs once after all tests in thedescribeblock (or file) have completed.
💻 Section 2: The AAA Pattern (Arrange, Act, Assert)
A widely adopted best practice for structuring individual test cases is the "Arrange, Act, Assert" (AAA) pattern:
-
Arrange: Set up the test conditions. This involves:
- Preparing any data needed for the test.
- Rendering the component with specific props.
- Setting up mocks if necessary.
-
Act: Perform the action or interaction you want to test. This could be:
- Simulating a user event (e.g., clicking a button, typing in an input) using
@testing-library/user-eventorfireEvent. - Calling a function.
- For simple rendering tests, the "act" is often implicitly the
render()call itself from the Arrange phase.
- Simulating a user event (e.g., clicking a button, typing in an input) using
-
Assert: Verify that the outcome of the action is as expected. This involves:
- Using
expectwith Jest matchers (andjest-dommatchers) to make claims about the component's output, state changes (indirectly), or function calls.
- Using
Example (from Greeting.test.js using AAA):
test('renders "Hello, Alice!" when name prop is "Alice"', () => {
// 1. Arrange
const testName = 'Alice';
render(<Greeting name={testName} />); // Rendering is part of Arrange/Act here
// 2. Act
// (Implicitly, the component renders based on props. No user interaction needed for this test.)
// For interaction tests, an "Act" step would be explicit, e.g.:
// userEvent.click(screen.getByRole('button'));
// 3. Assert
const expectedGreeting = `Hello, ${testName}!`;
expect(screen.getByText(expectedGreeting)).toBeInTheDocument();
});
Adhering to AAA makes your tests clearer, more organized, and easier to understand.
🛠️ Section 3: RTL Queries In-Depth (Part 1) - getBy*, queryBy*, findBy*
React Testing Library provides three main variants for its query functions, each with different behavior when an element is not found:
-
getBy*Queries: (e.g.,getByText,getByRole,getByTestId)- Behavior: Returns the matching element. If no element is found or if more than one element is found, it throws an error, immediately failing the test.
- Use When: You expect the element to be present in the DOM. This is the most common type of query you'll use for assertions.
- Example:
expect(screen.getByText('Submit')).toBeInTheDocument();(This implicitly asserts the button exists before checking if it's in the document).
-
queryBy*Queries: (e.g.,queryByText,queryByRole,queryByTestId)- Behavior: Returns the matching element if found. If no element is found, it returns
null(it does NOT throw an error). If more than one element is found, it throws an error. - Use When: You want to assert that an element is not present in the DOM.
- Example:
expect(screen.queryByText('Error Message')).not.toBeInTheDocument();
- Behavior: Returns the matching element if found. If no element is found, it returns
-
findBy*Queries: (e.g.,findByText,findByRole,findByTestId)- Behavior: Returns a Promise that resolves when an element is found. The Promise rejects if the element is not found after a default timeout (configurable, usually 1000ms) or if more than one element is found.
- Use When: You need to test for elements that appear asynchronously (e.g., after a data fetch, a
setTimeout, or an animation). - You'll typically use
awaitwith these queries in anasynctest function. - Example:
test('should show success message after form submission', async () => {
render(<MyForm />);
userEvent.click(screen.getByRole('button', { name: /submit/i }));
// Wait for the success message to appear
expect(await screen.findByText('Form submitted successfully!')).toBeInTheDocument();
});
All Variants (getAllBy*, queryAllBy*, findAllBy*):
Each of these also has an "All" variant (e.g., getAllByRole, queryAllByRole, findAllByRole) that returns an array of all matching elements.
getAllBy*: Throws an error if no elements are found.queryAllBy*: Returns an empty array ([]) if no elements are found.findAllBy*: Promise resolves to an array of elements (or an empty array if none found after timeout).
Choosing the Right Query:
- Use
getBy*for most cases where an element should be present. - Use
queryBy*to assert an element is not present. - Use
findBy*for asynchronous elements.
🔬 Section 4: Common screen Queries
RTL's screen object provides convenient access to these queries, already bound to document.body. Here are some of the most commonly used ones, prioritized by RTL's own recommendations (which favor accessibility):
-
Queries Accessible to Everyone (Users and Assistive Tech):
screen.getByRole(role, options): Queries for elements by their ARIA role. This is often the preferred query as it aligns with accessibility best practices.role: e.g.,'button','link','heading','listitem'.options: e.g.,{ name: /submit/i }(finds a button with accessible name "submit", case-insensitive).
const submitButton = screen.getByRole('button', { name: /submit/i });
const pageTitle = screen.getByRole('heading', { level: 1, name: 'Welcome' });screen.getByLabelText(textMatch, options): Finds a form element associated with a<label>.const emailInput = screen.getByLabelText(/email address/i);screen.getByPlaceholderText(textMatch, options): Finds an input by its placeholder text. Use sparingly, as placeholders are not a substitute for labels.screen.getByText(textMatch, options): Finds an element by its text content. Good for non-interactive elements like paragraphs, headings, or button text.const welcomeMessage = screen.getByText('Welcome to our app!');screen.getByDisplayValue(valueMatch, options): Finds form elements (input,textarea,select) by their current displayed value. Useful for testing controlled components.
-
Semantic Queries (HTML5 and ARIA):
getByAltText(textMatch, options): For images (<img>,<area>,<input type="image">) by theiralttext.getByTitle(textMatch, options): For elements with atitleattribute.
-
Test IDs (Use as a Last Resort):
screen.getByTestId(testIdMatch, options): Queries for elements by theirdata-testidattribute.<div data-testid="custom-element">Content</div>RTL recommends using this as an "escape hatch" when you can't find elements by more user-centric or semantic queries, or when text content might change frequently due to internationalization. Over-reliance on test IDs can make tests more brittle to DOM structure changes that don't affect user experience.const myElement = screen.getByTestId('custom-element');
Query Priority (RTL Recommendation):
- Queries accessible to everyone (e.g.,
getByRole,getByLabelText,getByText). - Semantic queries (e.g.,
getByAltText). - Test IDs (
getByTestId).
✨ Section 5: Basic Assertions with jest-dom
Thanks to import '@testing-library/jest-dom' in our setupTests.js, we have access to very readable and useful custom matchers.
Let's re-examine our Greeting.test.js with these concepts in mind:
// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
describe('Greeting Component', () => {
test('renders "Hello, Guest!" when no name prop is provided', () => {
// Arrange
render(<Greeting />);
// Act (Implicit: component renders)
// Assert
// Using getByText because the text is the primary way a user identifies this greeting.
const guestGreeting = screen.getByText('Hello, Guest!');
expect(guestGreeting).toBeInTheDocument(); // Asserts the element exists in the DOM
// We could also be more specific about the element type if needed:
// expect(screen.getByRole('heading', { name: 'Hello, Guest!' })).toBeInTheDocument(); // If it's a heading
});
test('renders "Hello, Alice!" when name prop is "Alice"', () => {
// Arrange
const testName = 'Alice';
render(<Greeting name={testName} />);
// Act
// Assert
const expectedText = `Hello, ${testName}!`;
const nameGreeting = screen.getByText(expectedText);
expect(nameGreeting).toBeInTheDocument();
// More specific assertion using toHaveTextContent:
expect(nameGreeting).toHaveTextContent(`Hello, ${testName}!`); // Checks the text content explicitly
// Could also check visibility:
expect(nameGreeting).toBeVisible(); // Asserts element is not hidden (e.g. display:none or visibility:hidden)
});
test('does not render guest greeting when a name is provided', () => {
// Arrange
render(<Greeting name="Bob" />);
// Act
// Assert
// Use queryByText to assert an element is NOT present
const guestGreeting = screen.queryByText('Hello, Guest!');
expect(guestGreeting).not.toBeInTheDocument(); // or expect(guestGreeting).toBeNull();
});
});
Common jest-dom Matchers:
.toBeInTheDocument(): Checks if an element is present in the DOM..toBeVisible(): Checks if an element is visible to the user (notdisplay: none,visibility: hidden, opacity 0, etc.)..toHaveTextContent(expected): Checks if an element contains the given text (can be a string or regex)..toHaveAttribute(attr, value): Checks if an element has a specific attribute, optionally with a specific value..toHaveClass(className): Checks if an element has a specific CSS class..toBeDisabled()/.toBeEnabled(): For form elements..toBeChecked(): For checkboxes or radio buttons..toHaveValue(expected): For input, select, textarea current value.
💡 Conclusion & Key Takeaways (Part 1)
We've now taken our first concrete steps into writing unit tests for React components. Understanding the structure of a test file, the AAA pattern, how to query for elements using React Testing Library's variants (getBy*, queryBy*, findBy*), and making basic assertions with jest-dom matchers are foundational skills.
Key Takeaways:
- Test files are structured with
describefor suites andtest/itfor individual cases.beforeEach/afterEachhandle setup/teardown. - The Arrange-Act-Assert pattern provides a clear structure for tests.
- RTL queries (
getBy*,queryBy*,findBy*) differ in how they handle elements not being found, catering to different assertion needs. - Prioritize user-facing queries like
getByRole,getByLabelText,getByText. UsegetByTestIdsparingly. @testing-library/jest-domprovides intuitive matchers for DOM assertions (e.g.,toBeInTheDocument,toHaveTextContent).
➡️ Next Steps
In "Writing Your First Unit Tests (Part 2)", we'll continue building on these fundamentals by:
- Testing component interactions using
@testing-library/user-event. - Testing components with state changes.
- Exploring more advanced assertions and query techniques.
You're well on your way to writing effective React unit tests!
glossary
- AAA Pattern (Arrange, Act, Assert): A structuring pattern for test cases.
getBy*(RTL Query): Returns a matching element; throws an error if zero or multiple matches.queryBy*(RTL Query): Returns a matching element ornull; throws if multiple matches. Used to assert absence.findBy*(RTL Query): Returns a Promise resolving to a matching element; rejects on timeout or multiple matches. Used for async elements.screen(RTL): An object providing query methods pre-bound todocument.body.- ARIA Role: Attributes defining the role of an element for accessibility (e.g., 'button', 'navigation', 'alert').
toBeInTheDocument()(jest-dom): Asserts an element is present in the DOM.toHaveTextContent()(jest-dom): Asserts an element contains specific text.toBeVisible()(jest-dom): Asserts an element is not hidden from the user.
Further Reading
- React Testing Library: Queries
- React Testing Library:
screenObject - Jest:
expectAPI @testing-library/jest-domMatchers List- Common mistakes with React Testing Library (by Kent C. Dodds) (Re-iterating this as it's very insightful for query usage)