Mocking HTTP Calls In Tests With Jest and MirageJS
Tests should be independent and isolated from external dependencies. That, of course, includes services that need to be called over a network connection. This means we end up needing to mock those calls over the network. In tests for front end code, this usually means mocking fetch
or a library like axios
.
Mocking our HTTP client of choice works and technically isolates that test from an external dependency. I don’t think eliminating the dependency by having to implement a test version is worth it. For me, the real goal here is to remove the network and ensure known responses.
If we can accomplish those two things without having to mock out everything these libraries do for us, I think we should. As luck would have it, there are several libraries out there that can help us with this. In this post, we’re going to look at MirageJS to mock out our API for some tests.
The Goal
We’re going to write tests for a set of service functions that call a non-existent back end service. Rather than writing cumbersome mocks for fetch
or axios
, we’ll configure Mirage to run a mock server for our tests. With the mock server, we’ll prevent any actual network calls from being made and control the service responses. We’ll start by walking through how to mock responses for the happy path and then we’ll look at an example of testing for service errors.
Initial Setup
The Project
To keep things simple, I’m going to start with a vanillaJS project on CodeSandbox. I’ll add both axios
and MirageJS as npm
dependencies. You could to the same thing locally on your machine with your preferred project starter.
Our examples will be based on everybody’s favorite JavaScript example, a task service. These techniques can be applied to anything, using something simple and familiar reduces cognitive load.
Our Service Function
We’ll start by adding a file to hold our implementation code. To keep this example simple, I’m just going to put both files we’ll be working with directly in the project’s /src
directory.
We’ll start with our first service function. We’re using axios.get
to call /api/tasks
and return the data. For now, we’ll focus on the happy path.
src/taskService.js
import axios from 'axios'
export async function getTasks() {
const { data } = await axios.get('/api/tasks')
return data
}
Setting Up Our Tests
Let’s start by importing our service function and creating a skeleton test with a describe
and it
. Note that the callback for it
is an async
function.
src/taskService.test.js
import { getTasks } from './taskService'
describe('task service', () => {
it('returns the data from the service', async () => {
// test code here...
})
})
Writing The Test
Arrange, Act, Assert
We’ll follow the “arrange, act, assert” pattern for our test. Let’s start by defining some sample data that we expect to get back from our service function. Then we’ll make a call to our getTasks
function and assign the return value to result
. Finally, we’ll assert that our result
is equal to data
.
it('returns the data from the service', async () => {
// arrange
const data = [
{ id: 1, name: 'test 1', isComplete: false },
{ id: 2, name: 'test 2', isComplete: true },
]
// act
const result = await getTasks()
// assert
expect(result).toEqual(data)
})
At this point, running this test will result in an error like the one below.
expect(received).toEqual(expected) // deep equality
Expected: [{"id": 1, "isComplete": false, "name": "test 1"}, {"id": 2, "isComplete": true, "name": "test 2"}]
Received: "<!DOCTYPE html>
<html>·
...
We’re getting this error because our call to axios.get('/api/tasks')
is actually attempting to connect to /api/tasks
relative to our app root. Since that isn’t defined, and I’m running the vanilla template on CodeSandbox, Parcel is responding to that non-existent path with the root HTML file.
Setting up the Mock Server
In order to avoid this network call and subsequent failure, let’s get our Mirage server setup.
We’ll start by importing createServer
into our test file and creating a server
variable at the top-level of the file (not inside a describe
or it
).
src/taskService.test.js
+import { createServer } from 'miragejs'
import { getTasks } from './taskService'
+let server
describe('task service', () => {
Then in our describe
block, we’ll add a beforeEach
as well as an afterEach
. The beforeEach
will create and start our mock server and the afterEach
will be responsible for shutting it down. This way we get a fresh server instance for every test.
beforeEach(() => {
server = createServer({ environment: 'test' })
})
afterEach(() => {
server.shutdown()
})
If we run our test now, it’ll still fail, but it should fail for a new reason, and that means we’re making progress! You should now be seeing an error from Mirage about not having a route defined for the GET
call our service makes.
Mirage: Your app tried to GET '/api/tasks', but there was no route defined to handle this request. Define a route for this endpoint in your routes() config. Did you forget to define a namespace?
Apply Mocking to the Test
Now that we know Mirage is intercepting our request, it’s time to let Mirage respond to the request. This part is really straightforward. In the test, we’ll define a mock response for a GET
to /api/tasks
by adding this line of code before our call to getTasks()
server.get('/api/tasks', () => data)
So now our full test file will look like this, and our test will pass 🎉!
import { createServer } from 'miragejs'
import { getTasks } from './taskService'
let server
describe('task service', () => {
beforeEach(() => {
server = createServer({ environment: 'test' })
})
afterEach(() => {
server.shutdown()
})
it('returns the data from the service', async () => {
const data = [
{ id: 1, name: 'test 1', isComplete: false },
{ id: 2, name: 'test 2', isComplete: true },
]
server.get('/api/tasks', () => data)
const result = await getTasks()
expect(result).toEqual(data)
})
})
Testing Errors
It would be nice if we only ever had to worry about the happy path in our code, but that just isn’t the reality any of us live in. Since we have control over server responses, it’s fairly straightforward to mock server errors and test that our code handles those errors properly.
Since we have Mirage setup and ready to go, let’s work through error handling in a test first fashion. We’ll create the test that sets up the error scenario and then we’ll update the service code to meet our expectations.
Testing For Errors
On an API error, we want to catch it, log the error in some way and then let the consuming code know that there was an error. We’ll leave the logging piece out for now to keep the examples small, but we will catch the error and throw a new, custom error.
Let’s create a new test that mocks the GET
(we’ll implement this mock next) and expects the function to reject with our “custom” error new Error('Boom!')
.
it('Throws a custom error on service error', async () => {
server.get('/api/tasks/', () => {
// TODO: Make it reject!
})
await expect(getTasks()).rejects.toEqual(new Error('Boom!'))
})
In order to make the API call fail, we’ll update our import for Mirage to pull in the Response
object.
-import { createServer } from 'miragejs'
+import { createServer, Response } from 'miragejs'
Then we can use that Response
object to return an error from the mock server.
it('Throws a custom error on service error', async () => {
server.get('/api/tasks/', () => {
+ return new Response(502, {}, { errors: ['Bad Gateway'] })
})
await expect(getTasks()).rejects.toEqual(new Error('Boom!'))
})
We’ll respond with a 502 Bad Gateway
error. When we run this test, it should fail because we’re getting the error that axios
returns from the 502
response and not our Boom!
error.
expect(received).rejects.toEqual(expected) // deep equality
Expected: [Error: Boom!]
Received: [Error: Request failed with status code 502]
Add the Error Handling Code
Let’s update our service function to handle the server error and throw our custom error instead. Since we’re using async/await
syntax here, we’ll wrap our API call in a try/catch
. When we catch a server error we’ll ideally log the details, then we can throw a custom error. This will prevent sharing server errors in the UI and allow the consuming code to handle the error in whatever way is appropriate for that UI.
src/taskService.js
import axios from 'axios'
export async function getTasks() {
+ try {
const { data } = await axios.get('/api/tasks')
return data
+ } catch (error) {
+ // Some error logging code should go here
+ throw new Error('Boom!')
+ }
}
With that in place, our server error test will pass 🎉.
Wrap Up
We could (and should) write tests for additional scenarios. That logging code we left out would be mocked and assertions made about how and when it was called, I would add tests for more specific errors, etc. but the patterns and use of Mirage for mocking out the back end server would be the same.
With a tool like Mirage, you can write tests that are clear in their intent and isolated from external dependencies. You can test all the paths, happy as well as the not so happy ones. No need to mock fetch
or axios
anymore!