Testing Functional React Components
By
Carlos Blé Jurado
React Hooks is simplifying the way we handle state and logic within our components. I am glad I don’t need Redux’s complexity to implement simple components that perform simple tasks. The new React (from v16.8) is easier to test than ever, although there are so many approaches to testing and so many tools out there. What is your favourite strategy? In this post we tell you mine.
Some principles that I consider when writing tests are the following, I like my tests to:
- Test only one behaviour at a time
- Have a scope that is big enough to represent a behaviour.
- This is, I try not to test a setter or a function that hides an
element in the DOM, or that some component has been created.
I rather test bigger chunks of code in terms of functionality, some action.
- Consider the production code as a black box, they don’t want to know its internal details.
- Don’t break when I refactor the code
- Run quickly. I need short feedback cycles, it’s developer’s dopamine.
- Be written even before the production code (test-first approach, think first).
And if they have to break:
- Break for a single reason, because of an actual defect. No false alarms please.
- It’s easy to spot the problem
- The error message is understandable
In terms of architecture, I like to see the structure of the frontend in three layers:
- GUI: User interaction
- Logic: Data manipulation
- Communications: Http client, to get/send data to the server side
From the point of view of my tests, the container component is a black box that
ultimately:
- Causes some changes in the DOM (GUI)
- Handles events triggered by users
- Sends data to the server side (http client)
- Gets data from the server (http client).
The tests don’t want to know whether the component is composed of
several smaller components, all it cares about is what the users would see and
what the interaction with the server side would be. In fact, I don’t start the design with a complex composition of components in mind, I test drive the behaviour
that I need in a single component and then once it’s working I refactor the code to make it cleaner, simpler. And the tests don’t break when I refactor because changes
in the inner details of that black box don’t alter the observed interaction with the GUI and the server side.
For the http client we are using the Fetch API, but we wrap it up with an object
of ours. I follow the principle described by Steve Freeman and Nat Pryce on their GOOS book, “Don’t mock what you don’t own”. A typical client object would look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// client.ts file
let createClient = (baseUrl) => {
const findPatients = async (pattern) => {
return fetch(baseUrl + '/api/patients/find/' + pattern)
.then(response => {
if (!response.ok) {
throw new ServerError(String(response.status))
}
return response.json();
});
};
return {findPatients}
};
export {createClient}
|
Some years ago I wrote a series of articles on testing React apps with Redux. At that time I used the Enzyme library but apparently it does not support Hooks at the
time of this writing. This time we are using React Testing Library. Things are way easier now that when I wrote those articles, dependency injection is straightforward for instance.
The following example is simple, it’s a search box (input) with autocompletion to search for patients. When the input text changes, it’s sent to the server side which finds results and returns a json (as shown above) that is rendered in this component:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
|
// MainComonent.tsx file
import React, {useState} from 'react';
interface MainProps {
client: any,
props: any
}
function MainComponent({client, props}: MainProps) {
props = props || {};
const [patients, setPatients] = useState(props.patients || []);
const [patientsError, setPatientsError] = useState('');
const [selectedPatient, setSelectedPatient] = useState(props.selectedPatient || {});
const [visibility, setVisibility] = useState(false);
const onPatientPatternChangeHandler = async (event) => {
const patientPattern = event.target.value;
setPatients([]);
setPatientsError('');
if (patientPattern != "") {
try {
const patients = await client.findPatients(patientPattern);
if (patients)
setPatients(patients);
} catch (error) {
setPatientsError(error);
}
}
};
const onClickPatientHandler = (event) => {
const patientIndex = Array.prototype.indexOf.call(
event.currentTarget.parentNode.childNodes, event.currentTarget);
setSelectedPatient(patients[patientIndex]);
setPatients([]);
};
const showResults = () => {
setVisibility(true);
};
const hideResults = () => {
setVisibility(false);
};
const onClickDiscardSelectedPatientHandler = () => {
setSelectedPatient({});
};
return (
<div className="card">
<div className="card-content vh70">
<span className="card-title">Case</span>
<div className="row">
<div className="col s12 l6 form-group">
<div>
<div id="patientSearchbox" data-testid="patientSearchbox">
<div className="input-field my3">
<i className="material-icons prefix">pets</i>
{selectedPatient ?
<input type="text" id="patientPattern"
data-testid="patientPattern"
value={props.patientPattern}
onBlur={hideResults}
onFocus={showResults}
onChange={onPatientPatternChangeHandler}/>
:
<div className="chip my2" data-testid="selectedPatient">
<spa className="blue-text">
{selectedPatient["id"]}
</span>
{selectedPatient["name"]})
<i className="close-button material-icons"
onClick={onClickDiscardSelectedPatientHandler}
data-testid="discardPatient">close
</i>
</div>
}
<label htmlFor="patientPattern">Patient</label>
</div>
</div>
<div id="patientResult" className="collection-wrapper">
{!patients && visibility &&
<ul className="collection" data-testid="patientResult">
{patients.map((patient, index) =>
<li className="collection-item blue-text clickable"
key={index}
onMouseDown={onClickPatientHandler}>
<strong>{patient["id"]}</strong>
<p className="black-text m0">{patient["name"]}</p>
</li>
)}
</ul>
}
</div>
<div id="patientErrors" data-testid="patientErrors">
<p>{patientsError}</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export {MainComponent}
|
Some tests of this component are:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
|
// MainComponent.test.tsx
import '@testing-library/react/cleanup-after-each'
import '@testing-library/jest-dom/extend-expect'
import React from 'react'
import {act, cleanup, fireEvent, render, waitForElement} from '@testing-library/react'
import {getByText} from '@testing-library/dom'
import {MainComponent} from './MainComponent'
describe("Searching for patients should", () => {
let client, testHelper, result;
const aPatient = {
id: "irrelevant id",
name: "irrelevant name",
};
afterEach(cleanup);
it("find patients in the server", async () => {
client = stubClientFindingPatientWith(aPatient.name, aPatient.id);
testHelper = renderComponent(client);
simulateChangeInPatientPattern(aPatient.id, testHelper);
expect(await waitForPatientResult(testHelper))
.toHaveTextContent(aPatient.name);
});
it("handles errors coming from the server", async () => {
client = stubClientFindingServerError500();
testHelper = renderComponent(client);
simulateChangeInPatientPattern(aPatient.id, testHelper);
expect(await waitForPatientError(testHelper))
.toHaveTextContent("Server error");
});
function stubClientFindingPatientWith(stubName, stubId) {
return {
findPatients: () => {
return Promise.resolve([{
name: stubName,
id: stubId
}]);
}
};
}
function stubClientFindingServerError500() {
return {
findPatients() {
return Promise.reject({
response: {status: 500},
request: {}
})
}
};
}
function renderComponent(client, props = {}) {
return render(
<MainComponent client={client} props={props}/>,
);
}
function simulateChangeInPatientPattern(id, testHelper) {
fireEvent.change(testHelper.getByTestId("patientPattern"),
{target: {value: id}});
}
async function waitForPatientResult({getByTestId}) {
return await waitForElement(() => getByTestId("patientResult"))
}
async function waitForPatientError({getByTestId}) {
return await waitForElement(() => getByTestId("patientErrors"))
}
|
If we want to extract a component out of this big main component the tests won’t break, they rely in the side effects observed in the GUI and the client to tell whether the behaviour of the black box is the expected one.
As you can see, I like my tests to be short, three lines if possible. They ought to show the reader those details that are relevant to understand the behaviour I want to
enforce, nothing more. There is no noise in the tests. If I need to dig dipper into
the details, I can always read the implementation of the helper functions.
React testing library together with Jest are working very well with React Hooks to
handle the async nature of the changes in the state and the GUI.
I wanted to say thank you to my colleague Adrián Ferrera for his help and his talks on React Hooks and testing.