11 min read

How we use automated testing to ship new releases with confidence

A beginner-friendly real-world example of testing node.js web applications. Engineers at Invide explain how they use Mocha, Chai, Sinon, etc. to test node.js applications. Code examples included.
How we use automated testing to ship new releases with confidence
blog banner

Table of contents:


What is software testing

Software testing is the act of investigating a program or application to discover errors, mistakes, or bugs. To guarantee that the software meets the wants and specifications set by the customer or end user, assessment is done. Unit testing, integration testing, system testing, and approval testing are all examples of different stages in which software testing can be conducted. In addition, it can be done physically or with the help of automated testing software. The primary benefits of software testing are improved software quality, fewer costs and risks, and greater customer satisfaction.

Examining enables the evasion of expensive reform and postponements by spotting and correcting slips early on in the construction process and guarantees the completed item meets consumer requirements. In conclusion, software testing is a key component of the software creation life cycle and is basic for fabricating top-notch software merchandise that are designed to the needs of the final user.

Types of testing

There are many types of software tests, each having diffrent objectivies and strategies and their puporse of testing. At Invide, we focus mainly on three tests i.e. Integration, Unit, E2E tests.

  1. End-to-end (e2e) testing : Evaluating the final outcome of the software application from the user's perspective by testing the APIs endpoints. End-to-end testing involves testing the entire system, from start to finish, to ensure that all components and interactions between them are working as expected.
  2. Unit testing : Examining software with an emphasis on examining individual software program units or parts is called unit testing. The purpose of unit testing is to make sure that each software unit or component functions as planned and meets the specifications set forth during the design and development process.
  3. Integration testing : Demonstrating how distinct system components or modules interact with each other. Integration testing's goal is to verify that the system's individual parts(units) operate correctly when functioning jointly as a single entity.

Let's learn with an example now

The test environment for our node.js application | A real-world example

Testing is an essential part before deploying any application and it is especially true for node.js applications. There are many reliable testing frameworks available for node.js which includes Jest, Jasmine, Karma, Cypress, and Mocha that is one of the most popular for node applications

At Invide, we use

  • Sails.js - a model-view-controller (MVC) web application framework for  node.js environment. This is built on top of express.js
  • Mocha - an open source JavaScript testing framework that can run both on node.js and in the browser. It is capable of testing both synchronous and asynchronous code, making it a versatile and useful tool for testing node.js applications
  • Chai - an assertion library for tests. This is used to write the test logic with expect, should, and assert statements to test if the code behaves as it should
  • Sinon - a library for test doubles such as stubs, spies, and mocks (will explain this later in this article)

In this blog, we will focus on a normal express app to provide a broader view of testing. We will perform tests using Mocha, along with Chai, Chai-http and Sinon.js these are the tools we use to test our applications at Invide.

In this blog post, we are going to setup automated testing such as way that, running npm test would show us various test results as following

Tests Passing
test results screenshot

Setting up Mocha for testing a node.js app

This is how we set up tests for a Node.js application using Mocha

  1. Install Mocha: Install Mocha as a development dependency using npm. Run the following command in your terminal npm install mocha --save-dev
  2. Create a test directory: Create a new directory in the project root called test. This directory will contain all the test files
  3. Write your tests: Create a new test file in the test directory and write your tests (javascript code) using the Mocha test framework. We will cover the exact tests later in this article
  4. Run your tests: To run the tests, use the Mocha command-line interface (CLI). In the terminal, navigate to your project root directory and run ./node_modules/.bin/mocha test. This will run all the tests in the test directory and output the results to your terminal

We can also simplfy this and run tests using simply npm test. For this to work, we have added this script in package.json as following

//package.json
"scripts": {
      "test": "./node_modules/.bin/mocha test",
}

You can also configure Mocha to run your tests automatically whenever you make changes to your code. To do this, you can use a tool like nodemon or watch to watch your files for changes and run Mocha automatically.

Lifecycle code before and after running tests

In order to run tests against our server, we need to make sure that the server is started before running the tests. One way to do this is to start the server in a before hook, which is a special function in Mocha that runs before test.

Here's an example of how you can start the server before running tests using Mocha:

Lifecycle code for a sails.js app

If you're using sails.js web frameworks similar to us, this is how the code would look like

/**
  * lifecycle.test.js
  * File containing code for tasks that needs to be executed before and after tests
/**

// Before running any tests...
before(function(done) {
	// Start the application
    var sails = require('sails');
    var rc = require('rc');
    sails.lift(rc('sails', {
        	environment: 'test'
        }), function(err) {
            if (err) { return done(err); }
            done()
        })
	})
  })
})

// After all tests have finished...
after(function(done) {
	// Stop the application
  setTimeout(()=>{
    console.log("Lowering down sails now");
    sails.lower(done);
  }, 20000)
});

Lifecycle code for express.js app

If you're using only express.js instead of sails.js, your code would look like this


/**
  * lifecycle.test.js
  * File containing code for tasks that needs to be executed before and after tests
/**


// start the server before starting the testing
before(function(done) {
    const app = require('./app');
    server = app.listen(3000, function(err) {
      if (err) return done(err);
      done();
    });
});


// stop the server after all tests
after(function(done) {
    server.close(done);
});

Now we can update npm test script to run this lifecycle code before running any test as following

//package.json
"scripts": {
      "test": "./node_modules/.bin/mocha test/lifecycle.test.js test/*/*.test.js",
}

Here, we are running tests in this order

  1. lifecycle.test.js - the code that needs to be run before testing starts and after testing is completed
  2. The tests under test/* folders

Now the basic setup for the tests is ready.

System under test - The code we need to test

Let's take a look at the functions we are testing. We have two functions addUser and sendEmail, addUser function adds the new user to our database, used for a signup functionality. When new users gets added to the user database, they are notified on email via sendEmail function. Here's a simplified version of these two functions. We are going to test these functions in this blog post.

UserController.js // The system under test
exports.addUser = addUser;
const User = require('../Models/UserModel')
const EmailController = require('./EmailContoller')

function addUser(user, cb){
     User.create(user, function(err, user){
        if(err) return cb(err, null);
        else {
            EmailController.sendEmail(user, function(err, info){
                if(err) return err
                else {
                    console.log(info)
                    console.log('Email sent: ' + info.response)
                }
            })
            return cb(err, user)
        }
    })
}

In the above UserController, It has a function called "addUser" which adds every new user to our database. This function Utilizes "User" Model, The above file also imports the Email controller. Email Controller is responsible for handling the email functionality. Let's Take a look at Email Controller file.

EmailController.js // The system under test
exports.sendEmail = sendEmail

const nodemailer = require('nodemailer');
var transporter = nodemailer.createTransport({
    host: 'smtp.ethereal.email',
    port: 587,
    auth: {
      user: 'okey97@ethereal.email',
      pass: 'your password from etherial'
    }
  });

function sendEmail(user, cb){
    var mailOptions = {
        from: 'okey97@ethereal.email',
        to: user.email,
        subject: 'Your account has been created succesfully.',
        text: 'That was easy!'
    };
    transporter.sendMail(mailOptions, function(error, info){
        if (error) {
          return cb(err, null);
        } else {
          return cb(err, info)
        }
    });
}

In EmailController, we have imported nodemailer which is a library to send emails, we are using ethereal.email as the email server to send/receive our emails. Email sent to any address will be captured by ethereal and presented in a dashboard, making the email testing convenient.

👉
Ethereal helps us with testing emails easily. Also if we had used a real-world email server for testing (e.g. gmail), that would have damaged our official email server/domain reputation because multiple duplicate emails would have been sent to real-world mailboxes during the testing.

Test code structure

Before jumping into the tests, lets understand the folder structure of our test code.

At the root directory of our project, we have a test directory which consist of 4 other directories

Tests Directory

Fixtures - The dummy data for tests

In fixtures folder, we keep the dummy data used in test. The dummy data is typically used to compare the result or to stub/fake the data. For example, we have a simple dummy user data as following

userFixture.js
exports.user = {
        email: 'user@invidelabs.com',
        password: '12ihd2i4wh342',
        name: 'invideUser'
}

We can use this fixture data by referringrequire('./fixtures/userFixture.js').user

👉
Remember: Always clone the fixture data to a new object before using

Instead of directly referring the object, we clone the object using Object.assign({}, userFixtureDataObject) to make sure that tests get the data what they expect. If we had not cloned this object, it is possible that some of our code where we use this fixture data may have modified this object, resulting in other tests receiving the fixture data which is different than what was written(and expected by the tests) in userFixture.js


Let's move to other folders. We have other three directories where we have test code of our application using different strategies to test namely - integration, unit and e2e tests.

1. End-to-end (E2E) tests

Now lets dive into the e2e (End-to-end) tests of our project

userE2E.test.js
const {expect} = require('chai')
const chai = require('chai')
const chaiHttp = require('chai-http')
const { user } = require('../fixtures/userFixture')
chai.use(chaiHttp)


describe('end to end test for user', () => { 
    it('testing end results for user create model', (done) => {
        chai.request('http://localhost:3000').post('/user').send(user).then(res=>{
            try {
                expect(res.status).to.be.equal(201)
                expect(res.text).to.be.equal('User added Successfully')
                expect(res).to.be.html
                done()   
            } catch (error) {
                done(error)
            }
        })
    });    
})

This test uses Chai and Chai HTTP libraries to simulate an HTTP request to the server and verify the response. The test sends a POST request to the /user endpoint with a user object from the userFixture directory which contains dummy data for testing. After sending the request, the test verifies the response by checking the HTTP status code, the response text, and the response type. Overall this E2E test ensures that the user creation functionality is working as expected and the API endpoint is properly handling the user object data.

2. Unit tests

In Unit test, we foucus mainly on one function at a time for the testing. In such tests, we do not care about the interaction with other components or external dependencies.

userUnit.test.js
const {expect} = require('chai')
const userController = require('../../Controllers/UserController')
const emailController = require('../../Controllers/EmailController')
const {user} = require('../fixtures/userFixture')

//A simple example of unit test

describe('add 2 numbers', ()=>{
	it('adding 2,5 should return 7', (done)=>{
    	function add(a, b){
        	return  a + b
        }
        expect(add(2, 5)).to.be.equal(9)
        done()
    })
})

//Real-world example testing addUser function 

describe('addUser unit test', ()=>{
    it('unit test for creating user', (done)=>{
        userController.addUser(user, function(err, user){
            try {
                expect(err).to.be.null
                expect(user.email).to.be.equal('user@invidelabs.com')
                expect(user).to.have.property('_id').equal(user._id)
                done()
            } catch (error) {
                done(error)
            }
        })
    })
})

In this unit test we are only testing the addUser function. We are comparing the expected output with the provided input values with extra expected data from the return data, such as a user created in the MongoDB database has an autogenerated _id field. We check this by applying a property check to ensure that the _id field is present in the returned user object. Essentially we are testing the functionality of the addUser function to ensure that it correctly creates a new user in the database and returns the expected data.

Lets see the integration tests for our node.js application. as stated integration tests are used for testing functions operation working together.

3. Integration tests

Integration tests will test the interaction between two components/modules of the system. In this test, we are going to use the concept of "test doubles". So before moving ahead, let's understand what are "test doubles".

What are test doubles

In software testing, sometimes we need to simulate or manipulate parts of our code to ensure that they're behaving correctly. Test doubles are techniques used to replace a software component with a simplified version of itself for testing purposes.

Types of test doubles

At this point, you only need to have a high-level overview of what are different techniques

  • Spies - A test spy is a tool that allows us to keep track of how our code is being used during testing. It lets us know if a particular function was called, how many times it was called, and with what parameters.
  • Stubs - A test stub is a tool that helps us replace certain parts of our code with predefined values or functions, allowing us to isolate specific parts of our code during testing.
  • Mocks - A test mock is a tool that lets us define the expected behavior of certain parts of our code. It allows us to simulate how our code should respond to certain inputs or events, and ensures that it behaves as expected during testing.

Don't worry if it sounds complex, this can be clarified further in the next tutorial. For now, we need only stub technique in our example code.

Let's look at an integration test in action now

userIntegration.test.js
const userController = require('../../Controllers/User')
const emailController = require('../../Controllers/Email')
const { expect } = require('chai')
const sinon = require('sinon')
const sandbox = sinon.createSandbox()
const {user} = require('../fixtures/userFixture')

describe('UserController', () => {
  describe('AddUser for Integration', () => {
    it('should add a user and send an email', (done) => {
      const sendEmailStub = sinon.stub(emailController, 'sendEmail').callsFake((user, cb) => {
        cb(null, 'Email sent successfully');
      });

      userController.addUser(user, (err, savedUser) => {
        expect(err).to.be.null;
        expect(savedUser).to.be.an('object');
        expect(savedUser.name).to.equal('Invide User');
        expect(savedUser.email).to.equal('user@invidelabs.com');
        expect(sendEmailStub.calledOnce).to.be.true;

        sendEmailStub.restore();
        done();
      });
    });
  });
});

This integration test checks if the addUser function successfully adds a user to the database and sends email. It uses Mocha and Chai for testing. It uses Sinon to create a stub for the emailController.sendEmail function which allows us to test the function without actually sending an email, instead it returns a dummy response. The test takes a user object from fixtures, passes it to the addUser function, and checks if the function successfully saves the user and sends an email.

Lets take a look on the output of these tests which is an important part to verify whether the code work as intented.

Tests Passing
test results screenshot

Conclusion

To conclude, organizing test directory of our project plays a crucial role in maintaining the functionality and stability of our application. These tests enable us to verify whether our code works as expected under various scenarios and detect errors before they can impact the production environment. By utilizing testing frameworks such as Mocha, Chai, and Sinon, we can verify the correctness of our code and ensure that it performs as intended. Before deploying the application to the production environment, it is crucial to confirm that all the tests in the test suite pass, thereby guaranteeing the reliability and quality of our application. If you have any questions or suggestions, post on the community forum.