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.
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.
GET
RequestsLet'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
.
matchJson()
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',
},
});
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);
});
PUT
requestsSimilar 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! 👋