Testing Async JavaScript & API Calls

Agenda

  • Discuss async testing goals and pitfalls
  • Pseudocode tests for async code
  • Write tests using .then() promise resolution
  • Refactor tests to use async/await
  • Refactor fetch out of the component
  • Use manual mocks to update and refactor test suite

Learning Goals

By the end of this lesson, you will:

  • Know how to test React components that contain methods with async JavaScript
  • Understand how and what to test when making API calls with fetch

Vocab

  • mock
  • control flow
  • then
  • async/await

Testing Async JavaScript & API Calls

Getting Started

Follow along with a modified version of the grocery list application here.

Clone the repo, checkout the async-begin branch, and install the dependencies.

git clone git@github.com:turingschool-examples/grocery-list.git
git checkout async-begin
npm install

Open the code up in your editor.

Open two tabs in your terminal and run npm run server in one terminal window and npm start in the other terminal window to get started.

Testing API Calls

When our application makes a request to an API endpoint, we typically want to test our app’s reaction to the response it receives from that request. We don’t really care about what goes on in the back-end, we just want to know that we can handle the response appropriately. This makes API calls a good scenario for using mocks. However, we’re usually placing our fetch requests within other functions or methods, and we might not want to override the functionality of the entire method with a mock. Consider the following example from our AddGroceryForm Component:

// AddGroceryForm.js

handleAddGrocery(event) {
  event.preventDefault();
  const { updateGroceryList } = this.props;
  const { grocery } = this.state;

  return fetch('/api/v1/groceries', {
    method: 'POST',
    body: JSON.stringify({ grocery }),
    headers: {
      'Content-Type': 'application/json'
    }
  })
  .then(response => response.json())
  .then(groceries => {
    this.setState({
      grocery: {
        name: '',
        quantity: ''
      }
    }, updateGroceryList(groceries));
  })
  .catch(error => {
    this.setState({
      errorStatus: 'Error adding grocery'
    })
  });
}

If we would like to test this method, containing a fetch request, we’re going to run into some issues when it executes. Mainly, fetch won’t be available when running our tests in the console and we wouldn’t have access to the API endpoint. There are a bunch of libraries that you could use to handle this behavior, some that you’ll come across may include nock or fetch-mock. The thing is, Jest has some really great utilities for mocking built into it, so using an external library beyond Jest here really isn’t necessary.

Let’s take a closer look at the previous example. Building off of our Grocery List application, we’ve now added a back-end for persisting the grocery data we’re working with. When we submit a new grocery, we now make a POST request to our server to add that grocery item. We don’t want to override the entire handleAddGrocery method, but we do want to intercept that POST request so that we can return some fake data to work with.

Let’s start by adding a test file for this component named AddGroceryForm.test.js. At the top of our new test file, we’ll import the React and Enzyme dependencies, and the component we’re testing:

// AddGroceryForm.test.js

import React from 'react';
import { shallow } from 'enzyme';

import AddGroceryForm from './AddGroceryForm';

Before we start into writing our tests for this method, let’s first consider what we need to test in our function. In my mind the critical pieces fall into four categories. First, we should test that fetch is in fact called, with the correct parameters. Second, we need to test that the state of the component is correctly set after the fetch call is made. Third, we need to test that our callback, updateGroceryList, is called with the correct parameters. Finally, we should test that in the event of an error, our errorStatus state is set correctly. With that in mind, lets sketch out our four tests.

// AddGroceryForm.test.js

import React from 'react';
import { shallow } from 'enzyme';

import AddGroceryForm from './AddGroceryForm';

describe('AddGroceryForm', () => {
  it('calls fetch with the correct data when adding a new grocery', () => {
  })

  it('resets the state after adding a new grocery', () => {
  })

  it('calls the updateGroceryList callback after adding a new grocery', () => {
  })

  it('sets the error in state if the fetch fails', () => {
  })
})

Now that we have our test placeholders, let’s consider what we’ll need to mock to effectively test our handleAddGrocery method. We’re going to need a mockGrocery, to simulate the actual data that is being posted. We’ll need some mockGroceries to return from our fetch. We’re going to need a mockEvent, because our handleAddGrocery method is expecting an event as a param, and finally, we’re going to need a mockUpdateGroceryList function, to pass to our component as a param.

// AddGroceryForm.test.js

import React from 'react';
import { shallow } from 'enzyme';

import AddGroceryForm from './AddGroceryForm';

describe('AddGroceryForm', () => {
  let mockEvent
  let mockGrocery
  let mockGroceries
  let mockUpdateGroceryList

  beforeEach(() => {
    mockEvent = { preventDefault: jest.fn() }
    mockGrocery = { name: 'Oranges', quantity: 3 }
    mockGroceries = [
      {name: 'Pineapples', quantity: 10},
      {name: 'Oranges', quantity: 3}
    ]
    mockUpdateGroceryList = jest.fn()
  })

  it('calls fetch with the correct data when adding a new grocery', () => {
  })

  it('resets the state after adding a new grocery', () => {
  })

  it('calls the updateGroceryList callback after adding a new grocery', () => {
  })

  it('sets the error in state if the fetch fails', () => {
  })
})

Tangent: before & after blocks

Often times, you’ll want to start from scratch after every it block runs in your test files. For example, if we have more than one test that manipulates our component state, we might get some unexpected failures in later tests because we didn’t start fresh with a clean default state. This is where before and after hooks come in handy. There are four hooks we can use to run some setup code at different points in our testing lifecycle:

  • beforeAll - will run once before any it blocks have been run
  • afterAll - will run once after all it blocks have been run
  • beforeEach - will run before every single it block
  • afterEach - will run after every single it block

In the following examples, we’ll be using the beforeEach method to get a fresh instance of our renderedComponent.

Writing our tests

Now that we’ve mocked all the important data, let’s mock our fetch function itself. Remember, we need to override the behavior of fetch because we don’t want to actually make an API request. We can override the behavior of fetch really easily with Jest, using mockImplementation.

// AddGroceryForm.test.js

import React from 'react';
import { shallow } from 'enzyme';

import AddGroceryForm from './AddGroceryForm';

describe('AddGroceryForm', () => {
  let mockEvent
  let mockUpdateGroceryList
  let mockGrocery
  let mockGroceries
  let wrapper

  beforeEach(() => {
    mockEvent = { preventDefault: jest.fn() }
    mockUpdateGroceryList = jest.fn()
    mockGrocery = { name: 'Oranges', quantity: 3 }
    mockGroceries = [
      {name: 'Pineapples', quantity: 10},
      {name: 'Oranges', quantity: 3}
    ]
    wrapper = shallow(<AddGroceryForm updateGroceryList={mockUpdateGroceryList} />)
  })
    window.fetch = jest.fn().mockImplementation(() => Promise.resolve({
      json: () => Promise.resolve(mockGroceries)
    }))

  it('calls fetch with the correct data when adding a new grocery', () => {
  })

  it('resets the state after adding a new grocery', () => {
  })

  it('calls the updateGroceryList callback after adding a new grocery', () => {
  })

  it('sets the error in state if the fetch fails', () => {
  })
})

The fetch function returns a Promise, which resolves to a Response object. That Response object has a json() method on it, which itself returns a Promise. Using our Jest mock, it’s easy to recreate this behavior. Now that we’ve mocked everything. We can start writing our tests.

Our first test needs to assert that fetch was called with the expected parameters. Since the fetch params pull from the state of the component, we’ll need to set the state of our wrapper before we call the handleAddGrocery method.

// AddGroceryForm.test.js

it('calls fetch with the correct data when adding a new grocery', () => {
  const url = '/api/v1/groceries'
  const options = {
    method: 'POST',
    body: JSON.stringify({ grocery: mockGrocery }),
    headers: {
      'Content-Type': 'application/json'
    }
  }

  wrapper.setState({grocery: mockGrocery})
  wrapper.instance().handleAddGrocery(mockEvent)
  expect(window.fetch).toHaveBeenCalledWith(url, options)
})

Our second test asserts that the state of the component was properly set after our fetch call fired. We’ll want to step through the asynchronous behavior of the function to make sure the state gets reset after a successful fetch. If we take a look at our code, the handleAddGrocery function is not currently returning anything. However, if we update our code and return the fetch, handleAddGrocery is now returning a Promise that we can chain behavior to.

// AddGroceryForm.test.js

it('resets the state after adding a new grocery', () => {
  const expected = { name: '', quantity: '' }
  wrapper.setState({grocery: mockGrocery})

  // Execution and Expectation - this assumes that handleAddGrocery method returns a promise
  wrapper.instance().handleAddGrocery(mockEvent)
   .then(() => {
      expect(wrapper.state('grocery')).toEqual(expected)
    })
})

Our third test looks similar to our first. We need to step through the asynchronous behavior of a successful fetch to determine if our mockUpdateGroceryList was called with the correct param.

// AddGroceryForm.test.js

it('calls the updateGroceryList callback after adding a new grocery', () => {
  wrapper.instance().handleAddGrocery(mockEvent)
    .then(() => {
      expect(mockUpdateGroceryList).toHaveBeenCalledWith(mockGroceries)
    })
})

Our final test asserts that our catch statement sets the state correctly if the fetch call fails. However in order to simulate this failure, we’re going to need to mock our fetch call again to simulate a failed fetch.

// AddGroceryForm.test.js

it('sets an error when the fetch fails', () => {
  window.fetch = jest.fn().mockImplementationOnce(() => Promise.reject(
    new Error('Fetch failed')
  )

  wrapper.instance().handleAddGrocery(mockEvent)
    .then(() => {
      expect(wrapper.state('errorStatus')).toEqual('Error adding grocery')
    })
})

A note on error handling

When using fetch calls, there are two main times when we want to check for errors. The first is when our fetch rejects, which is the case we tested above.

The second is when the fetch doesn’t fail, but the response is not ok. In that case we should manually throw an error that we’ll catch elsewhere. We’ll show how that’s done later on in this lesson.

Refactoring to async/await

Awesome, now all the critical functionality of our addGrocery method is tested! Already though, you should be thinking that there may be an easier, or at least more succinct way of writing this code. Rather than chaining Promises, I’d like to use the new ES7 async/await syntax. Let’s lean on our new test suite to refactor our code.

// AddGroceryForm.js

async handleAddGrocery(event) {
  event.preventDefault();
  const { updateGroceryList } = this.props;
  const { grocery } = this.state;

  try {
    const response = await fetch('/api/v1/groceries', {
      method: 'POST',
      body: JSON.stringify({ grocery }),
      headers: {
        'Content-Type': 'application/json'
      }
    })
    const groceries = await response.json()
    this.setState({
      grocery: {
        name: '',
        quantity: ''
      }
    }, updateGroceryList(groceries));
  } catch(error) {
    this.setState({
      errorStatus: 'Error adding grocery'
    })
  };
}

Using async/await with try/catch allows us to await all our asynchronous behavior. Should any of our awaited Promises fail, they will be caught by the catch statement. In this example, our code is now moderately more terse, and I would say a fair bit more readable. Let’s use this new syntax to now update our tests.

Our first test is unchanged, as there is nothing asynchronous happening. In small groups, work to refactor the rest of the tests, using the ES7 async/await syntax.

Refactoring fetch(es) into a separate file

While this works for us now, I’m not totally satisfied with our workflow here. Making naked fetch calls inside of our component code isn’t ideal, for a couple reasons.

  1. For starters, if we had more than one fetch call inside this component, it would get harder to correctly mock them when testing our components.
  2. Additionally, if another component in our application needs to POST to our ‘/api/v1/groceries’, we’d need to rewrite our fetch call.

Instead of doing this, I prefer to pull all of my API calls into their own file. This will make it easier to test the behavior of these fetch calls, as well as how our component digests the data.

Let’s create a new file that will hold our API calls, named apiCalls.js, as well as an accompanying test file, apiCalls.test.js.

$ touch src/apiCalls.js src/apiCalls.test.js

Writing the fetch in apiCalls.js

Let’s write our function! Remember, we are replacing the fetch from our handleAddGrocery function.

// apiCalls.js

export const addGrocery = grocery => {
  return fetch('/api/v1/groceries', {
    method: 'POST',
    body: JSON.stringify({ grocery }),
    headers: {
      'Content-Type': 'application/json'
    }
  })
  .then(response => {
    if(!response.ok) {
      throw Error('Error adding grocery')
    } else {
      return response.json()
    }
  }
}

Take a second and compare this with the fetch in the updateGroceryList method in AddGroceryForm. What’s the same? What’s different?

Notice that our fetch is also parsing the json!

Testing the fetch in isolation

Let’s test this fetch now that it’s not stuck inside our AddGroceryForm component code.

// apiCalls.test.js

import { addGrocery } from './apiCalls'

describe('addGrocery', () => {
  let mockGrocery
  let mockGroceriesResponse

  beforeEach(() => {
    mockGrocery = { name: 'ice cream', quantity: '5000' }
    mockGroceriesResponse = [
      { name: 'ice cream cones', quantity: '3000' },
      mockGrocery
    ]

    window.fetch = jest.fn().mockImplementation(() => {
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve(mockGroceriesResponse)
      })
    })
  })

  // fetch called w/ correct params
  it('should be called with correct params', () => {
    const expected = [
      '/api/v1/groceries',
      {
        method: 'POST',
        body: JSON.stringify({grocery: mockGrocery}),
        headers: {
          'Content-Type': 'application/json'
        }
      }
    ]

    addGrocery(mockGrocery);

    expect(window.fetch).toHaveBeenCalledWith(...expected);
  })

  // returns a response if the status is ok
  it('should return a parsed response if status is ok', async () => {
    const result = await addGrocery(mockGrocery);

    expect(result).toEqual(mockGroceriesResponse);
  })

  // return an error if the status is not ok
  it('should return an error if status is not ok', async () => {
    window.fetch = jest.fn().mockImplementation(() => {
      return Promise.resolve({
        ok: false
      })
    })

    await expect(addGrocery()).rejects.toEqual(Error('Error adding grocery'))
  })
})

Our helper method addGrocery uses a similar mocking strategy as before. This time, I’m also adding a status code to the resolved object. This will exist on the response object as well, and will be considered ‘ok’, if the status is less than 400. Thus, if the status code is less than 400, my helper function should resolve to an array of grocery objects; otherwise, I should expect an error. Note the resolves/rejects happening in the test. These expectation helpers are built into Jest, and allow you get the resolved or rejected values from asynchronous functions.

Refactoring AddGroceryForm to use our new, isolated fetch

With our new reusable function, our component method now knows nothing of fetch, and instead await’s our asynchronous function from apiCalls.js.

Notice that we don’t need to parse our response, because we’re parsing it before we send it!

// AddGroceryForm.js

import { addGrocery } from './apiCalls.js'

async handleAddGrocery(event) {
  event.preventDefault();
  const { updateGroceryList } = this.props;
  const { grocery } = this.state;

  try {
    const groceries = await addGrocery(grocery)
    this.setState({
      grocery: {
        name: '',
        quantity: ''
      }
    }, updateGroceryList(groceries));
  } catch(error) {
    this.setState({
      errorStatus: 'Error adding grocery'
    })
  };
}

Now that we’ve isolated and tested our fetch functionality, testing our component method handleAddGrocery is simplified, because we can mock the response from our new addGrocery function. We no longer need to test that fetch is being called in the component tests, we only need to test that the data is handled correctly after the function is called.

To facilitate this, we’re going to create a mock, which will override the addGrocery helper method we just created.

Using Jest’s mockImplementationOnce helper, we can control what is returned from our function each time it is called. This greatly simplifies our three tests. When we call jest.mock('./apiCalls'), jest overwrites any functions that are found in apiCalls.js with whatever we specify in the second argument to that mock function call.

// AddGroceryForm.test.js

import React from 'react'
import { shallow } from 'enzyme'
import AddGroceryForm from './AddGroceryForm'
import { addGrocery } from '.apiCalls'
// The line below is what allows us to mock addGrocery and make it return our mockGroceries array
jest.mock('./apiCalls')

describe('AddGroceryForm', () => {
  let mockGrocery
  let mockGroceries
  let mockUpdateGroceryList
  let mockEvent
  let wrapper

  // We add a beforeAll to mock addGrocery, ensuring that it returns our mockGroceries array when it's called
  beforeAll(() => {
    addGrocery.mockImplementation(() => mockGroceries);
  })

  beforeEach(() => {
    mockGrocery = { name: 'Oranges', quantity: 3 }
    mockGroceries = [{ name: 'apple', quantity: 12 }, mockGrocery]
    mockUpdateGroceryList = jest.fn()
    mockEvent = { preventDefault: jest.fn() }
    wrapper = shallow(<AddGroceryForm updateGroceryList={mockUpdateGroceryList} />)
  })

  // We no longer need to test that the fetch is happening! We tested the fetch in isolation in apiCalls.test.js already!

  it('resets the state after adding a new grocery', async () => {
    wrapper.setState({grocery: mockGrocery})
    await wrapper.instance().handleAddGrocery(mockEvent)
    expect(wrapper.state('grocery')).toEqual({name: '', quantity: ''})
  })

  it('calls the updateGroceryList callback after adding a new grocery', async () => {
    await wrapper.instance().handleAddGrocery(mockEvent)
    expect(mockUpdateGroceryList).toHaveBeenCalled()
  })

  it('sets an error when the fetch fails', async () => {
    // We have to overwrite the original mock of addGrocery to make sure that it sends back an error!
    addGrocery.mockImplementation(() => {
      throw new Error('Error adding grocery')
    });
    await wrapper.instance().handleAddGrocery(mockEvent)
    expect(wrapper.state('errorStatus')).toEqual('Error adding grocery')
  })
})

Moving the fetch into its own file and testing it in isolation allows us to streamline our component tests.

We make sure that the fetch works in its own test (apiCalls.test.js), and we make sure that the overall expected functionality works in our component tests (AddGroceryForm.test.js).

Final thoughts

As a rule of thumb, code is easier to test when it is doing less. By separating our API calls from component code, it’s easier to test the expected behavior of both pieces. By using the mocking and asynchronous expectations that are available in Jest, it’s easy to mimic the behavior of an API, and ensure that your application responds as you expect it should.

Your turn

This isn’t the only fetch call which needs to be tested. Notice the fetch call that’s happening in the componentDidMount method of App.js? With a partner, extract that functionality into a helper method, and test both the helper method, and the App, using the same patterns we just went over.

Instructor Notes

Lesson Search Results

Showing top 10 results