How It Should Have Been Tested, Episode III: Test-Driven Asynchronous React Development
In the previous episode of #HowItShouldHaveBeenTested the basics of React.js were considered through the prism of TDD. In this article we will reconsider the way to teach asynchronous React development. Inspired by this great course by Eve Porcello, we will see how the development of a simple API client should be done via test-driven approach.
Let’s start with figuring out what we want to achieve. Our task will be displaying a random fact about Chuck Norris (kudos to the article where I learned about this idea), produced based on a random activity suggestion from Bored API, something like that:
How about you Start a collection?
BTW, Chuck Norris doesn’t need garbage collection because he doesn’t call .Dispose(), he calls .DropKick().
We’ll search for a random fact about Chuck Norris, using the last two words of the suggested random activity. If the result is empty, we use the very last word from the activity’s sentence.
This exercise is clearly a bit more complex than in the original chapter of the “React.js: Essential training”, but this way we can cover more topics at once.
Mocking custom hooks
Having created a new project with Create React App, we jump straight to the auto-generated App.test.js
and modify it as follows:
it("Should show activity text", async () => {
// Given
render(<App/>);
// When
const activityText = screen.getByText("How about you Start a collection?");
// Then
expect(activityText).toBeInTheDocument();
});
So, as the first step, we want to display just the activity suggestion. Having ensured that this test fails, we provide a simple naive implementation:
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<p>
How about you Start a collection?
</p>
</header>
</div>
);
}
export default App;
But that’s of course not interesting, and we proceed with the refactoring of our test to make it parameterised:
it.each(["Start a collection", "Listen to music you haven't heard in a while"])("Should show activity text: %s", (activity) => {
// Given
render(<App activity={activity}/>);
// When
const activityText = screen.getByText("How about you " + activity + "?");
// Then
expect(activityText).toBeInTheDocument();
});
Here, in the list of expected activity texts, we place actual activity suggestions from the Bored API. The prefix “How about you”
and suffix “?”
have to be constructed dynamically in our code. The minimal implementation is quite straightforward:
function App({activity}) {
return (
<div className="App">
<header className="App-header">
<p>
How about you {activity}?
</p>
</header>
</div>
);
}
However, we don’t want to pass the activity
argument directly to the App
component. Instead let’s create a hook that somehow extracts this value. And because we don’t want to put the hook’s logic inside the test, we will mock it using jest
:
import useActivity from './hooks/useActivity';
jest.mock('./hooks/useActivity');
describe("Tests App component", () => {
it.each(["Start a collection", "Listen to music you haven't heard in a while"])("Should show activity text: %s", (activity) => {
// Given
useActivity.mockReturnValue({ activity })
render(<App/>);
// When
const activityText = screen.getByText("How about you " + activity + "?");
// Then
expect(activityText).toBeInTheDocument();
});
});
In the App.js
, we need to import our custom (not-yet-created!) hook:
import useActivity from './hooks/useActivity';
function App() {
const { activity } = useActivity();
...
Finally, in the src/hooks
directory we create an empty hook useActivity.js
:
export default function useActivity() {
}
Since our tests now pass, it’s time to provide an implementation for our custom hook. And of course we start with a test: useActivity.test.js
. For that we will use React Hooks library which is a part of React Testing Library:
import { renderHook } from '@testing-library/react';
import useActivity from './useActivity';
describe("Tests useActivity hook", () => {
it.each(["Start a collection", "Listen to music you haven't heard in a while"])("Should fetch activity text: %s", (activity) => {
// Given
// When
const { result } = renderHook(() => useActivity(activity));
// Then
expect(result.current.activity).toBe(activity);
});
});
Here the renderHook(() => useActivity(activity))
statement is a very naive way to pass the expected value of the activity in the hook. This however allows us to make a small step towards the ultimate hook’s implementation:
import {useState} from "react";
export default function useActivity(defaultActivity = "") {
const [activity] = useState(defaultActivity);
return { activity };
}
API mocking with Mock Service Worker (msw)
We know that in reality our custom hook useActivity
should fetch its data from the external Bored API, rather than accept the value as a function argument. To test this behavior we are going to use the industry standard for API mocking called “Mock Service Worker”. First, let’s install this dependency:
npm i msw@1.3.2
Now, you may wonder why on earth we are not using the most recent version of msw which is 2.1.5
at the moment of writing this article? Well, the problem lies in the fact that we used Create-React App (CRA) tool to build our initial project setup. But, again, at the moment of writing this article, there is an unresolved issue involving these three things: CRA, jest and msw 2.0, which is described here: ReferenceError: TextEncoder is not defined (updating to v2) · Issue #1796 · mswjs/msw (github.com). You are free to read it and try it by your own, but for the vast majority of cases msw 1.x
is more than sufficient.
Long story short, after installing the msw
dependency, let’s modify our test as follows:
import { renderHook, waitFor } from '@testing-library/react';
import useActivity from './useActivity';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
describe("Tests useActivity hook", () => {
it.each([
"Start a collection",
"Listen to music you haven't heard in a while"
])("Should fetch activity text: %s", async (activity) => {
// Given
const server = setupServer(
rest.get('https://www.boredapi.com/api/activity', async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json(
{
"activity": activity,
}
)
);
})
);
server.listen();
// When
const { result } = renderHook(() => useActivity());
// Then
await waitFor(() => {
expect(result.current.activity).toBe(activity);
});
server.close();
});
});
Here the rest.get(‘https://www.boredapi.com/api/activity'…
block describes a mock of the Bored API server’s response. Also note, that we made our test async
because we use the await waitFor(…)
construction. The request to the API is asynchronous by nature, because it is performed in another thread and we will leverage useEffect
to change the state of the activity
. Ensuring, that the test fails:
expect(received).toBe(expected) // Object.is equality
Expected: "Listen to music you haven't heard in a while"
Received: ""
we jump to the implementation:
import {useState, useEffect} from "react";
export default function useActivity() {
const [activity, setActivity] = useState();
useEffect(() => {
fetch('https://www.boredapi.com/api/activity')
.then((response) => response.json())
.then((json) => setActivity(json.activity));
}, []);
return { activity };
}
And now our tests are green again.
Expecting the hook to be called with specific parameters
The last step is to fulfil the implementation and implement a call to the Chuck Norris facts API. Coming back to App.test.js
and implementing this new functionality:
import { render, screen } from '@testing-library/react';
import App from './App';
import useActivity from './hooks/useActivity';
import useChuckNorrisFacts from './hooks/useChuckNorrisFacts';
jest.mock('./hooks/useActivity');
jest.mock('./hooks/useChuckNorrisFacts');
describe("Tests App component", () => {
it.each([
["Start a collection", "Chuck Norris doesn't need garbage collection because he doesn't call.Dispose(), he calls.DropKick()."],
["Listen to music you haven't heard in a while", "Chuck Norris once caught an eighty pound tuna while spearfishing in a mirage."]
])("Should show activity text: %s and Chuck Norris' fact: %s", (activity, chuckNorrisFact) => {
// Given
useActivity.mockReturnValue({ activity });
useChuckNorrisFacts.mockReturnValue({ chuckNorrisFact });
render(<App/>);
// When
const activityText = screen.getByText("How about you " + activity + "?");
const chuckNorrisFactText = screen.getByText("BTW, " + chuckNorrisFact);
// Then
expect(activityText).toBeInTheDocument();
expect(chuckNorrisFactText).toBeInTheDocument();
});
});
We modified our test by expanding the list of arguments and turning the initial array of strings into an array of arrays of strings. Each element in this array is a pair of values: an activity and the corresponding fact about Chuck Norris.
At this stage this test is not even compilable, because we need to create a dummy implementation of the new hook useChuckNorrisFacts
:
import {useState} from "react";
export default function useChuckNorrisFacts() {
const [chuckNorrisFact] = useState();
return { chuckNorrisFact };
}
And finally add an implementation inside App.js
:
import './App.css';
import useActivity from './hooks/useActivity';
import useChuckNorrisFacts from './hooks/useChuckNorrisFacts';
function App() {
const { activity } = useActivity();
const { chuckNorrisFact } = useChuckNorrisFacts();
return (
<div className="App">
<header className="App-header">
<p>
How about you {activity}?
</p>
<p>
BTW, {chuckNorrisFact}
</p>
</header>
</div>
);
}
export default App;
Now App.test.js
passes, and we can refactor it a bit. According to our requirements, the Chuck Norris’ fact should be generated based on the activity’s last two words. How to extract these two last words will be a concern for the hook it self, so we just pass the actual activity to it:
expect(useChuckNorrisFacts).toBeCalledWith(activity);
Our test is failing again:
expect(jest.fn()).toBeCalledWith(...expected)
Expected: "Listen to music you haven't heard in a while"
Received: called with 0 arguments
And the fix is quite simple:
const { chuckNorrisFact } = useChuckNorrisFacts(activity);
Asserting query params in msw
Now it’s time to implement a test for the useChuckNorrisFacts
hook. We start with a simple test case:
import { renderHook, waitFor } from '@testing-library/react';
import useChuckNorrisFacts from './useChuckNorrisFacts';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
describe("Tests useChuckNorrisFacts hook", () => {
it.each([
["Study a foreign language", "foreign language", "Chuck Norris can speak in any of 37 foreign languages in a Dyslexic format."]
])("Should fetch a fact about Chuck Norris for activity %s and query %s: %s", async (activity, query, chuckNorrisFact) => {
// Given
const server = setupServer(
rest.get('https://api.chucknorris.io/jokes/search', async (req, res, ctx) => {
const url = new URL(req.url)
const queryParam = url.searchParams.get('query')
let total = 0;
let result = [];
if (queryParam === query) {
total = 1;
result = [{
"value": chuckNorrisFact
}];
}
return res(
ctx.status(200),
ctx.json(
{
"total": total,
"result": result
}
)
);
})
);
server.listen();
// When
const { result } = renderHook(() => useChuckNorrisFacts(activity));
// Then
await waitFor(() => {
expect(result.current.chuckNorrisFact).toBe(chuckNorrisFact);
});
server.close();
});
});
The array [“Study a foreign language”, “foreign language”, “Chuck Norris can speak in any of 37 foreign languages in a Dyslexic format.”]
defines three arguments: the activity, the extracted two last words of the activity and the expected fact. To make this test to pass:
import {useState, useEffect} from "react";
export default function useChuckNorrisFacts(activity) {
const [chuckNorrisFact, setChuckNorrisFact] = useState();
const query = activity.substring(activity.lastIndexOf(' ', activity.lastIndexOf(' ') - 1) + 1);
useEffect(() => {
fetch('https://api.chucknorris.io/jokes/search?query=' + query)
.then((response) => response.json())
.then((json) => setChuckNorrisFact(json.result[0].value));
}, []);
return { chuckNorrisFact };
}
So far so good. Let’s add one more case when nothing is found using two last words, so the hook should perform another request with only the last word:
["Start a collection", "collection", "Chuck Norris doesn't need garbage collection because he doesn't call.Dispose(), he calls.DropKick()."],
First we ensure that the test is failing and then we create an enhanced implementation but doing some refactoring and introducing a new state query
that should serve as a dependency for useEffect:
...
const lastIndexOfSpace = activity.lastIndexOf(' ');
const lastButOneIndexOfSpace = activity.lastIndexOf(' ', lastIndexOfSpace - 1);
const [query, setQuery] = useState(activity.substring(lastButOneIndexOfSpace + 1));
useEffect(() => {
fetch('https://api.chucknorris.io/jokes/search?query=' + query)
.then((response) => response.json())
.then((json) => {
if (json.result[0]) {
setChuckNorrisFact(json.result[0].value);
} else {
setQuery(activity.substring(lastIndexOfSpace + 1));
}
});
}, [query]);
...
Despite this test passes, there are still things to consider. The value of activity
can be undefined, because it is filled asynchronously. We have to be sure that we do not send any requests about Chuck Norris facts until activity
gets its value. This case deserves a separate test:
it("Should not fetch a fact about Chuck Norris if activity is undefined", async () => {
// Given
// When
const { result } = renderHook(() => useChuckNorrisFacts(undefined));
// Then
await waitFor(() => {
expect(result.current.chuckNorrisFact).toBe("");
});
server.close();
});
This test will fail ever before reaching any fetch activity — due to the operations over activity string:
TypeError: Cannot read properties of undefined (reading 'lastIndexOf')
The solution is to slightly refactor the code:
...
const [chuckNorrisFact, setChuckNorrisFact] = useState("");
const [query, setQuery] = useState();
useEffect(() => {
if (activity) {
const lastIndexOfSpace = activity.lastIndexOf(' ');
const lastButOneIndexOfSpace = activity.lastIndexOf(' ', lastIndexOfSpace - 1);
setQuery(activity.substring(lastButOneIndexOfSpace + 1));
}
}, [activity]);
...
After running our test again, we see another error:
TypeError: Cannot read properties of undefined (reading 'substring')
This time it happens within the second useEffect
, so we need to add a check for query
to be defined:
useEffect(() => {
if (query) {
fetch('https://api.chucknorris.io/jokes/search?query=' + query)
.then((response) => response.json())
.then((json) => {
if (json.result[0]) {
setChuckNorrisFact(json.result[0].value);
} else {
setQuery(activity.substring(lastIndexOfSpace + 1));
}
});
}
}, [query]);
This fixes the last test, but now the first test (Should fetch a fact about Chuck Norris for activity %s and query %s: %s
) starts failing:
ReferenceError: lastIndexOfSpace is not defined
I deliberately left here this mistake I made during the refactoring. At least for me it wasn’t that obvious that by wrapping the query preparation logic, I broke the scope of the lastIndexOfSpace
variable. But the point here is to show that we can identify problems through failing tests without the need to refresh the browser. Anyway, my solution to fix the problem is to refactor the code again like that:
...
const lastWordsOfSentence = (sentence, wordsNumber) => {
let lastIndexOfSpace = sentence.length;
for (let wordNumber = 0; wordNumber < wordsNumber; wordNumber++) {
lastIndexOfSpace = sentence.lastIndexOf(' ', lastIndexOfSpace - 1);
}
return sentence.substring(lastIndexOfSpace + 1);
}
useEffect(() => {
if (activity) {
setQuery(lastWordsOfSentence(activity, 2));
}
}, [activity]);
useEffect(() => {
if (query) {
fetch('https://api.chucknorris.io/jokes/search?query=' + query)
.then((response) => response.json())
.then((json) => {
if (json.result[0]) {
setChuckNorrisFact(json.result[0].value);
} else {
setQuery(lastWordsOfSentence(activity, 1));
}
});
}
}, [query]);
...
One for sure may find a more elegant solution, but it is not the main point of this article.
Let’s check the Test Code Coverage
As in the previous episode, let’s see what the code coverage we got by following TDD practice. We run this command:
npm test -- --coverage
and observe this, as it seems to me, beautiful picture:
Summary and what’s next?
In this episode of #HowItShouldHaveBeenTested we covered two topics: Custom Hooks and Asynchronous development in React. We studied these topics through TDD by starting each and every development step with writing a test first. There are some minor sub-topics not covered in this essay, like managing errors that occur during the fetch operation or handling loading states. I will do my best to cover these leftovers as well as more complex topics as React Router or OAuth2 in the next episodes of this series.
The React project discussed in this article is available on GitHub: Andremoniy/hishbt3 (github.com).
Acknowledgement
I want to thank Grégory Del Frate for his review of this article and for providing highly valuable advice for its improvement.