DEV Community

Cover image for An Introduction to Unit Testing in Node.js
Antonello Zanini for AppSignal

Posted on • Originally published at blog.appsignal.com

An Introduction to Unit Testing in Node.js

Unit tests are essential to verify the behavior of small code units in a Node.js application. This leads to clearer design, fewer bugs, and better adherence to business requirements. That's why Test-Driven Development (TDD) and Behavior-Driven Development (BDD) have become so popular in the backend development community.

In this tutorial, we'll dive into unit testing and understand why it's needed in your backend. We'll then learn how a unit test is structured and explore the most widely used libraries for unit testing in Node.js.

It's time to become a Node.js unit testing expert!

What Is Node.js Unit Testing?

Unit testing is a software testing practice in which individual units of an application are tested to ensure they behave as expected. In particular, a unit test is a short and isolated test that verifies the correctness of a specific piece of code — typically a function or method.

Thus, Node.js unit testing involves writing tests to validate the functionality of individual modules in your backend. This includes testing API endpoints, database interactions, and business logic functions to ensure robustness.

A CI/CD pipeline usually runs unit tests to verify the integrity of a deployment, ensuring that a Node.js application stays reliable, even after code changes are made pre-production.

Why Write Unit Tests in Node.js?

Now that you know what unit testing is in Node.js, you may wonder why your backend needs unit tests.
Here are three good reasons:

  • To discover bugs earlier: Unit tests allow you to detect bugs in APIs during development or in the CI/CD deployment pipeline. By identifying issues earlier, you can fix them immediately and reduce the likelihood of runtime errors. Thanks to higher code coverage, your users will have a better experience.
  • To increase code quality: Unit testing serves as a form of documentation for your code. That's because each Node.js unit test provides a clear example of how a specific function should behave. By writing tests that cover different scenarios, you can verify that your backend is resilient to unexpected inputs. This leads to higher code quality and increased maintainability.
  • To improve your architecture design: To make the codebase of your backend testable, you must organize your codebase accordingly. This encourages designing a modular and decoupled Node.js architecture. As a rule of thumb, you don't want to write complex components that are difficult to test.

What a Unit Test in Node.js Looks Like

Most Node.js test suites follow the BDD specification style, which revolves around the describe() and it() functions:

describe("user service functions", () => {
  it("should retrieve the user with the given ID", async () => {
    // ...
  });

  it("should update the information of an existing user", async () => {
    // ...
  });

  // other it() unit tests...
});
Enter fullscreen mode Exit fullscreen mode

In this convention, a single JavaScript test file typically contains multiple unit tests represented as it() functions. These test functions can then be encapsulated within a describe() block for clarity and logical organization.

Let's now explore the logical sections a Node.js unit test usually consists of.

1. Setup

Here, you focus on preparing an environment to run unit tests in isolation. In a Node.js application, this involves setting up the right environment variables and a sample database. This step aims to ensure that tests produce predictable and consistent results.

To achieve that, the BDD specification style provides the following two hooks:

  • before(): Runs once before all the tests in describe().
  • beforeEach(): Runs before each individual unit test case in describe().

These hooks are useful to initialize resources and shared variables for tests.

To ensure that Node.js unit tests run in isolation, you may also need to rely on the following techniques:

  • Mocking: Creating fake objects to simulate external dependencies that the functions you're testing depend on.
  • Stubbing: Overriding specific functions or methods with default behavior.
  • Spying: Observing function calls to track their behavior during testing.

2. Testing

Testing directly executes the lines of code you want to test to exercise the functionality you're testing and observe its behavior.
Specific lines of code — commonly business logic functions — are called to retrieve data or perform operations. In other words, you provide inputs to a function and retrieve the resulting output.

3. Assertion

Assertions are predicates that make sure your code behaves as intended. The results you obtain after testing are compared with the expected results.

Node.js test suite libraries come with an assertion engine that makes it easier to write more expressive and readable assertions. You get functions to compare values and verify conditions in a structured way.

This step is critical to validate the correctness of the code being tested. It checks whether the tested code produces the correct results on the given inputs and conditions.

4. Cleanup

This (optional) phase of a Node.js unit test releases the allocated resources from the preparation step. The BDD specification style provides two hooks for cleanup:

  • after(): Runs once after all unit tests in describe() have been executed.
  • afterEach(): Runs after each individual unit test defined in describe().

The code in these hooks does tasks like closing database connections, deleting temporary files, and resetting temporary settings.

Popular Libraries to Perform Unit Testing in Node.js

Suppose you have a sample Node.js endpoint that returns the Fibonacci series up to n terms, where n is a query parameter. This is what your Express server may look like:

// server.js
const express = require("express");
const { generateFibonacciSeries } = require("./services/math");

const app = express();
const PORT = process.env.PORT || 3000;

app.get("/api/v1/math/fibonacci/:n", (req, res) => {
  const n = parseInt(req.params.n);

  const fibonacciSeries = generateFibonacciSeries(n);

  res.json({ fibonacciSeries: fibonacciSeries });
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

The business logic to calculate the Fibonacci series is encapsulated in the following function:

// services/math.js

function generateFibonacciSeries(n) {
  const fibonacciSeries = [0, 1];
  for (let i = 2; i < n; i++) {
    fibonacciSeries.push(fibonacciSeries[i - 1] + fibonacciSeries[i - 2]);
  }
  return fibonacciSeries;
}

module.exports = {
  generateFibonacciSeries,
};
Enter fullscreen mode Exit fullscreen mode

Now it's time to explore the most popular Node.js unit testing frameworks and libraries using this example. We'll take a look at each tool, its pros and cons, and how to use it to test the above Node.js business logic function.

Mocha

Mocha is a simple and flexible JavaScript testing framework for browser and Node.js applications. Unlike other testing frameworks, it takes a minimalist approach and relies on external libraries for key tasks. It uses Sinon for handling spies, stubs, and mocks, and Chai as the assertion engine. Mocha is extensible through many plugins and can integrate with most test runners.

This is how you can use Mocha to write a Node.js unit test for generateFibonacciSeries():

const assert = require("assert");
const { generateFibonacciSeries } = require("../services/math");

describe("verify that math service works", function () {
  it("the Fibonacci series should return [0, 1, 1, 2, 3, 5] when n is 6", function () {
    const expectedFibonacciSeries = [0, 1, 1, 2, 3, 5];
    const actualFibonacciSeries = generateFibonacciSeries(6);
    assert.deepEqual(actualFibonacciSeries, expectedFibonacciSeries);
  });
});
Enter fullscreen mode Exit fullscreen mode

📈 npm weekly downloads: Over 6.8 million

⭐ GitHub stars: 22.4k+

👍 Pros:

  • Simple setup and lightweight approach to unit testing.
  • Highly extensible via plugins for assertions, reporters, and mock libraries.
  • Great for testing asynchronous operations.
  • Chai's assertion API is extremely rich.

👎 Cons:

  • Configuration overhead when setting up different assertion libraries, mocking tools, and other plugins.

Jest

Jest is an all-in-one JavaScript testing framework with a focus on simplicity. It supports projects using Babel, TypeScript, Node.js, React, Angular, Vue, and more. To maximize performance, it runs tests in parallel on isolated processes. Jest comes with an integrated assertion engine based on the expect() function and requires zero configuration for most projects.

Here's how to use Jest to write a unit test for generateFibonacciSeries():

const { generateFibonacciSeries } = require("../src/services/math");

describe("verify that math service works", () => {
  it("should return [0, 1, 1, 2, 3, 5] for Fibonacci series when n is 6", () => {
    const expectedFibonacciSeries = [0, 1, 1, 2, 3, 5];
    const actualFibonacciSeries = generateFibonacciSeries(6);
    expect(actualFibonacciSeries).toEqual(expectedFibonacciSeries);
  });
});
Enter fullscreen mode Exit fullscreen mode

📈 npm weekly downloads: Over 20.2 million

⭐ GitHub stars: 43.5k+

👍 Pros:

  • Ready to go after installation, with no configuration overhead.
  • Comes with built-in mocking, snapshot testing, and code coverage out of the box.
  • Optimized for speed with parallel test execution.
  • Well-documented, with a large and active community providing support.

👎 Cons:

  • Installs many dependencies during the initial setup.

Jasmine

Jasmine is a BDD testing framework for Node.js projects and other JavaScript applications. The library comes with no external dependencies, so it is simple to use with a low overhead. It's the oldest of the three tools we've covered, having been around since 2010. Jasmine runs on any JavaScript platform and is compatible with other testing libraries.

See Jasmine in action when writing a Node.js unit test for generateFibonacciSeries():

const { generateFibonacciSeries } = require("../src/services/math");

describe("verify that math service works", () => {
  it("should return [0, 1, 1, 2, 3, 5] for Fibonacci series when n is 6", () => {
    const expectedFibonacciSeries = [0, 1, 1, 2, 3, 5];
    const actualFibonacciSeries = generateFibonacciSeries(6);
    expect(actualFibonacciSeries).toEqual(expectedFibonacciSeries);
  });
});
Enter fullscreen mode Exit fullscreen mode

Note that the expect() functions of Jest and Jasmine have a similar syntax, but they are not the same function.

📈 npm weekly downloads: Over 3.8 million

⭐ GitHub stars: 15.7k+

👍 Pros:

  • Offers built-in support for assertions, mocks, and spies.
  • Provides a readable and expressive syntax for writing tests.
  • Works in both browser and Node.js environments with easy configuration.
  • Thoroughly tested and documented over years of development.

👎 Cons:

  • Requires a separate test runner like Karma or Jasmine CLI for executing tests.

Node.js Unit Testing: Best Practices

Here's a list of best practices to build robust Node.js unit tests:

  • Keep unit tests small and focused on single functions to make them easier to understand and maintain.
  • Avoid declaring more than one assertion in the same test.
  • Use descriptive test names that clearly indicate what is being tested and the expected outcome.
  • Rely on mocks or stubs to isolate the code that's being tested from external dependencies such as databases, APIs, or other modules.
  • Use helper functions, setup functions, and utilities to avoid duplicating test code.
  • Integrate your unit tests into your CI/CD pipeline to ensure that tests run automatically at each deployment.
  • Adopt code coverage tools to monitor test coverage and identify areas of the codebase that are not adequately tested.
  • Write clear and concise documentation for your tests, including information about what is being tested, why it's important, and any relevant context or assumptions.

Wrapping Up

In this blog post, we explored Node.js unit testing and its benefits for backend projects.

You now know:

  • The definition of unit tests.
  • What elements a Node.js unit test consist of.
  • The most popular unit testing libraries and frameworks for Node.js.

Thanks for reading!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Top comments (0)