BigTest Mirage overview

Mirage

Mirage is client-side server to develop, test and prototype app. It could used both for development and testing.


Building blocks


Mirage is inspired by the ember-cli-mirage package and its approach. ember-cli-mirage docs useful to understand the internals of the BigTest Mirage and building blocks like:


Route handlersUnder the hood it uses Pretender which is a mock server library for XMLHttpRequest and Fetch APIs. In the example below the server will mock GET requests to the /api/songs endpoint and return back predefined result. Additionally there is API to mock other CRUD operations which could be found in the package documentation. Usually this stuff should be placed in /bigtest/network/config.js file.

  this.get('/api/songs', request => {
return [
200,
{'content-type': 'application/javascript'},
'[{"id": 12}, {"id": 14}]'
];
});


Typically there are two options to mock the response data. 

1) Return just plain object

this.get('/api/authors/current', {id: 1, name: 'Link'});


2) Specify function as the handler. The function handler takes two parameters, schema (Mirage server’s ORM) and request (the Pretender request object). Consult the schema’s API for how to interact with models (or the database directly) and Pretender’s docs for more info on the request object.

this.get('/api/events', () => {
  let events = [];

  for (var i = 0; i < 100; i++) {
    events.push({
      id: i,
      value: Math.random() * 100
    });
  };

  return events; // will be JSON.stringified by Mirage
});


Returning a Model or Collection (from schema) lets take advantage of the serializer layer.

this.get('/api/users/:id', ({ users }, request) => {
  return users.find(request.params.id);
});


This handler returns a User model, which will pass through the UserSerializer if one exists, or the ApplicationSerializer otherwise.

It is possible to return an instance of Response to dynamically set headers and the status code:

import { Response } from '@bigtest/mirage';
this.server.put('/configuration', () => {
return new Response(422, { some: 'header' }, {
    errors: [{ title: 'RM-API credentials are invalid' }]
});
});


FactoriesFactories are used to seed mock database, either during development or within tests. Whenever the object is generated via a factory, it will automatically get added to the database, and thus get an autogenerated idFactories have attributes, and objects can be created from factory definitions using the server.create and server.createList methods. To define factory the file should be created under the  /bigtest/network/factories directory. The name of the factory is determined by the filename. For the sake of data randomization a faker is good tool to use.

import { Factory, faker, trait } from '@bigtest/mirage';

export default Factory.extend({
title: () => faker.company.catchPhrase(), // here the field is a function which returns some value
  isCustom: false, // here the field is just a value
  barcode: () => Math.floor(Math.random() * 9000000000000) + 1000000000000,

location: () => {
return { name: faker.random.word() };
}
});

Function fields receive the sequence number as an argument, which is useful to create dynamic attributes.

It is possible to customize the objects created by factory using the afterCreate() hook. This hook fires after the object is built (so all the attributes have been defined will be populated) but before that object is returned. It is given two arguments: the newly created object, and a reference to the server. This makes it useful if it is needed factory-created objects to be aware of the rest of the state of Mirage database, or build relationships.

afterCreate(packageObj, server) {
let provider = server.create('provider'); create here another factory and assign it as field to newly created package
packageObj.provider = provider;
packageObj.save();
}


FixturesFixtures are data files that can be used to seed mock database, either during development or within tests. In general Mirage recommends using factories over fixtures, though there are times where fixtures are suitable. To add data to a database table titles, for instance, first create the file /bigtest/network/fixtures/titles.js:

import padStart from 'lodash/padStart';

let packageTitles = new Array(50).fill().map((_, i) => {
return {
id: `pkg_title_${padStart(i, 3, '0')}`,
name: `Package Title ${i + 1}`
};
});

export default [
...packageTitles
];


loadFixtures could be called from within a test to load this data into mock database.

beforeEach(function () {
this.server.loadFixtures('titles');
// At this point this.server.db.titles would be populated with fixtures from bigtest/network/fixtures/titles.js.
  // Screenshot below demonstrates how it looks in the code.
  
return this.visit('/eholdings/providers/paged_provider', () => {
expect(ProviderShowPage.$root).to.exist;
});
});



By the time the page is displayed, the fixtures would be loaded to the database. Calling loadFixtures without arguments would instantiate all fixtures /bigtest/network/fixtures/ folder. 


Models. Models allow to define relationships. Define models by adding files under bigtest/network/models/ folder. In db for every model the instance of the Collection is created which provides API for working with created models (like 'insert''find''findBy''where''update''remove''firstOrCreate' ,etc) where can be found. These methods returns model instance.

bigtest/network/models/package.js

import { Model } from '@bigtest/mirage';

export default Model.extend({});


bigtest/network/config.js

this.put('/packages/:id', ({ packages }, request) => {
let matchingPackage = packages.find(request.params.id);

let body = JSON.parse(request.requestBody);
let { name } = body.data.attributes;
  matchingPackage.update('name', name);

return matchingPackage;
});

The collections methods like find, where return the actual instance of the model which has its API methods like 'save', 'destroy', 'update'. These methods are actually wrappers on collection ones.

Associations

You can also define associations by using the belongsTo and hasMany helpers. Each helper adds some dynamic methods to the model.

export default Model.extend({
item: belongsTo(),
user: belongsTo()
});

This means that on the load object will eventually refer to the item and user objects by foreign keys itemId and userId. item and user models should also be defined beforehand. Logic of creation the relation models usually placed inside factories in afterCreate hook.

bigtest/network/factories/item.js

withLoan: trait({
afterCreate(item, server) {
server.create('loan', 'withUser', {
item
});
}
})

bigtest/network/factories/loan.js

withUser: trait({
afterCreate(loan, server) {
const user = server.create('user');
loan.user = user;
loan.save();
}
})

Traits improve test suite by pulling unnecessary knowledge about data setup out of your tests. Thus on creating the item object the needed tree will be created.

this.server.createList('item', 5, 'withLoan');

Schema. Mirage has an ORM which is managed through its Schema object. Assuming the models are defined models for server resources, schema will be passed into route handlers as the first parameter:

this.get('/authors', (schema, request) => {
  // work with schema
});


Database. Mirage server has a database which can be interacted with in route handlers. Typically models should be used to interact with database data, but it is possible to reach into the db directly in the event for more control.

this.delete('/packages/:id', ({ packages, resources }, request) => {
let matchingPackage = packages.find(request.params.id);
let matchingResources = resources.where({
packageId: request.params.id
});

// destroy model records from db
matchingPackage.destroy();
matchingResources.destroy();

return {};
});


Here the available API methods for db can be found.


Serializers. Serializers are responsible for formatting route handler’s response. The application serializer (/network/serializers/application.js as example in ui-eholdings) will apply to every response. To make specific customizations, per-model serializers could be defined (e.g. /network/serializers/title.js). Any Model or Collection returned from a route handler will pass through the serializer layer. Highest priority will be given to a model-specific serializer, then the application serializer, then the default serializer.

import { JSONAPISerializer, camelize } from '@bigtest/mirage';

export default JSONAPISerializer.extend({
serializeIds: 'always',

keyForModel(modelName) {
return camelize(modelName);
}
});


Now, without having to edit route handler, the response would look like this:

GET /authors/1

{
  author: {
    id: 1,
    'first-name': 'Keyser',
    'last-name': 'Soze',
    age: 145
  }
}


JSONAPISerializer is predefined serializer in Mirage to simulate JSON:API compliant servers. It which inherits the basic Serializer class and customize it using the hooks documented here.


All of those exist in the BigTest Mirage and links above is a good place to get acquitted with them.


Example

A good example of BigTest and basic Mirage setup can be found at this PR to ui-checkin package. More advanced scenarios could be found ui-eholdings repo where a bunch of tests are written.

Mirage useful links:

Github

Inside Ember CLI Mirage (Great video for getting acquainted with the motivation and key concepts behind the Mirage. Note, that the API in the talk could be stale or has more features as the post created in November 3, 2015.)

ui-eholdings (project where Mirage is used for e2e tests)

PR to ui-checkin (basic tests in similar to ui-eholdings repo fashion)