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 handlers. Under 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}]'
];
});
...
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);
});
...
import { Response } from '@bigtest/mirage';
this.server.put('/configuration', () => {
return new Response(422, { some: 'header' }, {
errors: [{ title: 'RM-API credentials are invalid' }]
});
});
...
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() };
}
});
...
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();
}
Fixtures. Fixtures 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.
...
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.
...
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.
...
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.
...
ui-eholdings (project where Mirage is used for e2e tests)
PR to ui-checkin (basic tests in similar to ui-eholdings repo fashion)