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);
})
Looking at my code, I can see the following outcomes:
- Fetch api data
- Fetch data with parameters specified
- Return the data if the call was successful
- Return an empty array if no data was received
- 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'
After importing it, we can overwrite the functionality of axios like this:
jest.mock('axios')
const mockedAxios = axios.get
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/'
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')
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' }],
},
}
Right! Let's begin our tests with a wrapping description:
describe('GetApiData() Source data so we can consume it', () => {
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()
})
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()', () => {
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)
})
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 ] })
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)
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
})
)
})
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'],
}
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,
},
})
})
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']
})
)
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
})
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)
})
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)