/ FeathersJS

Set Up Ava Test Runner with FeathersJS and Mongoose

Getting started with testing is difficult. When you have slowly worked up enough experience with testing that you want to apply it to the front-end, another language, or a blank project, it can be just as daunting as when you first began.

I had a lot of trouble trying to get Ava set up with FeathersJS and MongoDB and thought it would be useful to share my findings and eventual solution.

I will be working with a stock install of the FeathersJS framework, so please refer to their documentation if you want to follow along and don't have one set up already.

Ava

🚀 Futuristic JavaScript test runner

Ava is a modern test runner for JavaScript applications and wraps your test cases in asynchronous callbacks so that they can effectively be run in parallel. It makes use of a single processing thread, but because your tests are now non-blocking, they don't have to wait on results before others can be executed.

Installing Ava

In the main folder of your Feathers app, you should run either yarn add ava --dev or npm install ava --save-dev.

Open package.json and add the "ava" configuration:

"scripts": {
    "test": "NODE_ENV=testing ava --verbose",
// Other scripts...
},
// devDependencies etc.
"ava": {
    "files": [
      "test/**/*.test.js"
    ],
    "source": [
      "**/*.{js,jsx}",
      "!dist/**/*"
    ],
    "match": [
      "*"
    ],
    "concurrency": 5,
    "failFast": true,
    "tap": false,
    "powerAssert": false,
    "require": [
      "babel-register"
    ],
    "babel": "inherit"
  }

Now when you run yarn run test or npm test Ava will try to find tests in the test directory, and will run any files that end with *.test.js

Asynchronous Woes

When you are running any code asynchronously, you are in danger of experiencing what is known as a race condition.

A race condition or race hazard is the behavior of an electronic, software, or other system where the output is dependent on the sequence or timing of other uncontrollable events. It becomes a bug when events do not happen in the order the programmer intended. - Wikipedia

What can happen (and indeed what led me to this blog post) is that you can modify some state like a database record, and before your asynchronous test case has completed, a different test case has already modified the database in some way. This leads to test cases that occasionally work but often break with no clear reason.

A Concrete Example

import test from 'ava'
import User from './user'
import Team from './team'

test('a team can be created', async t => {
	let team = new Team({name: "First Team"});
	await team.save();

	let teams = Team.find();
	t.is(teams.length, 1); 
	 // !!!! 💥 Failed asserting 2 equals expected 1 💥 !!!!
});

test('user must belong to a team', async t => {
	let team = new Team({name: "Async"});
	await team.save();

	let user = new User({team});
	await user.save();

	let dbUser = User.findById(user._id);
	t.true(team._id.equals(dbUser.team._id)); // successfully passes
});

In the example above, the single database in use by the ODM is accessed by both test cases. The order that team.save() resolves in are not deterministic and so you can end up with the second test completing before the first.

You can survive by making your tests only applicable to the models you're dealing with.
For example, if I used Team.findById(team._id); in the first case, I could confirm that the team was indeed created.

Unfortunately this peace doesn't last forever, and you can't rely on the database being in any particular state for each test. You even run into unique constraint issues from previous tests because you couldn't know when to safely clear and reset your database!

The Solution

Thanks to a small package I made, it's easy to get a random in-memory (temporary) database for each of your tests. See the readme for more details.
Unfortunately, FeathersJS needs a little tweaking if you really want to isolate your tests.

Isolating FeathersJS

In the root folder of your application, there are two files: app.js and index.js.

  • app.js is responsible for configuring an 'app' object. This is where the guts of the framework are pulled together.
  • index.js is simply an entry file that starts the framework server.

To isolate Feathers, we need to change app.js so that we are returning a function instead of an object:


// src/app.js
const appGenerator = (options) => {
	let app = feathers();

	/*
	  Allow injection of options so that imported app modules may use them.
	*/
	if(options) {
    		for(let key of Object.keys(options)) {
      			app.set(key, options[key]);
    		}
 	}
	
	//...

	return app;
}

module.exports = appGenerator;

This creates a new 'application' instance every time it is called.
Next, you need to change your index.js file so that it calls the app function instead of just simply requiring it.

// src/index.js
const app = require('./app')(); // <--- call the function.

In order to prevent the application automatically starting up a Mongoose connection to your normal database, you should customise src/services/index.js so that it checks if Mongoose is already set.

// src/services/index.js
module.exports = function() {
  const app = this;

  // Get an override, or default back to null
  let mongoose = this.get('mongoose');

  // If not given an override, boot up the default database connection
  if(!mongoose) {
    mongoose = require('mongoose');
    mongoose.connect(app.get('mongodb'));
  }

 //...

}

The Good Part

You are now in a position to run asynchronous tests against MongoDB!
Here is refactored version of our first test example which now uses FeathersJS services with a mock application and a test mongoose instance:

import test from 'ava'
import User from './user'
import Team from './team'

import { MongoDBServer } from 'mongomem';
import mongoose from 'mongoose';
import app from '../src/app';

MongoDBServer.debug = false; // toggle on/off mongod logging to console
let server = MongoDBServer.start();

test.beforeEach('mock application and database connection', async t => {
	Object.assign(mongoose, {Promise});
    const mongooseInstance = new mongoose.Mongoose();
    await server;

	// Connect to a new temporary database
    let db = await MongoDBServer.getConnectionString();
    await mongooseInstance.connect(db, {promiseLibrary: Promise});

	// Copy Mongoose models over from global Mongoose to new Instance.
    Object.keys(mongoose.models).forEach(name => {
      const model = mongoose.models[name];

      mongooseInstance.model(name, model.schema);
    });

    // generate an app instance with the mock in place
    let mockApp = app({
      mongoose: mongooseInstance,
    });

    // Replace service models in mockApp with temporary mongoose connection models.
    for(let key of Object.keys(mockApp.services)) {
      let currentService = mockApp.services[key];

      if(currentService.Model && currentService.Model.modelName) {
        currentService.Model = mongooseInstance.models[currentService.Model.modelName];
      }
    }

    // expose the mongoose instance and mock app to the test
    t.context.mongoose = mongooseInstance;
    t.context.app = mockApp;
});

test('a team can be created', async t => {
	let app = t.context.app;

	let team = await app.service('/teams').create({name: "First Team"});
	let teams = await app.service('/teams').find();
	
	t.is(teams.total, 1); 
	 // 😎 - it now works!
});

test('user must belong to a team', async t => {
	let app = t.context.app;

	let team = await app.service('/teams').create({name: "Async"});
	let user = await app.service('/users').create({team});

	let dbUser = await app.service('/users').get(user._id);
	t.true(team._id.equals(dbUser.team._id)); // still successfully passes
});

I know what you're thinking.

Whoa! That's a lot of setup for each test. - Me

Fear not. This can easily be refactored to its own module. Although outside the scope of this article, I was able to easily achieve the following in my tests:

import helpers from '../helpers.js'

test.beforeEach('setup', async t => {
	await helpers.mock(t);
});

// ... test as before

Write once in a helper, use everywhere.

Final Words

It has taken me several days (I'm new to the Node world) and a lot of hard work but I am happy with the implementation. I can now test as I want with the added benefits that Ava brings in terms of speed.

Some of the example test cases above are written free-hand, so may not work exactly as prescribed. I trust you know how to follow a stack trace. With that said, all of the code mocking the FeathersJS app and Mongoose is real.

If you have found this useful or have any questions or comments, feel free to post below.

Thanks!