How to test network requests, using Mirage and Sinon

Testing network requests can become complex. You're often faced with scenarios where you want to check if you call a specific endpoint. You also want to see the request payload, or how many times you fire that particular request. Often, different requests to the same endpoint have different payloads. How do you assert that?

In this post, I'm going to explore what Mirage.js and Sinon.js are, and what each of them is best suited for. I'm also going to dive into how to use them together. I'll show you some testing scenarios, where you can use these tools together. This will improve your tests and make you more confident about them.

What is Mirage.js?

From the Mirage website

Mirage JS is an API mocking library that lets you build, test and share a complete working JavaScript application without having to rely on any backend services.

In Ember, we can use the Ember CLI Mirage addon, a wrapper for the Mirage.js library.

In a nutshell, we use Mirage to mock the API endpoints in our tests. This way we can hook into these endpoints and check the network requests.

What is Sinon.js?

Sinon.js is one of the most popular JavaScript libraries for test doubles. It provides standalone test spies, stubs, and mocks. It works with any unit testing framework.

We'll use Sinon to spy/stub the network requests so that we can later assert against these.

Spying network requests

Imagine we have a component that's fetching data.

// components/todos.js

import Component from '@glimmer/component'
import { inject as service } from '@ember/service'
import { tracked } from '@glimmer/tracking'
import { dropTask } from 'ember-concurrency'

export default class TodosComponent extends Component {
  @service store

  constructor() {
    super(...arguments)
    this.fetchDataTask.perform()
  }

  @dropTask
  *fetchDataTask() {
    yield this.store.query('todos', { status: 'completed' })
  }
}

And a rendering test for this component, testing we call the endpoint with the expected data:

// tests/integrations/components/todos.js

test('it calls the /todos endpoint with the expected payload', async function (assert) {
  this.server.get('/todos', function (schema, request) {
    assert.deepEqual(request.queryParams, { status: 'completed' })
  })

  await render(hbs`<Todos />`)
})

What if the component will yield an action that we can call to allow the user to re-fetch the data?

// components/todos.js

import Component from '@glimmer/component'
import { inject as service } from '@ember/service'
import { tracked } from '@glimmer/tracking'
import { action } from '@ember/object'

export default class TodosComponent extends Component {
  @service store

  constructor() {
    super(...arguments)
    this.fetchData('completed')
  }

  @action
  fetchData(status) {
    this.fetchDataTask.perform(status)
  }

  @dropTask
  *fetchDataTask(status) {
    yield this.store.query('todo', { status })
  }
}
<!-- components/todos.hbs -->

{{yield this.fetchData}}

How do we test that?

// tests/integrations/components/todos.js

test('it calls the /todos endpoint with the expected payload', async function (assert) {
  this.server.get('/todos', function (schema, request) {
    assert.deepEqual(request.queryParams, { status: 'completed' })
  })

  await render(hbs`
    <Todos as |fetchTodos|>
      <button {{on "click" fetchTodos}} data-test-button></button>
    </Todos>
  `)

  await click('[data-test-button]')
})

This time, we call the endpoint twice and want to assert against that. On top of that we'll dynamically pass the status when calling the fetchData action. Of course, we can duplicate the route handler to achieve that:

// tests/integrations/components/todos.js

test('it calls the /todos endpoint with the expected payload', async function (assert) {
  this.server.get('/todos', function (schema, request) {
    assert.deepEqual(request.queryParams, { status: 'completed' })
  })

  await render(hbs`
    <Todos as |fetchTodos|>
      <button {{on "click" (fn fetchTodos "pending")}} data-test-button></button>
    </Todos>
  `)

  this.server.get('/todos', function (schema, request) {
    assert.deepEqual(request.queryParams, { status: 'pending' })
  })

  await click('[data-test-button]')
})

One caveat is that we still cannot be confident that we call the endpoint exactly twice. We can know for sure that we call it at least twice. Imagine something will change in our implementation. Let's say we'll call the endpoint more times than expected. How can we catch that in the test?

How can we avoid repeating the route handler and be more strict about the test assertions? We can do all this by using Sinon spies

// tests/integrations/components/todos.js

import sinon from 'sinon'

test('it calls the /todos endpoint with the expected payload', async function (assert) {
  const handler = sinon.spy()

  this.server.get('/todos', handler)

  await render(hbs`
    <Todos as |fetchTodos|>
      <button {{on "click" (fn fetchTodos "pending")}} data-test-button></button>
    </Todos>
  `)

  await click('[data-test-button]')

  assert.true(handler.calledTwice)

  const firstRequest = handler.getCall(0).args[1]
  const secondRequest = handler.getCall(1).args[1]

  assert.deepEqual(firstRequest.queryParams, { status: 'completed' })
  assert.deepEqual(secondRequest.queryParams, { status: 'pending' })
})

Stubbing network requests

In the example above, we don't care about what we return from the route handler. When we need to return something, we have to use Sinon stubs.

Imagine we need to return something like { todos: [] }. With Ember Data store.query() we'll actually need this. Let's use the stub:

// tests/integrations/components/todos.js

import sinon from 'sinon'

test('it calls the /todos endpoint with the expected payload', async function (assert) {
  const handler = sinon.stub().returns({ todos: [] })

  this.server.get('/todos', handler)

  await render(hbs`
    <Todos as |fetchTodos|>
      <button {{on "click" fetchTodos}} data-test-button></button>
    </Todos>
  `)

  await click('[data-test-button]')

  assert.true(handler.calledTwice)

  const firstRequest = handler.getCall(0).args[1]
  const secondRequest = handler.getCall(1).args[1]

  assert.deepEqual(firstRequest.queryParams, { status: 'completed' })
  assert.deepEqual(secondRequest.queryParams, { status: 'completed' })
})

Anything else stays the same since stubs support the full spies API.

Can I do all this without using Sinon?

Of course, you can. If you're not used to using Sinon and you don't want to add an extra dependency to your app that is fine. On the opposite, if you're used to Sinon or want to harness its potential, here are a few benefits:

  • simplify your testing code
  • write more strict and granular assertions, resulting in better tests
  • Sinon is a popular library, and generally, there is no problem for developers to adopt and use it

What else can I do with Mirage and Sinon?

There's much more you can do with these tools and it's up to you to explore that. I'm always exploring new possibilities and will share more scenarios in future posts.

Subscribe below to get future posts from Sabin