We just covered testing JavaScript code and now it is time to talk more on we can apply same pronciples when testing React apps.
- introduction to
react-testing-library userEventfor user interactions- testing React components
- API mocking
Let's start with simple React component that we would like to cover with tests. This component checks the length of the word and provides different message depending on whether the word is within the boundaries or not.
export default function WordChecker({ minLength = 3, maxLength = 7 }) {
const [word, setWord] = useState("");
const handleChange = (e) => {
setWord(e.target.value);
};
return (
<div>
<h3>Check the word</h3>
<label htmlFor="word">Enter a word</label>
<input id="word" value={word} onChange={handleChange} />
{word.length <= maxLength && word.length >= minLength ? (
<p role="status">π Good word!</p>
) : (
<p role="alert">
π« Bad word!
</p>
)}
</div>
);
}Let's start simple, let's test that component renders correct heading.
We can start by creating a test file WordChecker.test.jsx and writing an empty test case. If we log out console.log(<WordChecker />) we will see that it returns the Virtual DOM node.
Output
{
'$$typeof': Symbol(react.element),
type: [Function: WordChecker],
key: null,
ref: null,
props: {},
_owner: null,
_store: {}
}
We need to render it to receive actual DOM tree.
Interesting tip
If we try to log out innerHTML, we would see empty string.
const div = document.createElement("div")
const root = ReactDOM.createRoot(div);
root.render(<WordChecker />);
console.log(div.innerHTML)Why is that? Since React v18 root.render became async, so we need to take that into account
const waitNextTick = () => new Promise((resolve) => setTimeout(resolve, 0));
test("renders header", async () => {
const div = document.createElement("div")
const root = ReactDOM.createRoot(div);
root.render(<WordChecker />);
await waitNextTick();
console.log(div.innerHTML);
});But there is also react's test utils we can use renderIntoDocument() instead, which would not have that problem:
const container = renderIntoDocument(<WordChecker />)When we log console.log(container.innerHTML) we would see:
const container = renderIntoDocument(<WordChecker />);
console.log(container.innerHTML)<div><h3>Check the word</h3><label for="word">Enter a word</label><input id="word" value=""><p role="alert">π« Bad word!</p></div>Now we can try to find heading and check that its content is "Check the word".
console.log(div.querySelector("h3").textContent) // "Check the word"Now we can write an assertion:
expect(div.querySelector("h3").textContent).toBe("Check the word");Congratulations! We just wrote our first assertion for React component. But it is a bit fussy and requires a lot of code.
Introducing -- DOM testing library β¨ It is a very light-weight solution for testing DOM nodes.
Let's try it!
import { getByRole } from '@testing-library/dom';
test("renders header", async () => {
const div = document.createElement("div")
const root = ReactDOM.createRoot(div);
root.render(<WordChecker />);
await waitNextTick();
expect(getByRole(div, "heading").textContent).toBe("Check the word")
})Though our matching does not look very convenient. Lucky that DOM testing library comes with jest-dom, which extends Jest's default matchers.
It is already included in Create React App in setupTests.js:
import '@testing-library/jest-dom';Now we can refactor out matcher:
// before
expect(getByRole(div, "heading").textContent).toBe("Check the word")
// after
expect(getByRole(div, "heading")).toHaveTextContent(/check the word/i);Following the same principles we can write more matchers, for example, for input:
test("renders header and input", async () => {
// ...
expect(getByRole(div, "heading")).toHaveTextContent(/check the word/i);
expect(getByLabelText(div, "Enter a word")).toBeInTheDocument();
})If we are to add more tests, there is quite a lot of code to copy around:
const div = document.createElement("div")
const root = ReactDOM.createRoot(div);
root.render(<WordChecker />);Luckily for us, there is also an awesome react-testing-library! It builds on top of DOM Testing Library by adding APIs for working with React components.
It exposes render method which we can use to render React components conveniently. And screen utility to simplify usage of all queries.
With this, our test would look like this:
import { render, screen } from "@testing-library/react";
test("renders header and input", () => {
render(<WordChecker />);
expect(screen.getByRole("heading")).toHaveTextContent(/check the word/i);
expect(screen.getByLabelText("Enter a word")).toBeInTheDocument();
});Now, let's write more tests. Next up, we would need to fire events in the tests. We would need to type text into the input and check whether alert is shown.
React testing library ships with fireEvent utility that helps to fire events in our tests.
test("displays alert when word is too long", () => {
render(<WordChecker maxLength={9} />);
const input = screen.getByLabelText(/enter a word/i);
fireEvent.change(input, { target: { value: "abrakadabra" } });
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent(/bad word/i);
});While fireEvent is very useful it dispatches DOM events and they do not work in the same way as the events when user interacts with web interface.
For example, in order ty type into the input, under the hood all these events actually happen: onFocus, onKeyPress, onKeyDown, onChange, onBlur etc.
Therefore if in our tests we want to simulate the user behavior in the most accurate way, we would need to dispatch a lot of events π
Enter user-event, it provides a convenient abstraction on top of fireEvent that simulates actual user behavior.
test("displays alert when word is too long", () => {
render(<WordChecker maxLength={9} />);
const input = screen.getByLabelText(/enter a word/i);
userEvent.type(input, "abrakadabra");
const alert = screen.getByRole("alert");
expect(alert).toHaveTextContent(/bad word/i);
});Now, in sinilar fashion we can write the rest of the tests. But there is something I want to highlight -- testing rerenders.
When you are using components in real life, you often change their props which causes them to rerender. This an important bahavior to test. For example, our component accepts maxLength prop. If we change it, and the world is already entered, it shoukd influence whether alert is displayed or not.
react-testig-library's render method returns rerender function to do just that.
test("displays success message when the word is at correct length", async () => {
const { rerender } = render(<WordChecker maxLength={10} />);
// enter a word that satisfied maxLength
userEvent.type(screen.getByLabelText(/enter a word/i), "tallinn");
expect(screen.getByRole("alert")).toHaveTextContent(/good word/i);
// rerender with new maxLength
rerender(<WordChecker maxLength={12} />);
// check the changes, alert needs to be displayed
expect(screen.getByRole("alert")).toHaveTextContent(/bad word/i);
expect(screen.queryByRole("status")).toBeNull();
});π π π Task 2.1
This part lacks transcript for now, it will be added later!
Now it's time for you to practice testing React components on your own. I prepared Counter component that you would need to cover with a set of tests. Head to /task-01-counter and open Counter.test.jsx file.
Head to /task-02-quotes, there you will find small component that generates random quotes using REST API. Open Quote.test.jsx file and cover its logic with tests utilizing the principles we just learned right now.
π Bonus task: Add loading indication feature through TDD
βΉοΈ Bonus tasks can be completed if you finished early with main tasks or at home
Head to /task-03-welcome, there you will find small component that generates random quotes using REST API.
Open Welcome.test.jsx file and cover its existing logic with tests. And then use TDD approach to add new functionality to it:
- Field should clean itself after "Greet me" clicked
- "Greet me" button should be disabled when field is empty
- Display warning if user enters the same name twice