Testing your Mirage.js setup
Mirage.js is a universal library to mock out HTTP-based APIs. It has proven quite useful to us in several client projects, where it helped us write a lot of acceptance tests in a concise, but flexible manner.
The issue with tools like this is that you are not testing "the real API" though. This is where end-to-end tests are useful, but since those kinds of tests are quite slow and complex it would be quite costly to use them for all the kinds of tests in a modern web application.
One solution to some of the challenges of using a mock API is to test it and make sure it matches what you would expect from your real API. In this blog post we will show you how we started writing tests for our Mirage.js setup and why it might be useful for you too.
anchorWhere do we put those tests?
Before we start writing tests we need to figure out where to put all those new tests. In the case of an Ember.js app there is a top-level tests
folder, where these new tests would probably feel right at home. But inside of the folder there are only acceptance
, helpers
, integration
, and unit
subfolders. None of that really matches what we're building here so we decided to put all of our Mirage-related tests into a tests/mirage/
folder. Note that "Mirage-related" means the tests that are testing our Mirage.js setup, not the other tests that are just using the setup.
anchorTesting GET
Requests
Let's start with a simple example. We have created a user
model in Mirage.js, and a corresponding this.get('/users/:id')
shorthand route handler. Now we want to check if the serialization layer in Mirage.js works as expected. For that we will create a new test file tests/mirage/user/get-test.js
:
import { setupTest } from 'ember-qunit';
import { module, test } from 'qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import fetch from 'fetch';
module('Mirage | User', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
module('GET /users/:id', function () {
test('returns the requested user', async function (assert) {
let user = this.server.create('user', {
email: 'johnny@dee.io',
firstName: 'John',
lastName: 'Doe',
});
let response = await fetch(`/users/${user.id}`);
assert.equal(response.status, 200);
let responsePayload = await response.json();
assert.deepEqual(responsePayload, {
user: {
id: 1,
email: 'johnny@dee.io',
first_name: 'John',
last_name: 'Doe',
},
});
});
});
});
You can see here that we are first creating the test resource in the Mirage.js database (the server.create()
call), and then we use the regular fetch()
API to perform a network request and see what Mirage.js returns. We check if the correct HTTP status code is returned and then compare the resulting JSON payload with our expectation.
You may have noticed that we hardcoded the id
in this example. This works in simple cases like this, but if, for example, your API is using random UUIDs then hardcoding things like this just doesn't work. What we could use instead is: id: user.id
.
anchormatchJson()
When writing such API tests it can often happen that assert.deepEqual()
just does not provide enough flexibility. To resolve this problem we have integrated the match-json JS library into our tests, which makes assertions against nested values in the JSON payload much easier to write.
Inside the tests/test-helper.js
file we added the following snippet:
/* globals QUnit */
import match from 'match-json';
QUnit.assert.matchJson = function (actual, expected, message) {
let result = match(actual, expected);
this.pushResult({ result, actual, expected, message });
};
This imports the match-json
library, and introduces a new type of assertion on the assert
object that is available in QUnit tests. We can now write assertions like:
assert.matchJson(responsePayload, {
user: {
id: Number,
email: (value) => isEmail(value),
first_name: 'John',
last_name: 'Doe',
},
});
anchorTesting Edge Cases
One advantage of testing the Mirage.js setup is that we can make sure that edge cases also work similar to the production API. For example, if we want to ensure that our Mirage.js setup returns a "404 Not Found" HTTP status if the requested user does not exist, we can skip the server.create()
call at the start of the test, perform the fetch()
request, and then check for the expected response.status
value:
test('returns HTTP 404 if the requested user does not exist', async function (assert) {
let response = await fetch('/users/42');
assert.equal(response.status, 404);
});
anchorTesting PUT
requests
Similar to read-only GET
requests, we can also test e.g. PUT
requests, that mutate the existing resource:
module('GET /users/:id', function () {
test('returns the requested user', async function (assert) {
let user = this.server.create('user', {
email: 'johnny@dee.io',
firstName: 'John',
lastName: 'Doe',
});
let response = await fetch(`/users/${user.id}`, {
method: 'PUT',
body: JSON.stringify({
user: {
first_name: 'Joe',
},
}),
});
assert.equal(response.status, 200);
let responsePayload = await response.json();
assert.deepEqual(responsePayload, {
user: {
id: 1,
email: 'johnny@dee.io',
first_name: 'Joe',
last_name: 'Doe',
},
});
});
});
You can see that the test looks fairly similar to the GET
request test, except that pass an options object to the fetch()
function, where we specify that this is a PUT
request, and what the request payload will be.
Similar strategies can also be applied for DELETE
and POST
requests, but we will leave that as an exercise for our readers... 😉
Finally, if you want more information on how we make sure that our mock APIs always match the production API or you need more help implementing these things in your own project, please contact us! 👋