Testing and code quality in Node.js

Dive deep into the world of testing in Node.js. In this article you will learn the importance of testing, the various types of tests, and compare the two most popular frameworks in order to build reliable and robust applications.

As a Node.js developer, you know that testing code and maintaining its quality are essential aspects of software development, arguably just as important as writing code. In this article, we will dive deep into the world of testing in Node.js, explore its importance, and examine the various types of tests required for building reliable and robust applications. By the end of this article, you should be able to set up tests for Node.js applications.

Why test Node.js applications?

Node.js applications are widely used for creating server-side applications, APIs, and real-time applications. They often make concurrent requests and perform complex operations, which makes them prone to all types of errors and bugs. Without proper testing, it can be challenging to place and rectify these issues. This can lead to unreliable applications that may crash at anytime or produce incorrect results.

Testing our applications offers us several essential benefits:

  1. Bug Detection: Tests identify bugs early in the development process, allowing us to resolve them and avoid impacting users. By writing test cases that consider various scenarios, we can ensure that our code functions correctly and consistently.

  2. Refactoring Safety: As applications evolve, we frequently need to refactor or modify the codebase to meliorate performance or incorporate new features. Without tests, such changes can introduce unintended side effects or break existing features. Comprehensive tests protect your application, ensuring that refactored code maintains consistent, expected behavior.

  3. Regression Prevention: A regression occurs when a code change inadvertently causes existing features to stop working as expected. By running tests regularly and automatically, we can quickly detect regressions and fix them before they reach production, ensuring that new changes do not break existing features.

  4. Documentation: Tests serve as living documentation, providing insights into how different parts of our codebase should behave. They can be seen as examples of expected behavior, making it easier for developers to understand the functionality of various components.

The types of tests you'll encounter

When writing tests in Node.js, there are three primary types of tests you’re likely to run into:

  1. Unit tests: These tests focus on checking the correctness of individual units or components of your code in isolation. Unit tests are the foundation of your testing suite, ensuring that each block of code functions as intended.
  2. Integration tests: Moving up the ladder, integration tests examine how different units or modules of your Node.js application work together.
  3. End-to-end tests: At the top level, end-to-end tests take a holistic approach, simulating real user activities throughout your application. This way, you can catch issues that might come up due to interaction between various components.

Writing unit tests

Writing a unit test involves creating test cases that cover various use cases for a specific unit. For example, if you have a simple math.js module that contains an add function, you might use the following test:


// math.js
function add(a, b) {
  return a + b;
}

module.exports = { add };

Your corresponding unit test, using a testing tool like Mocha and an assertion library like Chai, might be something like this:


// test/math.test.js
const { expect } = require('chai');
const { add } = require('../math');

describe('add function', () => {
  it('should return the sum of two positive numbers', () => {
    const result = add(2, 3);
    expect(result).to.equal(5);
  });

  it('should handle negative numbers', () => {
    const result = add(-1, 5);
    expect(result).to.equal(4);
  });
});

Writing Integration tests

For integration tests, you need to set up the application environment to simulate real interactions between modules. For example, consider an Express.js application with the endpoint /hello:


// app.js
const express = require('express');
const app = express();

app.get('/hello', (req, res) => {
  res.send('Hello, world!');
});

module.exports = app;

An integration test for this endpoint created with Supertest and Mocha might look like this:


// test/app.test.js
const request = require('supertest');
const app = require('../app');

describe('GET /hello', () => {
  it('should return "Hello, world!"', async () => {
    const response = await request(app).get('/hello');
    expect(response.text).to.equal('Hello, world!');
  });
});

Writing end-to-end tests

End-to-end tests use tools like Cypress, which can mimic user activity in the browser. Here is an example:


// app.spec.js
describe('App', () => {
  it('should display "Hello, world!" when visiting /hello', () => {
    cy.visit('/hello');
    cy.contains('Hello, world!');
  });
});

Choosing the right test type

To build a comprehensive testing suite, you'll likely use all three types of tests discussed above. Unit tests are the foundation, validating the smallest units of your code. Integration tests ensure that different parts of your application relate with each other correctly. Finally, end-to-end tests validate the application's whole functionality and user experience.

Selecting a testing framework: Mocha vs. Jest

When it comes to testing Node.js applications, the two popular frameworks that stand out are Mocha and Jest. Let's briefly explore each framework’s strengths and features before selecting one for our example.

Mocha: the flexible choice

Mocha is a widely used testing tool known for its flexibility and simplicity. It provides a rich set of features and allows developers to use their preferred assertion library, such as Chai. Mocha is highly configurable and supports both synchronous and asynchronous testing, making it a good option for testing various scenarios in Node.js applications.

Jest: the all-in-one solution

Jest, developed by Facebook, is a powerful testing tool focused on ease of use and speed. It comes with built-in mocking capabilities, code coverage, and snapshot testing. Jest was designed to be an all-in-one solution, making it an attractive choice for projects that value zero-configuration setups and a smooth and straightforward testing experience.

Choosing Jest for our example

For this article, we'll choose Jest as our testing framework. Its simplicity and integrated features will allow us to create a testing environment quickly and demonstrate the testing process more effectively. Jest's built-in mocking and code coverage tools will also be worth showcasing.

Installing Jest in a simple Node.js app

Let's make a basic Node.js application and integrate Jest for testing:

Create a new directory for your project and navigate into it:

mkdir node-app-example
cd node-app-example

Initialize a new Node.js project:

npm init -y

We'll create a simple math.js module that contains an add function:


// math.js
function add(a, b) {
  return a + b;
}

module.exports = { add };

Now, let's install Jest as a development dependency:

npm install jest --save-dev

Next, we'll create a test file for our math.js module:


// math.test.js
const { add } = require('./math');

test('adds two numbers correctly', () => {
  const result = add(2, 3);
  expect(result).toBe(5);
});

test('handles negative numbers', () => {
  const result = add(-1, 5);
  expect(result).toBe(4);
});

In the test file, we use Jest's test function to define our test cases. The first argument is a description of the test, which helps identify it in the test results. We utilize [expect](https://jestjs.io/docs/expect) and Jest's matcher [toBe](https://jestjs.io/docs/expect#tobevalue) to implement assertions. In this case, we are asserting that the result variable should be equal to 5. The toBe matcher checks for strict equality (===).

Running tests

To run the tests, add the test script in your package.json:


{
  "scripts": {
    "test": "jest"
  }
}

Now, execute the test script in your terminal:

npm test

Jest will detect and run the tests in the math.test.js file, displaying the results in the terminal.

Let's look at a more complex example: a Node.js application that fetches information from an API and tests the API call with Jest. We'll use the Axios library for making API requests.

Set up a new Node.js application and install the required dependencies:

mkdir node-api-example
cd node-api-example
npm init -y
npm install axios --save

Create a new file named fetchData.js in your project's root folder. This file will contain a function to fetch data from an external API:

// fetchData.js
const axios = require('axios');

async function fetchDataFromAPI() {
  try {
    const response = await axios.get('https://jsonplaceholder.typicode.com/posts/1');
    return response.data;
  } catch (error) {
    console.error('Error fetching data:', error.message);
    return null;
  }
}

module.exports = { fetchDataFromAPI };

Above, we import the axios library, which enables us to make HTTP requests. The fetchDataFromAPI function is an asynchronous function that fetches data from an external API using the Axios library. try...catch handles errors that might occur during the API request. If the API call is successful, the expected data is returned. Otherwise, an error is logged, and null is returned.

Next, we’ll create a simple entry point to the application in a file named app.js. In this file, we will call the fetchDataFromAPI function and display the fetched data:

// app.js
const { fetchDataFromAPI } = require('./fetchData');

async function main() {
  const data = await fetchDataFromAPI();
  if (data) {
    console.log('Fetched data:', data);
  }
}

main();

Run the application to ensure everything is set up correctly:

node app.js

You should see the fetched data displayed in the terminal.

Testing the API call with Jest

Now, let's use Jest to test the fetchDataFromAPI function.

Create a directory named tests for our test files:

mkdir tests

Inside the tests directory, create a new file named fetchData.test.js:


// tests/fetchData.test.js
const { fetchDataFromAPI } = require('../fetchData');
const axios = require('axios');

jest.mock('axios');

test('fetchDataFromAPI returns the correct data', async () => {
  const mockData = { userId: 1, id: 1, title: 'test title', body: 'test body' };
  axios.get.mockResolvedValue({ data: mockData });

  const data = await fetchDataFromAPI();
  expect(data).toEqual(mockData);
});

test('fetchDataFromAPI handles API request error', async () => {
  const errorMessage = 'Network Error';
  axios.get.mockRejectedValue(new Error(errorMessage));

  const data = await fetchDataFromAPI();
  expect(data).toBeNull();
});

In the test file, we use Jest's test function to define two test cases: one for a successful API response and one for handling API request errors. We use jest.mock('axios') to mock the axios.get function. This allows us to control its behavior during testing and return data for our test cases without making API requests.

1st test case: fetchDataFromAPI returns the correct data:

  • We create some mock response data (mockData) that represents the data we expect to receive from the API.
  • Using axios.get.mockResolvedValue, we instruct Jest to return the mockData when the axios.get function is called.
  • Next, we call the fetchDataFromAPI function and assert that the returned data matches our mockData using the expect(data).toEqual(mockData) function.

2nd test case: fetchDataFromAPI handles API request errors:

  • We create a mock error message (errorMessage) that represents a potential error when making an API request.
  • Using axios.get.mockRejectedValue, we instruct Jest to throw an error with the errorMessage when the axios.get function is called.
  • Then, we call the fetchDataFromAPI function and assert that it returns null since an error is expected to occur.

Now update your package.json file to use Jest as the test runner:

{
  "scripts": {
    "test": "jest"
  }
}

Running Tests

To run the tests, execute the test script in the terminal:

npm test

Jest will automatically detect and run the tests in the tests directory, displaying the results in your terminal.

A Comparison of Mocha and Jest for testing Node.js applications

Both Mocha and Jest are powerful testing frameworks widely used in the Node.js ecosystem. Each has its strengths and features that cater to different testing needs. In this section, we'll compare Mocha and Jest in various aspects to help you decide which one to choose for testing Node.js applications.

Setup and configuration

Mocha: Mocha requires additional configuration to set up some features, such as mocking and code coverage. While this gives you more control, you might need to install and configure additional libraries for specific testing needs. Jest: Jest is designed to be an all-in-one solution with zero-configuration setups. Out of the box, it supports mocking, code coverage, and snapshot testing, making it easy for you to start testing immediately without much setup.

Test running and watch mode

Mocha: Mocha relies on external libraries for test running and watching in development mode. For example, you could use mocha and nodemon together for running and watching tests in the development stage. Jest: Jest comes with its own test runner and offers an easy-to-use watch mode out of the box. It re-runs only the relevant tests when your code changes, providing quick feedback during development.

Mocking and spying

Mocha: Mocha does not include built-in mocking and spying features. You’d need to use separate libraries, such as Sinon. Jest: Jest comes with powerful built-in mocking and spying features, making it easy for you to mock functions and modules directly within your test code.

Community and ecosystem

Mocha: Mocha has been around for longer, and it has a wide range of plugins and extensions that offer flexibility and customization options. Jest: Jest, backed by Facebook, has gained immense popularity in recent years, resulting in a large and active community. Its ecosystem also includes numerous integrations and plugins.

Both Mocha and Jest are excellent testing frameworks for Node.js applications. Mocha provides with the freedom to choose various assertion libraries, making it suitable for developers who prefer a more customizable approach. However, Jest's all-in-one root approach and built-in features offer simplicity and ease of use, especially for people looking for a more opinionated testing framework.

Conclusion

In this article, we explored the world of testing in Node.js and covered different types of tests, including unit, integration, and end-to-end tests. We discussed the importance of testing, the benefits it provides, and its role in maintaining the integrity of databases and complex updates. Prioritizing testing in your development workflow is crucial for ensuring the reliability, maintainability, and overall quality of your code. Regularly writing and running tests will not only catch bugs early but also provide a safety net during code changes and refactoring, enabling you to confidently deliver high-quality software to your users. Thanks for reading!

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial