Testing Asynchronous React

Learning Goals

  • Understand how and why we test asynchronous JavaScript in React
  • Know how to test React components that contain methods with asynchronous JavaScript
  • Understand how and what to test when making API calls with fetch
  • Be able to write tests using async/await syntax

The Current State of Things

Let’s assume we have a React application that is fetching data when the App component mounts. The app we’re thinking about is a classic ideabox that holds onto our best future ideas. Pseudocode for a test in this application might look something like:

it('Can view all the ideas when the app loads', () => {
  // Render the App component (this component fetches data from an external back-end API)

  // Check that there is a container element on the page

  // Check that there are ideas on the page
});

Turn & Talk

Discuss with a partner:

  • Why will this test fail?
  • What about this test is wasteful?

What’s Going On With This Test?

  • The test will fail because the test does not know to wait for the fetch call to be done before it checks for the ideas on the page.
  • The test is actually fetching data from the back-end API, which should not be necessary. Imagine if we had to pay for requests to the back-end API…we don’t want to waste money every time we run our tests.

Jest and jsdom

The testing library we use with React (Jest) comes with a library by default called jsdom. This library simulates the browser environment when we run our tests, which means that every browser API we use in our app can be used when we run our tests. Browser APIs like Date.now(), fetch(), document.querySelector(), etc are available to use while we run our tests. This is amazing! But it’s also something we want to avoid sometimes. In particular, we want to avoid actually making network requests in our tests (we’ll get into that later).

On Your Marks: Our Goal

We need to:

  1. Tell our tests to wait for certain things to happen (like wait until data is returned from a network request)
  2. Tell our test to not make a network request, but instead fake the network request

Get Set: Setup

Shut down all other servers that you currently have running, front-end and back-end.

Set up the front-end repo:

git clone https://github.com/turingschool-examples/ideabox-testing-rtl.git testing-async-react
cd testing-async-react
npm install
npm start

Run the test suite given by create-react-app to make sure it works:

npm test

If you get an fsevents error…

Try running npm install fsevents@1.2.11 to install a specific version of fsevents and then run npm test again to see if that worked.

Set up the back-end repo. You might already have this cloned down, and in that case find it and get it started.

git clone https://github.com/turingschool-examples/ideabox-api.git
npm install
node server.js

Make a Request for the Idea Data

Right now, the app does not make a network request to the back-end for the ideas.

One Your Own

In the App component, use fetch and componentDidMount to bring all the ideas from the back-end API into the App’s state.

Make sure that the ideas from the back-end API render to the page.

Write a Test to Verify Network Request

Setup the Testing Libraries

With a partner:

  • Install React Testing Library
  • Install the jest-dom library

Setup Needed

Run npm install --save-dev @testing-library/react to install React Testing Library

Run npm install --save-dev @testing-library/jest-dom to install jest-dom

Now that the testing libraries are added to the app, let’s get a test going.

Add A Failing Test

Remember the test pseudocode we started out with? Let’s actually write that test - it’s ok if it fails right now. In fact we expect it to fail.

Use the pseudocode to guide your test code.

it('Can view all the ideas when the app loads', () => {
  // Render the App component (this component fetches data from an external back-end API)

  // Check that there is a container element on the page

  // Check that there are ideas on the page
});

First Failing Test

it('Can view all the ideas when the app loads', () => {
  // Render the App component (this component fetches data from an external back-end API)
  render(<App />);
  // Check that there is a container element on the page
  const ideasContainer = screen.getByText("Ideas Component");
  const idea = screen.getByText("Sweaters for pugs");
  // Check that there are ideas on the page
  expect(ideasContainer).toBeInTheDocument();
  expect(idea).toBeInTheDocument();
});

Go: Fix Our Tests

Back to our first goal: Tell our tests to wait for certain things to happen (like wait until data is returned from a network request).

DOM Testing Library has a way to help us out with waiting for certain things to happen.

Head to the Docs and Try Something!

Look into the Async Utilities docs and read through these.

Our problem is that we need to know when the fetch is done and wait for that. Is there anything there we can use to help us with this problem?

Try something out. How do we know the fetch is done? Can we wait for anything to show up on the page that can tell us the fetch is complete?

Seeing Errors?

If you’re getting the error TypeError: MutationObserver is not a constructor while using waitFor, it’s not your fault.

Depending on the day you are writing your test, the version of Jest that comes with create-react-app might be different from the version that React Testing Library expects you to have.

Here is how to solve it for now:

Run npm install --save-dev jest-environment-jsdom-sixteen to install a more recent version of Jest.

Change the test script in the package.json file to be: "test": "react-scripts test --env=jest-environment-jsdom-sixteen"

The reason behind this is listed as a breaking change in these release notes.

A Passing Test!

describe('App', () => {
  it('Can view all the ideas when the app loads', async () => {
    // Render the App component (this component fetches data from an external back-end API)
    render(<App />);
    // Check that there is a container element on the page
    const ideasContainer = screen.getByText("Ideas Component");
    // Wait for an idea to appear on the page
    const idea = await waitFor(() => screen.getByText("Sweaters for pugs"));
    // Check that there are ideas on the page
    expect(ideasContainer).toBeInTheDocument();
    expect(idea).toBeInTheDocument();
  });
});

What is the async keyword needed for? What does await do?

The network request is still happening in the background. We’ll take care of that next.

No More Network Requests

Let’s return to our second goal: Tell our test to not make a network request, but instead fake the network request

To fake the network request, we need to mock the network request. The best way to mock something is to overwrite an existing function using a jest.fn(). The issue we need to solve now is how to overwrite the fetch call without always overwriting the entire implementation of fetch. To do that, we need to isolate any network request we make to its own function.

Isolate the Fetch Call

In the App component, we have fetch being invoked in componentDidMount. Spend some time taking this network request and putting it into it’s own function in a file called apiCalls.js that lives in the src directory.

With the code you have written in componentDidMount so far, you’ll have to play around with how much code you can pull out of App and how much needs to stay in the component.

If you have isolated the fetch call, make sure your test still passes.

An Isolated Fetch Call

// src/apiCalls.js
export const getIdeas = () => {
  return fetch('http://localhost:3001/api/v1/ideas')
    .then(response => response.json())
};

// App.js
// only looking at componentDidMount
componentDidMount() {
  getIdeas()
    .then(ideas => this.setState({ ideas }))
    .catch(err => console.error(err.message))
}

Mock the Function

Now that the function with the network request is isolated, we can focus on mocking it. In the future, this apiCalls file could have many network requests for posting and/or deleting ideas. So we will tell Jest to mock the entire file.

We need to add this to our App’s test file before we write any it blocks:

import { getIdeas } from '../apiCalls';
jest.mock('../apiCalls.js');

What is this doing? jest.mock(filename) is going into that file and making everything in that file a jest.fn(), which allows us to overwrite any of those functions behavior.

Ultimately, the behavior of the getIdeas function is to make a network request and return a promise with a resolved or rejected value. If we assume a happy path, the function returned a resolved promise with the resolved value being the array of ideas from the API.

[
  {id: 1, title: 'Sweaters for pugs', description: 'To keep them warm'},
  {id: 2, title: 'Film a romcom', description: 'But make it ghosts'},
  {id: 3, title: 'A game show called Ether/Or', description: 'When you lose you get chloroformed'},
]

In our test, now that the getIdeas function is a jest.fn(), we can overwrite its behavior. To do this, we can mock what the resolved value of the function should be in the test:

// at the top of the test

getIdeas.mockResolvedValueOnce([
  {id: 1, title: 'Sweaters for pugs', description: 'To keep them warm'},
  {id: 2, title: 'Film a romcom', description: 'But make it ghosts'},
  {id: 3, title: 'A game show called Ether/Or', description: 'When you lose you get chloroformed'},
]);

Now turn off your back-end API, and you should still have a passing test!

Mocked Network Request

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

import { getIdeas } from '../apiCalls';
jest.mock('../apiCalls.js');

describe('App', () => {
  it('when the App loads, we should see an idea', async () => {
    getIdeas.mockResolvedValueOnce([
      {id: 1, title: 'Sweaters for pugs', description: 'To keep them warm'},
      {id: 2, title: 'Film a romcom', description: 'But make it ghosts'},
      {id: 3, title: 'A game show called Ether/Or', description: 'When you lose you get chloroformed'},
    ]);

    render(<App />);

    const ideaContainer = screen.getByText('Ideas Component');

    const idea = await waitFor(() => screen.getByText('Sweaters for pugs'));

    expect(ideaContainer).toBeInTheDocument();
    expect(idea).toBeInTheDocument();
  });
});

Another Scenario

Write Another Test

Consider the scenario where the API returns an empty array of ideas.

Write another test to mock this scenario. Change the apps behavior to render a message like “No ideas yet!” on the page if the ideas state is an empty array.

Be sure to write an assertion to check for the message on the page.

Posting a New Idea

This one will take a little longer from start to finish.

Stretch Goal: Add Posting Functionality

Add functionality into the app so that when you submit a new idea, it is posted to the back-end API and displays on the page.

Then add a test for that scenario using the same order of operations we did for mocking getIdeas.

Sad Path Possibilities

In your apps, consider the scenarios where:

  • What if the network doesn’t give a response…?
  • What if the response is empty?
  • What if the server is down?

For a rejected promise, you can use mockRejectedValueOnce to mock that scenario.

Resources

Lesson Search Results

Showing top 10 results