Testing code can be a controversial subject, largely due to the multitude of ways one can go about writing a test.
There are no clear rules, and ultimately you are the one in charge of deciding what’s worth testing and how you’re going to do it.
One common mistake is to test implementation details, but perhaps you’ve read that already.
Let me take a step back then — what is the end goal of a test?
A common software abstraction is to write user stories — that is, possible actions that a user can take when interacting with your application.
Suppose you are to build a Celsius-to-Fahrenheit converter. A legitimate story could be something like:
“As a user, I want to be able to convert from Celsius to Fahrenheit.”
Naturally, as a careful developer, you want to assert that for a given set of numbers and inputs the conversion works (or it fails gracefully for invalid inputs like “banana”).
Note, however, that testing that a function is able to successfully handle the conversion from Celsius to Fahrenheit is only half the story.
If you are able to perform the most expensive and relevant calculation but your end user can’t access it, all effort will be in vain.
Why is that?
Well, as a frontend developer, your job is to not only ensure users get the correct answers to their questions but also to make sure they can use your application.
Therefore, you need to assess that the user has interacted with your application as expected.
In our example, that means that somewhere in the screen you expect some text to be displayed like this: “25ºC equals 77ºF.”
Now, that’s a relevant test. You just assessed that, for a given input, the user satisfactorily got the right answer on the screen.
The main takeaway here is that the user stories aren’t centered on your development implementations, so your tests shouldn’t be, either.
Of course, the scenarios in question are related to application-wide tests (things that have context), not bare-bones libraries.
If your goal is to create a library that converts Celsius to Fahrenheit to Kelvin, then it’s fine to test the details once you are detached of context.
Now that we understand that tests should resemble user stories, you can predict where semantics come from.
At the end of the day, your tests should have clear semantics such that you could read them in plain English—the same way you describe user stories.
We’ll see how we can leverage the react-testing-library API to write semantic tests that make sense.
Let’s dive further into the Temperature Converter application.
We’ll pretend that a competent Project Manager heard the complaints of their clients (probably any non-American who has moved recently to the US) and came up with the following requirements:
Apart from the lack of creativity of the PM when writing stories, the requirements are pretty straightforward.
We will sketch a simple app, do a good ol’ smoke test to check that everything looks alright, and then apply what we just learned in order to write better tests.
Consider the following CodeSandbox for our sample application:
https://codesandbox.io/s/temperature-converter-mw7se
Diving into the specifics of the code is beyond the scope of this article (check “How to Reuse Logic With React Hooks” for more context on how to use Hooks to create React applications).
However, the code should be pretty straightforward. We are basically requiring user input and allowing them to convert from Celsius to Fahrenheit or vice-versa.
We then display the results and a Reset button shows up. Upon clicking the button, the input is cleared and regains focus.
This aligns with what our users are looking for: we’ll improve the usability of the app and, most importantly, preserve its accessibility.
Now that we have a live application that seems to work, let’s be responsible developers and write some tests.
We’ll try to match each user story to a single test. By doing that, we will be confident that each requirement is being fulfilled with a set of tests backing us up.
Consider this basic skeleton for App.test.js
:
import React from "react"; import { cleanup } from "@testing-library/react"; afterEach(cleanup); test("user is able to convert from celsius to fahrenheit", () => { /* story 1 goes here */ }); test("user is able to convert from fahrenheit to celsius", () => { /* story 2 goes here */ }); test("user can reset calculation and automatically focus on the input", () => { /* story 3 goes here */ });
(We are using Jest as our test runner, but that’s not relevant to the main point presented in the article.)
Notice that our three tests are really straightforward and any failures in them would quickly expose what is really going on.
Now we’ll leverage RTL and write the first test in a way that makes sense:
import React from "react"; import App from "./App.js"; import { cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; afterEach(cleanup); test("user is able to convert from celsius to fahrenheit", () => { render(<App />); const input = screen.getByLabelText("Temperature:"); userEvent.type(input, "25"); expect(screen.getByText("25ÂşC equals to 77ÂşF")).toBeTruthy(); userEvent.type(input, "0"); expect(screen.getByText("0ÂşC equals to 32ÂşF")).toBeTruthy(); userEvent.type(input, "banana"); expect(screen.queryByTestId("result")).toBeFalsy(); }); /* code goes on */
There are a couple things to notice with the dependencies:
First, we import the component in question App.js
.
Then, notice that we are importing render
and screen
from RTL. While the first has been around since the library’s first launch, screen
is a new addition shipped on version 9.4.0. We will see its main advantage shortly.
We also import a new dependency, userEvents
, straight from @testing-library/user-event
. This library will boost our test readability and help us achieve our goal of improving our semantics.
Let’s actually dive into the test. If you are used to RTL, the first thing you’ll notice is that render
is not returning anything. In fact, that’s the main advantage of importing screen
.
What screen does is basically expose all queries that allow you to select elements in the screen (hence the name).
This is a pretty good change because it helps you avoid bloating the test with lots of destructuring, which is always annoying when you are not sure yet which queries to use.
Also, the code looks cleaner. (Note: there’s still a case for de-structuring container
and rerender
as mentioned by Kent C. Dodds in this tweet.)
The other difference from conventional tests you might have been writing is the userEvent
object.
This object provides a handful of user interactions that are semantically understandable and conceal implementation details. Consider the following example:
// Previously fireEvent.change(input, { target: { value: "25" } }); // With userEvents userEvent.type(input, "25");
Not only is our code is shorter, but it also makes much more sense now.
Remember that our goal is to write a test as close as possible to plain English. By encapsulating implementation details, userEvent
really puts us on the right track.
If you are curious, go ahead and check their documentation.
Once we are able to fill the input, we can now assert that the correct text is being displayed.
Now we can test a bunch of other options and confirm that what is displayed in the screen is expected (e.g., an invalid input like banana
won’t work).
Note: in a modular application, the conversion functions could be extracted in their own file and have their own tests (with many more test scenarios).
If you test the function separately, there’s no need to make redundant checks in the user stories as well (test is code and you want it maintainable as such).
With a test that is only eight lines long, we were able to check that our first scenario works as expected.
Let’s jump into our second user story — convert from Fahrenheit to Celsius (maybe a New Yorker having some fun on a beach in South America).
The test should be pretty similar to our first one, with a single caveat: we need to make sure that the user has selected the right option.
test("user is able to convert from fahrenheit to celsius", () => { render(<App />); const fahrenheitOption = screen.getByLabelText("Fahrenheit to Celsius"); userEvent.click(fahrenheitOption); const input = screen.getByLabelText("Temperature:"); userEvent.type(input, "77"); expect(screen.getByText("77ÂşF equals to 25ÂşC")).toBeTruthy(); userEvent.type(input, "32"); expect(screen.getByText("32ÂşF equals to 0ÂşC")).toBeTruthy(); userEvent.type(input, "banana"); expect(screen.queryByTestId("result")).toBeFalsy(); });
That’s it. By leveraging userEvent
again, emulating a click event becomes trivial.
Our code is perfectly readable and guarantees that the reverse direction (F to C) works as expected.
Our third and final test is slightly different — now our goal is to test the user experience rather than whether or calculator works.
We want to make sure that our application is accessible and that users can rapidly test several values:
test("user can reset calculation and automatically focus on the input", () => { render(<App />); const input = screen.getByLabelText("Temperature:"); userEvent.type(input, "25"); expect(screen.queryByTestId("result")).toBeTruthy(); const resetButton = screen.getByText("Reset"); userEvent.click(resetButton); expect(screen.queryByTestId("result")).toBeFalsy(); expect(document.activeElement).toBe(input); });
There you have it. We basically made three checks:
One of my favorite things about RTL is how easy it is to assert where a focus really is.
Notice how semantic expect(document.activeElement).toBe(input)
is. That pretty much looks like plain English to me.
And that’s it. Our three stories are covered, the Project Manager is happier, and hopefully our tests will keep the code clean for a long time.
The aim of this article was to expose the recent modifications in the react-testing-library’s API and show you how you can explore it to write better tests for you and your team.
I feel way more confident when I write tests that I understand because I stop chasing meaningless metrics (e.g. code coverage) to pay attention to what really matters (e.g. if my designed scenario works as expected).
react-testing-library was a big step in the right direction, mainly if you have some Enzyme background (in which case you might want to check “React Testing Library Common Scenarios,” where I explore how you tackle everyday scenarios in a React application).
It really facilitates to test what your application should do rather than how it does it. The semantics make a difference.
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 nowBackdrop and background have similar meanings, as they both refer to the area behind something. The main difference is that […]
AI tools like IBM API Connect and Postbot can streamline writing and executing API tests and guard against AI hallucinations or other complications.
Explore DOM manipulation patterns in JavaScript, such as choosing the right querySelector, caching elements, improving event handling, and more.
`window.ai` integrates AI capabilities directly into the browser for more sophisticated client-side functionality without relying heavily on server-side processing.
One Reply to "Semantic tests with react-testing-library"
Hi,
Interesting article but I disagree on some aspects, here are 2 cents of mine to be added to the thinking …
Each story needs to carry ‘acceptance criteria’ on which tests will be based.
You don’t create your tests solely based on the story description “as a X I need to do Y so that I get Z”, and you don’t rely on the PM blindly deciding which tests are to be executed.
How do you know 25 = 77 is representative of the test data you need to use to verify your converter result?
What about 0, what about -1, what about 12.3 (or is it 12,3 that is accepted as valid input), what about 2.345,67 (or 2,345.67) ? How many decimals are required on the visual or during calculation? Do we use rounding or truncation?
e.g. convert -17.77777 C to F : is that giving 1,4e-5°F as correct result ? or is it 1.399999998e-5 or 0.0000140000000001805 (sample taken from google search converter) ?
Also you can’t skip implementation from your tests.
Let’s imagine a developer detects numeric/alphabetic values in code by testing the keyboard key nrs, that is not good because not all keyboards have same layout and we want to test that (yes, I saw this being done by a developer)
For ‘clear rules’, you’d maybe like to look at techniques such as equivalence partitioning, boundary value analysis, decision tree …
Philippe.