How to test loading and error states using Mirage and Sinon

Imagine you're making a network request to fetch data. While the promise is pending and the user is waiting, you need to give them UI feedback. A spinner icon or a skeleton placeholder. You need to test this loading state and check all elements render as expected. How do you do it?

What you'll learn

This article will look at how to test loading and error states. Things you'll learn:

  • how to mock loading and error states using Mirage and Sinon
  • how to "freeze" your test while the promise is still pending

The tools

We'll use Mirage.js and Sinon.js to mock our endpoints and stub their behavior.

Writing our component

I recently wrote about testing the network requests using Mirage and Sinon. I encourage you to read it if you want to understand how we mock and stub our endpoints. We're going to use the same example to illustrate our use case. We have a component that fetches 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' })
  }
}

In the template of this component, we yield the loading and error states.

<!-- components/todos.hbs -->

{{yield this.fetchDataTask.isRunning this.fetchDataTask.last.isError}}

Note: We use the Ember Concurrency task derived state to expose the loading and error states.

Testing the loading state

In the test, we'll assert if the component yields the loading state. We can write it like this:

// tests/integrations/components/todos.js

test('it yields the loading state', async function (assert) {
  this.server.get('/todos')

  await render(hbs`
    <Todos as |isLoading|>
      {{#if isLoading}}
        <div data-test-loading></div>
      {{/if}}
    </Todos>
  `)

  assert.dom('[data-test-loading]').exists()
})

At this point, our test is failing. That's because at the time the test runs the assertion, the loading state is gone.

Stubbing the loading state

Apart from spies and stubs, Sinon also provides promises. We can use them to create fake promises that we can resolve or reject on demand.

Here's how we can use a Sinon promise to stub the loading state in tests:

// tests/integrations/components/todos.js

test('it yields the loading state', async function (assert) {
  const promise = sinon.promise()

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

  await render(hbs`
    <Todos as |isLoading|>
      {{#if isLoading}}
        <div data-test-loading></div>
      {{/if}}
    </Todos>
  `)

  assert.dom('[data-test-loading]').exists()
})

At this point, the test will timeout. That's because it will wait until it renders the component and resolves all promises. Since our stubbed promise doesn't resolve, the test will not settle and will timeout. Here's what we need to do to fix this:

  • "freeze" the test at the right time
  • assert the loading UI exists
  • resolve the promise
  • settle the test
  • assert the loading UI doesn't exist anymore

Here's how we can do all these:

// tests/integrations/components/todos.js

test('it yields the loading state', async function (assert) {
  const promise = sinon.promise()

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

  render(hbs`
    <Todos as |isLoading|>
      {{#if isLoading}}
        <div data-test-loading></div>
      {{/if}}
    </Todos>
  `)

  await waitFor('[data-test-loading]')

  assert.dom('[data-test-loading]').exists()

  promise.resolve()
  await settled()

  assert.dom('[data-test-loading]').doesNotExist()
})

Let's break down what we did:

  1. First, we remove the await from the component render function. This will prevent the test from timing out since we never resolve the promise.
  2. We use the waitFor() test helper to "freeze" the test at the point we need. In our case, that is when the loading state element renders. Now the assertion will pass.
  3. At this point we're ready to resolve the promise by calling promise.resolve(). Tip: in case we need to resolve the promise and return some specific data, we can always do it by passing the data as an argument to the resolve() method:
promise.resolve({ todos: [] })
  1. We need to put the test back in a settled state. We do this by using the settled() test helper. At this point, we know the loading state is gone and we can check that the loading elements are not on screen anymore.

Testing the error state

We can test the error case, by mocking the response with an error. We do that by passing any HTTP error response status code as the third argument to the mirage route handler:

// tests/integrations/components/todos.js

test('it yields the loading state', async function (assert) {
  this.server.get('/todos', {}, 500)

  await render(hbs`
    <Todos as |isLoading isError|>
      {{#if isError}}
        <div data-test-error></div>
      {{/if}}
    </Todos>
  `)

  assert.dom('[data-test-error]').exists()
})

What if we want to have the loading state, but instead of resolving the promise, we want to reject it? Our test would look like this:

// tests/integrations/components/todos.js

test('it yields the loading and error states', async function (assert) {
  const promise = sinon.promise()

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

  render(hbs`
    <Todos as |isLoading isError|>
      {{#if isLoading}}
        <div data-test-loading></div>
      {{else if isError}}
        <div data-test-error></div>
      {{/if}}
    </Todos>
  `)

  await waitFor('[data-test-loading]')

  assert.dom('[data-test-loading]').exists()
  assert.dom('[data-test-error]').doesNotExist()

  promise.reject()
  await settled()

  assert.dom('[data-test-loading]').doesNotExist()
  assert.dom('[data-test-error]').exists()
})

This time we use Sinon's promise reject() method. We can also pass any value, in case we need to reject the promise with a specific error payload.

promise.reject('Bad request!')

There's more stuff to Sinon promises than what I've covered here, like custom promises. I encourage you to look into these if your use case is more specific.

Subscribe below to get future posts from Sabin