Testing Node and Express with TypeScript, Mocha, Chai and Sinon.js

In this article, we are going to look at a few approaches to testing a basic Node.js web application using Express.js.

This tutorial is meant to follow on from my previous article on creating a basic web application to upload files although the material covered here should make sense if you have a basic understanding of TypeScript.

There are many situations such a mocking out dependencies that cause our tooling to be unable to infer types in our tests but these can be overcome with a relatively small amount of effort. (covered in more detail in another article)

"Why should or would I go to any additional effort?", you might ask. "Tests add enough extra effort as it is?" Well, there are 2 fairly large benefits to be had by leveraging strong typing in tests.

Firstly, it makes the code infinitely more comprehensible to another developer, especially if that developer is less familiar with the libraries that you are usin.

Secondly, there is a fairly massive benefit when it comes to refactoring. In my experience maintenance of tests can be a huge pain point. Using TypeScript in our tests gives us the ability to refactor our source code and in many cases have those refactorings carry over into our tests.

The completed application relating to this tutorial can be found on this branch of the linked GitHub repo. (branch - part-3)

What we will test

We are going to test the profiles.controller.ts file that contains our core logic for dealing with requests.
This should be enough for the purposes of this tutorial, though in a larger app with more complex logic we might abstract away more complex pieced of functionality into yet more modules that our profiles.controller.ts would call. But for the purposes of looking at the mechanics of it all we will test the following:

  1. That our "createProfile" function renders our index view.
  2. That our "viewProfiles" function returns all profiles retrieved as JSON.
  3. That our "uploadProfile" function saves the profile
  4. That our "uploadProfile" function saves the correct data

We will do this by using Sinon stubs to replace dependencies such as MongoDB.
As we have defined our request handler functions separately from our calls to the express route object (as opposed to using in line anonymous functions) we already have separation from our file upload functionality.

Installing the required libraries

So now we need to install our dependencies:

npm install chai mocha sinon @types/chai @types/mocha @types/sinon --save-dev

As none of these are actually required to run our application we use the "--save-dev" flag.

Basics constructs of Mocha

Mocha is a fairly basic library. It allows us to create contexts for our tests to execute in that allows us to group our tests logically, whilst offering us lifecycle hooks to prepare and clean up before and after our tests have run.

Testing functions

Mocha offers a really intuitive BDD interface that we are going to use to provide structure to our tests. In my experience, this is the most commonly used API when writing tests with Mocha.

Describe

This creates a context for our tests to run within. Lifecycle hooks defined within a describe block are scoped to the invocation of the "describe" function that they are invoked within.

It

This is used to create the tests that will define assertions against our code after any setup logic that we define.

beforeEach

This is a lifecycle hook. It is typically invoked at the start of a callback passed to a call of the describe function.

It will guarantee that code defined within it will run before each test in the "describe" function that it is invoked.

afterEach

This is a lifecycle hook. It is typically invoked at the start a "describe" function's callback, usually after the invocation of a related "beforeEach". It will guarantee that code defined within it will run after each test within the function that it is invoked.

As we are using Sinon to create fakes and stubs on functions that are called in the code that we are testing, it is generally a best practice to restore this code to its original state after each test. Whilst in our tests this is not strictly necessary it stops tests run at a later point in time from experiencing side effects based on the setup for one of our previous tests i.e we may want the actual implementation that we are replacing using Sinon in one test to run in another test.

Chai

As Mocha has no inbuilt means of asserting conditions in our tests we will be using Chai to perform this task.

DRY vs DAMP

Whilst setup and cleanup functions allow us to take care of boilerplate and repetitive tasks it is important that our team is able to relatively easily understand our tests. The Acronym DRY (don't repeat yourself) is often used and promoted as the sign of a good code base. However, in tests we want it to be very clear what the test is actually asserting based on the inputs or current state that we have set up in our system under test. Another acronym that is often used to describe this which is DAMP (descriptive and meaningful).

We should aim to have the arrangement of the dependencies of the function or part of the system that we are testing close enough and specific enough to our test to allow either yourself or any team member to quickly comprehend what initial state the system is.

If the groupings of our tests become too large and our setup logic tries to cover too many tests, then, the tests themselves can become harder to understand and debug than our main application itself.

Creating our tests

First, we need to create our test file. So in our profiles feature in the "controllers" folder we want to create the file "profiles.controller.spec.ts".

By placing the test next to the file, we are making easier to resolve the dependencies of the file we are testing as they will use the same relative paths and it will also make for a nice workflow as we are easily able to locate the tests associated with our application code.

The reason we have included the word "spec" in our file name is so that we can use a glob pattern to locate and run all test files. We will go into this in more detail later. It's just important to note there is no requirement as such to include the word "spec" in the name of the file that holds our tests. We're just going to use this convention later to run our tests in a scalable manner that avoids hard-coding values.

Firstly we need to add the following imports to our files:

import 'mocha';  
import { expect } from 'chai';  
import * as sinon from 'sinon';  
import * as mongoose from 'mongoose';  
import { Response, Request, Express } from 'express';

import { createProfile, uploadProfile, viewProfiles } from '../Controllers/profiles.controller';  
import { Profile, ProfileRecord } from '../Models/profiles.models';  

using a stub to verify that our view is returned

We are now going to test our "createProfile" request handler:

export const createProfile = (req: Request, res: Response) => {  
  res.render('index', { layout: false , title: 'Please upload your application' });
}

with the following tests:

describe('profiles controller create', function () {

    it('returns index view', async function () {

        let file = { path: 'test' }; 
        let body = { title:'test title', description: 'test description' };
        let req: Partial<Request> = {};

        let res: Partial<Response> = {
            render: sinon.stub()
        };

        createProfile(<Request>req, <Response>res);

        sinon.assert.calledWith(res.render as sinon.SinonStub, 'index',{ layout: false , title: 'Please upload your application'});      
    });
});

Here we are testing our "createProfile" request handler. We are creating our own implementation of the "request" and "response" parameters that this function expects then passing them to the function as we invoke it.

In the code that we are testing, we know that we expect that our request handler, given a request that maps to our "createProfile" handler, should respond with the index view by invoking the render function on the response parameter.

Whilst when our application is running "express" would provide the request and response parameters, we have control of this in our tests. This is why our request handler functions are defined separately from our route handlers rather than being created as inline anonymous functions.

The response object that we are defining (assigned to the "res" variable) has a render property that is a stub created by Sinon. This will be used by us later to verify whether or not it was called and that it was called with the expected parameters.

Replacing a module's exported value with a stub to assert behavior

We are now going to test the our "viewProfiles" request handler:

export const viewProfiles = async (req: Request, res: Response) => {

  const profiles = await ProfileRecord.find({});

  res.json(profiles);

}

to ensure that it returns models retrieved from the database as the response. As this is a unit test, we do not want to actually query the database but are aiming to test this code in isolation.

This will require us to ensure that functions that would access the database are replaced with Sinon stubs that will allow us to control what is returned. That way we can test the flow of our own code in isolation.

To do this we will use the "beforeEach" and "afterEach" lifecycle hooks. In the "beforeEach" function we will use the "require" function to get access to the exported "ProfileRecord" of the file 'profiles.models.ts' and change its "find" function to a Sinon stub which will allow us to setup a return value when this function is called without invoking the original implementation which would try and call the database.

describe('profiles controller',async function() {  
    let { ProfileRecord } = await import('../Models/profiles.models');

    beforeEach(function() {
        sinon.stub(ProfileRecord, 'find');
    });

    afterEach(function() {
        (ProfileRecord.find as sinon.SinonStub).restore();
    });

    it('should return expected models', async function() {

        var expectedModels = [{}, {}];
        (ProfileRecord.find as sinon.SinonStub).resolves(expectedModels);
        var req: Partial<Request> = { };
        var res: Partial<Response> = {
            json: sinon.stub()
        };

        await viewProfiles(<Request>req, <Response>res);

        sinon.assert.calledWith(res.json as sinon.SinonStub, expectedModels);
    });
});

We are also passing the "viewProfiles" function our own fake implementation of the response parameter that it expects and we are setting up the "json" property of this object to be a Sinon stub. This allows us to verify that the "viewProfiles" function responds to requests by returning records it retrieves from the "Profiles" MongoDB collection as JSON.

Testing that our records are saved correctly by using a fake

So in our next set of tests, we will be testing our "uploadProfile" function.

export const uploadProfile = async (req:Request, res: Response) => {

    const fileName = req.file.path;

    const { title, description } = req.body as Profile;

    const newProfile: Profile = Object.assign(new ProfileRecord(), { fileName, title, description });

    const savedProfile = await newProfile.save();

    res.json(savedProfile);
}

We are going to ensure that:

  1. Our records are saved correctly.
  2. That the correct data is sent in the response.

Firstly, we are going to ensure that we replace the "save" function with a Sinon stub. As "save" is an async function that returns a Promise we are specifying our own fake implementation in the "beforeEach" lifecycle hook that simply returns a Promise that resolves with the data that was saved.

When working with Mongoose the objects that we instantiate have the function for saving their own data. Therefore the stub we are setting up will actually replace the function on the prototype of the ProfileRecord model that our code defines. As we are using "strict" compilation we need to be explicit with TypeScript about the "this" type that is being used in our fake implementation, hence why we have a type annotation for this pointer in our function. You can read more about this feature in TypeScript in this article.

describe('profiles controller upload', async function () {  
    let { ProfileRecord } = await import('../Models/profiles.models');

    const ProfilePrototype: mongoose.Document = ProfileRecord.prototype;

    beforeEach(function () {
        sinon.stub(ProfileRecord.prototype, 'save');

        (ProfilePrototype.save as sinon.SinonStub).callsFake(function (this: Profile) {
            let currentRecord = this;

            return Promise.resolve(currentRecord);
        });
    });

    afterEach(function () {
        (ProfilePrototype.save as sinon.SinonStub).restore();
    });

    it('should call save ', async function () {

        let file: Partial<Express.Multer.File> = { path: 'test' }; 
        let body = { title:'test title', description: 'test description' };
        let req: Partial<Request> = { file: file as Express.Multer.File, body };

        let createdModels: Partial<Profile> = {};
        let res: Partial<Response> = {
            json: (data: any) => createdModels = data
        };

        await uploadProfile(<Request>req, <Response>res);

        sinon.assert.called(ProfileRecord.prototype.save);        
    });

    it('should create, save and return Profile', async function () {

        let file: Partial<Express.Multer.File> = { path: 'test' }; 
        let body = { title:'test title', description: 'test description' };
        let req: Partial<Request> = { file: file as Express.Multer.File, body };

        let createdModel: Partial<Profile> = {};
        let res: Partial<Response> = {
            json: (data: any) => createdModel = data
        };

        await uploadProfile(<Request>req, <Response>res);

        expect(createdModel.fileName).to.equal(file.path);
        expect(createdModel.title).to.equal(body.title);
        expect(createdModel.description).to.equal(body.description);       
    });
});

Our first test uses a Sinon stub to verify that our "save" function on the "ProfileRecord" instance in our code has been called.

The second test uses our own fake implementation of the "json" function that is called via the response parameter in our "uploadProfile" request handler. When this fake function is called it assigns the data passed to it to a variable that we have set up in our test and we use this variable to test our expectations using Chai's "expect" function.

Updating our build process

We now need to update our package.json to have the following npm scripts:

"test": "npm run compile && mocha ./dist/**/*.spec.js", "test:debug": "npm run compile && mocha --inspect-brk ./dist/**/*.spec.js"

This will allow us to run and debug our tests.

Final thoughts

So in this tutorial, we covered tasks such as:

  1. Manipulating module dependencies to isolate tests using lifecycle hooks.
  2. Altering functions on function prototypes whilst managing the typing of the "this" pointer.
  3. Using Sinon.js stubs to replace function implementations
  4. Using our own fake implementations in conjunction with Sinon.js to verify function invocation.

Testing can certainly introduce you to some of TypeScripts quirkier aspects but the benefits it can bring in terms of refactoring and long term maintenance, in general, are huge. JavaScript is an incredibly flexible language and testing is one of the times that this comes to the fore, however, with enough appreciation of the libraries we are using and TypeScript's own typing system it is possible to use this flexibility whilst still maintaining the strong-typing.

Andrew de Rozario

Self / community educated developer who loves all things web, JavaScript and .Net related. Passionate about sharing knowledge, teaching and eating though maybe not in that order.

Subscribe to Just In Time Coder

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!