Test Driven Development with React

basarbk

Basar Buyukkahraman

Posted on December 26, 2021

Test Driven Development with React

In this one lets see how we can apply test driven development in a react project.

Setup

First lets create the project.

npx create-react-app my-app

Create react app is creating the project having necessary dependencies for the testing. We are using
jest* as the test runner which also has the assertion and mocking functionalities.

We also have additional dependencies

  • @testing-library/react is for rendering the components in our test functions.
  • @testing-library/jest-dom is for dom releated matchers for jest
  • @testing-library/user-event is for making user actions on components, like clicking, typing, focusing etc

The project comes with App.test.js module which is a sample to demonstrate how to test a component. It displays how to render a component in a test module, how to query elements in that component and how to do the assertion part.

// App.test.js
import { render, screen } from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

There is also setupTest.js in the project. This file is loaded by jest and we can move repeating parts (like importing the common packages for the test modules) from test modules to this one.

Project

Lets have a simple component here. We will have a button in it and whenever we click to that button, it is going to be loading random user from this public api
https://randomuser.me/

First lets have two terminal and run the project npm start in one of them and run the tests npm test on another one.

By default the jest is running in watch mode in our project. And jest watch mode is working based on git status. If there is no changed files, it does not run tests. You can make sure to run all test to run by hitting a in the test terminal. The App.test.js would be run after that and you must be seeing all tests are passing.

 PASS  src/App.test.js
  √ renders learn react link (61 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.407 s
Ran all test suites.

Watch Usage: Press w to show more
Enter fullscreen mode Exit fullscreen mode

Now lets add our component RandomUser.js and corresponding test module RandomUser.spec.js

Jest automatically detects the test modules if the files have the extension *.test.js or *.spec.js.

and lets add our first test

// RandomUser.spec.js
import RandomUser from './RandomUser';
import { render, screen } from '@testing-library/react';

describe('Random User', () => {
  it('has button to load random user', () => {
    render(<RandomUser />);
    const loadButton = screen.queryByRole('button', {
      name: 'Load Random User'
    });
    expect(loadButton).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

We are rendering the RandomUser component. And then we use screen's functions to query the elements we are looking for. doc

this first test is looking for a button on page. We are using the a11y roles here and as a text, we expect the button to have Load Random User. In the end, we expec this button to be in the document.

As soon as we save this module, jest is running the tests again. It will be ending up with failure.

 ● Random User › has button to load random user

    Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

      4 | describe('Random User', () => {
      5 |   it('has button to load random user', () => {
    > 6 |     render(<RandomUser />);
        |     ^
      7 |     const loadButton = screen.queryByRole('button', {
      8 |       name: 'Load Random User'
      9 |     });

Enter fullscreen mode Exit fullscreen mode

The test fails at the render phase, because the RandomUser.js is not actually a react component yet.

Lets create the component.

// RandomUser.js
export default function RandomUser(){
  // lets return button element having the text we are looking for
  return <button>Load Random User</button>;
}
Enter fullscreen mode Exit fullscreen mode

Test is passing now.

Lets show this component in our application.

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
//
import RandomUser from './RandomUser';

ReactDOM.render(
  <React.StrictMode>
// replace <App/> with our component
    <RandomUser />
  </React.StrictMode>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

Now you must be seeing a button on page.

Now we are going to click to this button and it will be making an api call to randomuser.me
But first lets install a library for this api call.

npm i axios
Enter fullscreen mode Exit fullscreen mode

Make sure you stop and start test and app consoles after installing a new dependency.

Lets use axios for http calls.

We are going to add our test for this requirement. But first lets see the returned object from randomuser api.

{
  "results": [
    {
      "gender": "female",
      "name": {
        "title": "Miss",
        "first": "Jennifer",
        "last": "Alvarez"
      },
      "location": {
        //
      },
      "email": "jennifer.alvarez@example.com",
      "login": {
         //
      },
      "dob": {
        "date": "1954-07-01T18:59:36.451Z",
        "age": 67
      },
      "registered": {
        "date": "2016-11-17T05:48:39.981Z",
        "age": 5
      },
      "phone": "07-9040-0066",
      "cell": "0478-616-061",
      "id": {
        "name": "TFN",
        "value": "531395478"
      },
      "picture": {
        "large": "https://randomuser.me/api/portraits/women/24.jpg",
        "medium": "https://randomuser.me/api/portraits/med/women/24.jpg",
        "thumbnail": "https://randomuser.me/api/portraits/thumb/women/24.jpg"
      },
      "nat": "AU"
    }
  ],
  "info": {
    //
  }
}
Enter fullscreen mode Exit fullscreen mode

so the actual user object is in the results array.
now lets add our test

// we need to import two packages.
// we will mock the
import axios from 'axios';
// and we will use this user-event to click the button.
import userEvent from '@testing-library/user-event';

// this test will be having async/await
it('displays title, first and lastname of loaded user from randomuser.me', async () => {
  render(<RandomUser />);
  const loadButton = screen.queryByRole('button', {
    name: 'Load Random User'
  });

  // we will click the button but our request must not be going
  // to the real server. we can't be sure how that request
  // ends up. So we will mock it. Lets make sure we set what
  // axios will return. 
  // lets define the mock function first
  // axios get, post ... functions are promise and here
  // we will mock success response by mockResolvedValue
  // and we will return the axios response object.
  // so we put the actual api response into data object here
  const mockApiCall = jest.fn().mockResolvedValue({
    data: {
      results: [
        {
          name: {
            title: 'Miss',
            first: 'Jennifer',
            last: 'Alvarez'
          }
        }
      ]
    }
  });
  // now lets assign this mock function to axios.get
  axios.get = mockApiCall;
  // then we can click the button
  userEvent.click(loadButton);
  // and we expect to see this text on screen.
  // this is dependent onto async operation to complete
  // so to wait that api call to finish, we use this findBy...
  const userInfo = await screen.findByText("Miss Jennifer Alvarez");
  expect(userInfo).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

this test fails and you should be seeing a message like this

  ● Random User › displays title, first and lastname of loaded user from randomuser.me

    TestingLibraryElementError: Unable to find an element with the text: Miss Jennifer Alvarez. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.
Enter fullscreen mode Exit fullscreen mode

lets fix this.

// RandomUser.js

// importing axios, we will make api call
import axios from 'axios';
// we need to have a state
import { useState } from 'react';

export default function RandomUser(){
  // we have user object in state
  const [user, setUser] = useState();

  // this function will be handling the api call
  const loadRandomUser = async () => {
    try {
      const response = await axios.get('https://randomuser.me/api')
      // updates the user object with the loaded data
      setUser(response.data.results[0])
    } catch (error) {
    }
  }

  return (
    <>
      <button onClick={loadRandomUser}>Load Random User</button>
      // if we have user, lets display the name
      {user && (
        <h1>{`${user.name.title} ${user.name.first} ${user.name.last}`}</h1>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

after these changes test will pass.

With mocking, we have a predictable behavior in our application. If we test this on browser, we can see in each click, we receive different users.

But the downside of mocking is, now our test is highly coupled with our implementation detail. If we decide to replace axios with fetch, then our test needs to be refactored accordingly.

lets do that.

The fetch is coming with the browser. So to use it in our component we don't need to install anything. But in our test environment, which is running in node, it doesn't have fetch in it. So using fetch in application will cause problem on test part. To resolve this lets install another package. This is only needed for test modules.

npm i -D whatwg-fetch
Enter fullscreen mode Exit fullscreen mode

now lets import this one in our test and re-run tests.

// RandomUser.spec.js
import 'whatwg-fetch';
Enter fullscreen mode Exit fullscreen mode

But other than this import, lets do nothing on test. But lets use fetch in our component.

// RandomUser.js
  const loadRandomUser = async () => {
    try {
      const response = await fetch('https://randomuser.me/api');
      const body = await response.json();
      setUser(body.results[0]);
    } catch (error) {
    }
  }
Enter fullscreen mode Exit fullscreen mode

after these changes the tests are failing. But if we test this on browser, the user is properly loaded. So form user point of view, there is no difference.
But since our test is coupled with axios usage, it is broken now. We can update our mock functions in test to make our test pass. Or we can resolve it without mocking.

We are going to use the library Mock Service Worker - MSW
Lets install it

npm i -D msw
Enter fullscreen mode Exit fullscreen mode

We are going to use it in our test module.

// RandomUser.spec.js
// lets import these two functions
import { setupServer } from "msw/node";
import { rest } from "msw";

it('displays title, first and lastname of loaded user from randomuser.me', async () => {
// here we will create a server
  const server = setupServer(
    // and this server is going to be processing the GET requests
    rest.get("https://randomuser.me/api", (req, res, ctx) => {
      // and here is the response it is returning back
      return res(ctx.status(200), ctx.json({
        results: [
          {
            name: {
              title: 'Miss',
              first: 'Jennifer',
              last: 'Alvarez'
            }
          }
        ]
      }));
    })
  );
  // then..
  server.listen();
  // so at this step we have a server
  // after this part we don't need to deal with axios or fetch
  // in this test function
  render(<RandomUser />);
  const loadButton = screen.queryByRole('button', {
    name: 'Load Random User'
  });
  userEvent.click(loadButton);
  const userInfo = await screen.findByText("Miss Jennifer Alvarez");
  expect(userInfo).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

after this change, test must be passing.
Now our test is not dependent onto the client we are using. We can go back and use axios again.

const loadRandomUser = async () => {
  try {
    const response = await axios.get('https://randomuser.me/api')
    user = response.data.results[0];
  } catch (error) {
  }
}
Enter fullscreen mode Exit fullscreen mode

Tests must be passing with this usage too.

The mocking is a very good technique in scenarios where external services are taking place. With mocking we are able to create a reliable test environment. But the down side of it, our tests are being highly coupled with our implementation.
My choice is to avoid mocking if I can. And the msw library is great replacement for backend in client tests.

Resources

Github repo for this project can be found here

GitHub logo basarbk / dev-to-tdd-react

Repository of article project about test driven development with react published in dev.to

You can also check this video tutorial about similar topic

If you would be interested in a full TDD course on react, you can check my course at udemy React with Test Driven Development

💖 💪 🙅 🚩
basarbk
Basar Buyukkahraman

Posted on December 26, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Test Driven Development with React
react Test Driven Development with React

December 26, 2021