DEV Community

Ben Read
Ben Read

Posted on • Edited on

How to test JavaScript API Calls

In the January 2020 issue of Net Magazine, we walked through how to use React testing library to write basic unit tests for your React components. In this article I'm going to dive a little deeper and show how to write tests for some code that fetches data from an API.

This article was originally published in issue 330 of Net Magazine by Future Publishing. I'm re-publishing it to Dev.to as a test to see if it's useful. Please let me know if it is (or isn't!) by posting to the comments below.

This is an important distinction from what we covered previously because writing tests for UI components is very different from tests like this, and I hope that you'll learn some more things to help you ensure that all of your code is production ready, which will give you and your stakeholders more confidence when publishing new code.

Step 0. Decide What to Test

Before we even begin writing tests it's good to decide what needs to be tested. We need to set clear boundaries before we begin, otherwise we could waste time writing tests unnecessarily. Read through your code and see what different outcomes might be generated by your code.

In our example of fetching data from an API, the API call could be successful, that counts as one outcome. But what if it's not successful? And what should happen if the call is successful, but it returns no data? That's three different possible outcomes already!

Let's look at our imaginary API call to see what outcomes exist. Here's the code we're going to test:

import env from "./ENV"
import axios from "axios"


const getApiData = (parameters = {}, domain = env.domain) => axios.get(`${domain}/api/v1/data/?${parameters}`)
  .then(function (response) {
    // handle success
    if (!Array.isArray(data) || !data.length) {
      return []
    }
    return data
  })
  .catch(function (error) {
    // handle error
    console.log(error);
})
Enter fullscreen mode Exit fullscreen mode

Looking at my code, I can see the following outcomes:

  1. Fetch api data
  2. Fetch data with parameters specified
  3. Return the data if the call was successful
  4. Return an empty array if no data was received
  5. Log an error if the request was unsuccessful

Looking at your code in the beginning like this often reveals other issues to you that you may not have noticed before, and which prompts you to revisit your original code and improve it.

Let's handle the first 4 tests first, then come back to the last two and see how we can improve our code.

To begin, I'll create a new file to write my tests in. The name of the file is usually the same as the module. So if my module is called GetApiData.js, my test should be GetApiData.test.js.

Setup and Mocking

1. Mock the API

Although this test is about fetching data from the API, I don't want to actually call the data from the API. There are several reasons for this: Primarily, it's because I'm not testing the API, I'm testing the code I have written. But also there could be a cost involved each time I contact the API, I don't want or need that cost to be incurred. Finally, I don't want to wait for the API query to resolve for my tests to finish!

To do that, I'm going to "mock" this function. When you "mock" something you essentially overwrite the function with a fake function. Let's first import the code that was written to fetch data from that API, and also the library that we used to connect to the API, Axios:

import GetApiData from './GetApiData'
import axios from 'axios'
Enter fullscreen mode Exit fullscreen mode

After importing it, we can overwrite the functionality of axios like this:

jest.mock('axios')
const mockedAxios = axios.get
Enter fullscreen mode Exit fullscreen mode

Now, every time we call GetApiData in this file, and that calls Axios, it'll use our mocked implementation. Using it in the variable mockedAxios will help us identify clearly what we're doing when we write our tests.

The last thing we want to set up in regard to our API is the domain. This would be a parameter that is passed via our configuration, or part of our environment variables. But we're not testing our environment variables, so we should mock that domain too:

const domain = 'http://fakeapi.com/'
Enter fullscreen mode Exit fullscreen mode

2. Mock the console

The next thing we want to mock is what we would have used in our code to log out errors: console.log(), for similar reasons we mentioned above: we're not testing the functionality of the console. Also, we don't want to actually log the errors to the console as we're running tests, but instead somewhere we can test the output.

const mockedConsole = jest.spyOn(global.console, 'error')
Enter fullscreen mode Exit fullscreen mode

By using Jest's SpyOn function, we can examine when that function was called, and what it was called with ... it's actually is a spy function, reporting back to us (thankfully!).

3. Mock the data that should be returned

Finally, because we're not contacting the api, we need to provide mocked data to test against as if though it did:

const mockedDataOne = {
  id: 1234,
  title: 'Super Blog Post',
  categories: ['1'],
  _embedded: {
    'term': [[{ name: 'Category' }]],
    author: [{ name: 'Author' }],
  },
}
const mockedDataTwo = {
  id: 165,
  title: 'Super Post Two',
  categories: ['2'],
  _embedded: {
    'term': [[{ name: 'Category' }]],
    author: [{ name: 'Author' }],
  },
}
Enter fullscreen mode Exit fullscreen mode

Right! Let's begin our tests with a wrapping description:

describe('GetApiData() Source data so we can consume it', () => {
Enter fullscreen mode Exit fullscreen mode

4. Clean ups

Last piece of setup here: we want to reset our mocked API call and console log before each new test, otherwise we'll have stale data left over from the previous test, which could cause subsequent tests to fail:

beforeEach(() => {
    mockedAxios.mockReset()
    mockedConsole.mockReset()
})
Enter fullscreen mode Exit fullscreen mode

Right, now we've set up our tests, and mocked the important stuff, let's dive into our first test ...

Test 1: Fetch api data

Let's begin our tests with a wrapping description:

describe('GetApiData()', () => {
Enter fullscreen mode Exit fullscreen mode

This wrapping function describes the component, or makes a short statement to help us understand what these tests are for. If your function name adequately describes what it does, and you don't need a longer description, that's a good sign that you have named your function well!

it('Should get api data', async () => {
    mockedAxios.mockResolvedValueOnce({ data: [{ test: 'Hi I worked!' }] })
    const data = await getApiData(domain)
    expect(mockedAxios).toBeCalledTimes(1)
})
Enter fullscreen mode Exit fullscreen mode

First thing to note: this is an asynchronous function! axios.get is already an async function so it makes sense to test it asynchronously too. It's best to make api calls async because you have a callback even if something fails, rather than the request simply hanging indefinitely, which is bad for user experience.

mockResolvedValueOnce() is a built-in function in Jest that, well, mocks the resolved value of the API call just once.

Here we're mocking the result of the mocked axios call. We're not testing the contents of the data, so I've just added a dummy object to the result of the mockResolvedValueOnce() function, since that's adequate for what we're testing.

You can now run this test, and you should see 1 passing test. Go you!

So ... it worked! We can stop there right?

Well ... how do we know our code contacted the right API endpoint? How do we know it sent the correct parameters, if we need any?

Test 2: Return the data if the call was successful

Our next test will check that we have the data we expected in the return value of the GetApiData() function:

it('Should get data from the api', async () => {
    mockedAxios.mockResolvedValueOnce({ data: [ mockedDataOne, mockedDataTwo ] })
Enter fullscreen mode Exit fullscreen mode

This time we're mocking the return value containing the two objects we originally set up.

    const data = await getApiData(domain)
    expect(mockedAxios).toBeCalledTimes(1)
Enter fullscreen mode Exit fullscreen mode

Just as before, I like to check that we did actually call the mockedAxios function. Next I'm going to check one of the data objects to make sure it has the same id as mockedDataOne:

  expect(data[0]).toEqual(
  expect.objectContaining({
      id: mockedDataOne.id
    })
  )
})
Enter fullscreen mode Exit fullscreen mode

You could do more tests, perhaps making sure that data[1] also has the corresponding ID, but this is enough to convince me that the data is returning correctly.

Now this does seem a little ... "circular" at first. You might think "of course it contains it! That's what you told it to contain!", but think about it for a minute: we haven't just returned that data. We've used our preexisting code (minus the actual API calls and real data) to return it. It's like throwing a ball, then our code caught it, and threw it back.

If nobody threw our ball back, then something is very wrong with the code we're testing: it's not working as we expected.

Test 3: Fetch data with parameters specified

Here's our next assertion. We want to make sure our code passed the parameters we wanted, and returned the value we expected.

  it('should get data using parameters', async () => {
    const params = {
      categories: ['2'],
    }
Enter fullscreen mode Exit fullscreen mode

So this time our params contain an array specifying category 2 should be fetched. Remember we mocked some data in our setup? How many of those mocked data sets has the category of 2? Only one of them:mockedDataTwo.

    mockAxios.mockResolvedValueOnce({ data: mockedDataTwo })
    await GetApiData(domain, params)

    expect(mockAxios).toHaveBeenCalled()
    expect(mockAxios).toBeCalledWith(`${domain}/api/v1/data/`, {
      params: {
        categories: params.categories,
      },
    })   
  })
Enter fullscreen mode Exit fullscreen mode

Okay, so if this test passes, our code is passing the categories correctly. Great! But does the data reflect that?

    expect(data[0]).toEqual(
      expect.objectContaining({
        categories: ['2']
      })
    )
Enter fullscreen mode Exit fullscreen mode

If this test passes, then great! We have successfully obtained data with the correct parameters.

Another check to do here is that the data only contains items with this category, and not any other. I'll leave that one for you to figure out.

These next two tests are to verify we have captured two significant branches, or outcomes, of our code: failures.

Test 4: Return an empty object if no data was recieved

If there hasn't been any data sent back to us after the API call, we have returned an array as a fallback so that we don't have an exception in our data layer. that can be used by our UI to provide a fallback - once the API call has been resolved.

it('Should return an empty array if no data was recieved', async () => {

    const data = await GetApiData(domain, params)
    mockAxios.mockResolvedValueOnce({ data: null })

    expect(mockAxios).toBeCalledTimes(1)
    expect(Array.isArray(data)).toBeTruthy
})
Enter fullscreen mode Exit fullscreen mode

We're mocking a data object with a null value here to represent no values being returned from the API call. We're using Array.isArray because that is far more robust than using isArray, which is an older method that returns true for a number of different cases (don't ask...).

Test 5: Log an error if the request was unsuccessful

Logging errors is a vital part of a robust application. It's a great way of being able to respond to API failures or application exceptions before users get to see them. In this test, I'm just going to check for a console.log() call, but in a production app, there would be an integration with some external logging system that would send an email alert to the dev team if it was a critical error:

Our final test uses our consoleMock from our initial setup (see above):

  it('Should log an error if the request was unsuccessful', async () => {
    const error = new Error('there was an error')

    mockAxios.mockRejectedValue(error)
    await GetApiData(domain)

    expect(mockAxios).toBeCalledTimes(1)
    expect(mockedConsole).toBeCalledTimes(1)
    expect(mockedConsole).toBeCalledWith(error)
  })
Enter fullscreen mode Exit fullscreen mode

the consoleMock function allows us to mock the functionality of the console.log object. Because we're testing that an error is thrown by our code, we need to use the Error object to test the output correctly.

So there we are ... we now have a suite of tests to give us more confidence that our code is production ready ... as long as the tests don't fail in our pipeline, we can be confident that we have met the core criteria for our GetApiData function.

Conclusion

There's a lot to these functions and it can take quite a bit of time to get used to writing this much code:- more than our actual function! But what is the price of confidence? ... if you think about it, by spending the time writing this code, we could have saved our company hundreds of thousands of pounds from lost income if it was broken!

I would say that thoroughly testing your code is an important step, along with static typing, quality checking, and pre-release validation, to ensuring that your code is indeed production ready!


Boxout: The price of confidence

Developers will spend more time writing tests than writing the components they’re building. That makes sense if you think about it: you need to test every possible outcome of the code that’s being written. As is demonstrated in this article, one API call with some basic functionality can result in a number of differing outcomes.

The benefit of adding tests to your code can easily override the time spent by developers following this practice. If your business or customers needs the confidence that things won’t break, then testing is definitely a good practice to introduce at the start of a project.

Other ways that testing can benefit a project include during refactors. Often project requirements will change after the code has been written. That introduces more risk into the codebase because on revisiting the code a developer might decide to refactor to make it simpler … which could include deleting things that were actually needed! Looking at the test serves as documentation: developers can see that there was a decision behind every code outcome that has been written.


Boxout: Scoping outcomes

The hardest part of finding out what to test is knowing what your code actually does. This becomes harder with the more time that passes between when you write tests to when you write the actual code. So I recommend writing tests alongside the component, or even before you write your component.

When you’re doing this you’ll be more clearly able to think about all of the different outcome possibilities that your code offers: what variables might change? What different return values are possible?

I’ve used an API call in this example because there’s plenty of variety in what can happen … but I’ve still missed out one valuable test … can you spot which test I haven’t done?

Top comments (0)