leanmind logo leanmind text logo

Blog

Advanced Refactoring

Master productive refactoring to maximise return on investment.

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:

And if they have to break:

In terms of architecture, I like to see the structure of the frontend in three layers:

From the point of view of my tests, the container component is a black box that ultimately:

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.

Published on 2019/08/07 by

Do you want more? We invite you to subscribe to our newsletter to get the most relevan articles.

If you enjoy reading our blog, could you imagine how much fun it would be to work with us? let's do it!

We boost the professional growth of your development team