Mocking HTTP Calls In Tests With Jest and MirageJS

8 min read

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!