I’ve been thinking a lot about testing lately. Not the abstract theory, but the practical, daily work of making sure a React component does what it’s supposed to do. I’ve seen tests that break with every minor refactor and tests so focused on internal state they miss if a button actually works. This frustration led me to a powerful duo: Jest and Testing Library. Together, they form a practical foundation for writing tests that matter. Let’s build that foundation together.
Think about how a user sees your app. They don’t see useState hooks or prop names. They see labels, buttons, and text. They click, type, and wait for things to happen. This perspective is the core idea behind Testing Library. It provides tools to find and interact with elements the way a real person would. Instead of hunting for a div with a specific class, you find a button by the text it displays. This approach naturally leads to more accessible and resilient components.
Jest is the engine that makes everything run. It finds your test files, executes the code, and checks your expectations. It handles the mechanics: organizing tests, providing assertion functions, and creating snapshots. While Jest can test any JavaScript, it needs a way to talk to the DOM for React components. That’s where Testing Library comes in, acting as the bridge between Jest’s world and the browser’s world.
Setting this up is straightforward. After creating a React app with a tool like Create React App, which includes both by default, you’re ready to go. For other setups, you install them via npm: npm install --save-dev jest @testing-library/react @testing-library/jest-dom. The @testing-library/jest-dom package adds helpful custom matchers to Jest, like .toBeVisible() or .toHaveTextContent(), making your assertions read more clearly.
A basic test looks like this. We render a component, query for something a user would see, and make an assertion.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Greeting from './Greeting';
test('shows a greeting message', () => {
render(<Greeting name="Sarah" />);
const message = screen.getByText(/hello, sarah!/i);
expect(message).toBeInTheDocument();
});
Notice screen.getByText. We’re finding the element by its visible text, not by a component prop or a nested element’s structure. If we change the component’s CSS class later, this test won’t care. It only cares that the greeting is shown to the user. This is the key to durable tests.
But what about interaction? Users don’t just look; they click, type, and submit. Testing Library’s user-event simulates these actions more realistically than firing plain DOM events. Let’s test a simple counter.
test('increments counter on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
const countDisplay = screen.getByText(/count: 0/i);
await user.click(button);
expect(countDisplay).toHaveTextContent('Count: 1');
});
We find the button by its accessible role and name. We simulate a user click. Then we assert that the displayed text updated correctly. The test flows like a user’s action. Can you see how this method pushes you to build accessible components? If your button doesn’t have a proper label, the test won’t even be able to find it.
Handling asynchronous behavior is common in modern UIs. A component might fetch data, wait for an animation, or update after a timer. Jest and Testing Library handle this gracefully. The findBy queries are built for waiting.
test('loads and displays user data', async () => {
render(<UserProfile userId="123" />);
// findBy queries wait for the element to appear
const userName = await screen.findByText(/john doe/i);
const loadingText = screen.queryByText(/loading\.\.\./i);
expect(userName).toBeInTheDocument();
expect(loadingText).not.toBeInTheDocument(); // It should be gone
});
The findByText will wait, retrying until the element appears or a timeout is reached. This is perfect for testing components that fetch data on mount. We also use queryByText to assert that the loading indicator disappeared. Using the right query—getBy, queryBy, or findBy—is a small detail that makes tests robust.
What about components that rely on context, like a theme or a router? Testing Library encourages you to test the component with its necessary providers, just like it runs in your app. You render the component wrapped in the context it needs.
import { ThemeProvider } from '../context/ThemeContext';
test('uses the dark theme class', () => {
render(
<ThemeProvider value="dark">
<ThemedButton />
</ThemeProvider>
);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-dark');
});
You’re not mocking the context itself; you’re providing it. This tests the integration between your component and the context system, which is more valuable than testing the component in isolation.
A common question is, “Should I test every single prop and state change?” The answer from this approach is usually no. Test the outcomes. If a user toggles a switch, does the correct panel expand? Don’t test that the internal isOpen state changed from false to true. Test that the panel’s content became visible. This focus protects your tests from unnecessary breaks during internal refactoring.
This combination has changed how I write React code. I think about elements with proper roles and labels from the start. I structure components so their functionality is exposed to a user, not just to a parent component. The tests become a specification of user-facing behavior, not a ledger of internal mechanics. They give me confidence that the code I’m changing still works for the person who actually uses it.
The real power isn’t just in avoiding bugs today. It’s in building a codebase you can change with confidence six months from now. Tests written this way act as a guardrail, ensuring that the user’s experience remains intact even as the code evolves beneath the surface. They answer the most important question: does my application still work?
I encourage you to try this approach on your next component. Start small. Write one test that checks if a crucial piece of text renders. Then write one that simulates a main user action. You might find, as I did, that it changes how you build features. If this perspective on testing resonates with you, or if you have your own methods, I’d love to hear about it. Share your thoughts in the comments below.
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva