Write the test first. – Beyoncé (paraphrased)
If you’re like Beyoncé, you know that writing your UI component code should only be done once you have a solid set of tests.
Ideally.
You know, if it works out that way. You try your best.
The thing is, test-driven development (TDD) orthodoxy suggests that you probably shouldn’t write tests that depend on a specific implementation strategy. But for React developers, popular tools such as Enzyme and React Test Renderer make it so easy to write tests that are tightly coupled with the details of the source code.
Don’t get me wrong — those are great tools, but their APIs make it close to impossible to write a unit test that won’t break if a component name or DOM selector changes.
You won’t find any shortage of think pieces and books about good unit testing practices. If you want to write a good test, they say, target the public interface; so if it’s a class, test the public methods of that class, not the private methods. This conventional wisdom serves us well when testing traditional object-oriented programs.
React apps, though, are not traditional object-oriented programs. Their public interfaces should not be thought of as messages between objects. Instead, consider the public interface of your React component to be the parts that the user can see or interact with.
That is ultimately the point of a UI component library: it manages messages between the user and the application. So if you want to write a good unit test for your React code, target the public interface — the parts that the user can perceive and interact with.
What text can the user see? What changes when they interact with an element?
These are the questions a component unit test should answer. And as it happens, these types of questions are very similar to the types of requirements that product stakeholders tend to write for user interfaces.
Consider the following specification for a simple weather app:
Like a good TDD’er, you set out to write a test for each of these criteria. Let’s look at one possible approach using the popular Enzyme library:
import { mount } from "enzyme"; describe("App", () => { it("should render a button", () => { const element = mount(<App />); const button = element.find("button"); expect(button).toHaveLength(1); }); it("should fetch a weather forecast upon tapping the button", async () => { const element = mount(<App />); const button = element.find("button"); button.simulate("click"); await wait(2000); // homespun utility function that waits X milliseconds before resolving expect(element.text()).toContain("New York"); expect(element.text()).toContain("°"); }); });
This is a perfectly serviceable test, but it’s brittle. This test depends on a <button>
element being interactive.
We all know that when product requirements say “button,” they mean “thing that looks and acts like a button.”
So what if, during implementation, we decide it’s best to use <div tabindex="0"role="button">
or <a href="/forecast-for-new-york">
?
What if the request takes longer than 2s? We would, of course, need to update the test. That’s not the end of the world, but it’s also not the way TDD is ideally done.
This feature is so simple that these edge cases are definitely in the “nitpick” category, but it’s not hard to imagine where these distinctions become more consequential. The button
selector is a “private” interface, so the user doesn’t perceive nor care what HTML tag is being used to express an element.
They just want to find “Get weather” and get the dang weather! And they won’t wait 2s if it only takes 100ms, either.
Remember: a component’s true public interface is that which is able to be perceived and be interacted with by the user, not abstractions such as selectors or component names.
So if Enzyme and similar tools such as React Test Renderer won’t do the trick, what will?
React Testing Library (RTL), of course! Initially released in early 2018, this relative newcomer is picking up lots of steam in the React community, and for good reason.
React Testing Library’s API offers a refreshing, user-centric alternative to Enzyme et al. Let’s write the same two tests as before, this time using RTL:
import { render, fireEvent, screen } from "@testing-library/react"; describe("App", () => { it("should render a button", () => { render(<App />); const button = screen.queryByText("Get weather"); expect(button).not.toBeNull(); }); it("should fetch a weather forecast upon tapping the button", async () => { render(<App />); const button = screen.queryByText("Get weather"); fireEvent.click(button); await screen.findByText("New York", { exact: false }); expect(screen.queryByText("°", { exact: false })).not.toBeNull(); }); });
RTL’s API is straightforward. We find elements by label, text, aria-label, or other user-facing attributes. Selectors are completely sidelined.
Out with find("DestinysChildImg")
and in with findByAltText("Destiny's Child performing at their reunion tour in 2022")
.
I also want to call attention to one of the several tools RTL gives us to handle asynchronous functionality:
await screen.findByText("New York", { exact: false });
This does exactly what you think it does.
findByText
queries the DOM on an interval until it: 1.) times out, or 2.) finds the element. In real life, response times for asynchronous services will vary a great deal depending on that service’s performance and the power and bandwidth of the client device.
Yet JavaScript unit tests often go to great lengths to tame asynchronous effects via mocking or best-guess wait functions. RTL’s async tools solve the problem of expressing asynchronous effects in a test suite. In other words, it waits for the page to change, just like your users would.
This is as close to plain English as a JavaScript test suite can get in 2020. It’s so intuitive to translate product requirements into unit tests using RTL, which makes it an ideal choice for TDD practitioners.
So, we’ve written our tests. Let’s close the loop and write some source code!
First, we’ll make the first test pass:
import React from "react"; const App = () => { return ( <div> Get weather </div> ); }; export default App;
Then, we’ll make the second test pass by introducing the interactive elements:
import React, { useState } from "react"; const App = () => { const [weather, updateWeather] = useState(null); const fetchAndUpdateWeather = async () => { const response = await fetch("https://wttr.in/New York?format=3"); const body = await response.text(); updateWeather(body); }; return ( <div> <div>{weather}</div> <div> <button onClick={fetchAndUpdateWeather}>Get weather</button> </div> </div> ); }; export default App;
I don’t care how small an app is, I’ll never get tired of seeing the tests turn green the first time. Total bliss.
I’ve aggregated the tests and source code into a CodeSandbox for your reference:
In React Testing Library, you get a tool that enables true test-driven development. The API all but forces you to query by items that are perceptible to end users, and asynchronous effects are handled elegantly while approximating real-world user behavior.
You can finally write your React unit tests first without a care in the world for how the source code will look.
“Irreplaceable.” – Beyoncé (but really this time)
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowDesign React Native UIs that look great on any device by using adaptive layouts, responsive scaling, and platform-specific tools.
Angular’s two-way data binding has evolved with signals, offering improved performance, simpler syntax, and better type inference.
Fix sticky positioning issues in CSS, from missing offsets to overflow conflicts in flex, grid, and container height constraints.
From basic syntax and advanced techniques to practical applications and error handling, here’s how to use node-cron.
One Reply to "TDD with React: A low-friction approach with examples"
Using `ByText` is not recommended. You don’t ensure that the selector found is part of the accessibility tree. `ByRole` is the much preferred selector. See: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#using-the-wrong-query