Test-Driven Development (TDD) with Node.js and Jest

·8 min read

blog/tdd-nodejs-jest

Hello, in this post, I will explore the world of Test-Driven Development (TDD) with Node.js and Jest. We’ll be building a simple scheduler module using TDD. This module will store and execute a series of functions and their arguments. If you want to create more robust and reliable code, join me on this journey into TDD.

What is Test-Driven Development?

Test-Driven Development (TDD) is a software development methodology in which tests are written before the actual code. The philosophy behind TDD is that it allows you to set clear goals (the tests) and then write code to meet these goals. This development method encourages better design decisions, leads to more maintainable code, and results in higher test coverage.

Our Companion on this Journey - Jest

Jest is a robust and delightful JavaScript testing framework maintained by Facebook. Its simplicity, powerful mocking capabilities, and rich assertion library make it ideal for practising TDD. With Jest, you get a feature-packed framework to write a wide array of tests, from unit to integration tests.

Our Scheduler Module

We aim to create a simple scheduler module to store and execute several functions along with their two arguments. This scheduler will have three key features:

  1. Add functions to a list: We need to store functions and their arguments to be executed later.
  2. Execute all stored functions: Our scheduler should run them with their given arguments when called upon.
  3. Count the number of stored functions: The scheduler should also be able to tell us how many functions are currently stored.

I’ll follow the TDD approach instead of jumping straight into the code.

Setting Up a New Project

Before we start writing our tests, let’s set up a new Node.js project. First, create a new directory for your project. You can name it anything you want, but for this tutorial, let’s call it jest-tdd-scheduler.

$ mkdir jest-tdd-scheduler
$ cd jest-tdd-scheduler
$ npm init

This command starts a questionnaire to configure your new project. You can now hit enter to accept the defaults for each question. You can find more information in the npm docs if you’re curious about any options.

Now that our Node.js project is initialized let’s add Jest. We’ll use yarn to add Jest as a development dependency. Run yarn add --dev jest in your command line. The —dev flag is used to specify that Jest is only a dependency during development and not in production.

You can use the command yarn run jest to run your tests with Jest. To take advantage of Jest’s watch mode, which reruns tests whenever a file changes, you can use yarn run jest --watch. In watch mode, Jest will only rerun tests related to changed files, making your TDD process smoother.

Now that our project is set up let’s start writing our first test!

Writing Our First Test

The first step in TDD is always writing a test. We’ll start by testing the first feature of our scheduler. It should be able to receive and store a function.

const Scheduler = require('./scheduler.js');

describe('scheduler functionality', () => {
    let scheduler;

    beforeEach(() => {
        scheduler = Scheduler();
    });

    test('receives a function and stores it', () => {
        const mockFn = () => 1;
        expect(scheduler.count()).toEqual(0);
        scheduler.add(mockFn);
        expect(scheduler.count()).toEqual(1);
    });
});

We’re using a Jest hook, beforeEach, to initialize a new scheduler before each test. This ensures that each test is isolated and not affected by the others. We then create a mock function, mockFn, and add it to our scheduler using the add method. We use the count method to verify that the function was indeed added.

Our test will fail at this stage because we still need an implementation. Let’s write the minimum amount of code to make this test pass.

const scheduler = function() {
    const functions = [];

    return {
        add: (fn) => {
            functions.push(fn);
        },
        count: () => functions.length
    }
}

module.exports = scheduler;

Writing Our Second Test

Now that we have our add method in place let’s write a test to verify that the scheduler can execute stored functions correctly.

test('executes the stored functions', () => {
    const mockFn = jest.fn((a, b) => a + b);
    scheduler.add(mockFn, 1, 3);
    expect(mockFn).not.toHaveBeenCalled();
    scheduler.execute();
    expect(mockFn).toHaveBeenCalledTimes(1);
})

Our test will fail as we have yet to have an execute method. Let’s implement it:

const scheduler = function() {
    const functions = [];

    return {
        add: (fn, arg1, arg2) => {
            functions.push({fn, arg1, arg2});
        },
        execute: () => {
            functions.forEach(o => o.fn(o.arg1, o.arg2));
        },
        count: () => functions.length
    }
}

module.exports = scheduler;

Writing Our Third Test

Next, we’ll ensure that our functions are executed with the correct arguments.

test('executes the stored functions with the correct arguments', () => {
    const mockFn = jest.fn((a, b) => a + b);
    scheduler.add(mockFn, 1, 3);
    expect(mockFn).not.toHaveBeenCalled();
    scheduler.execute();
    expect(mockFn).toHaveBeenCalledWith(1, 3);
})

Since we’ve already implemented the functionality to execute functions with arguments, this test should pass without any new code.

Writing Our Fourth Test

Lastly, let’s verify that the results of our function execution are correct.

test('verifies the result of each executed function is correct', () => {
    const mockFn = jest.fn((a, b) => a + b);
    scheduler.add(mockFn, 1, 3);
    const results = scheduler.execute();
    expect(results).toEqual([4]);
})

Our test will fail as our execute method doesn’t return any results. Let’s update it:

const scheduler = function() {
    const functions = [];

    return {
        add: (fn, arg1, arg2) => {
            functions.push({fn, arg1, arg2});
        },
        execute: () => {
            return functions.map(o => o.fn(o.arg1, o.arg2));
        },
        count: () => functions.length
    }
}

module.exports = scheduler;

Enhancing Our Scheduler: Supporting Arbitrary Number of Parameters

So far, our scheduler has done a great job handling functions with two arguments. But what if we want to add and execute functions with an arbitrary number of parameters? This is easily achievable in JavaScript with the spread syntax (…).

Refactoring Our Code

First, we need to modify the add method in our scheduler to accept an arbitrary number of arguments:

add: (fn, ...args) => {
    functions.push({fn, args});
},

Notice how we’ve changed arg1, arg2 to …args. This allows us to pass as many arguments as we want. These arguments are collected into an array of args.

Next, let’s modify the execute method to use these arguments when calling a function:

execute: () => {
    return functions.map(o => o.fn(...o.args));
},

Again, we use the spread syntax to expand our array args into individual arguments when calling the function.

Testing Our Enhancement

To ensure our enhancement works as expected, we’ll write a test case:

test('executes functions with arbitrary number of parameters', () => {
    const mockFn = jest.fn((...nums) => nums.reduce((a, b) => a + b, 0));
    scheduler.add(mockFn, 1, 2, 3, 4);
    const results = scheduler.execute();
    expect(results).toEqual([10]);
});

This test verifies that our scheduler can correctly handle and execute a function with multiple parameters.

Handling Asynchronous Functions

As JavaScript developers, we often deal with asynchronous functions. Let’s enhance our scheduler to handle this. We want our scheduler to handle asynchronous functions gracefully, and if any function fails during execution, we want to handle it properly.

Writing Our Test

As per the TDD approach, we’ll write the test first:

test('executes asynchronous functions and handles errors', async () => {
    const asyncFn = jest.fn().mockResolvedValue('success');
    const errorFn = jest.fn().mockRejectedValue(new Error('failure'));

    scheduler.add(asyncFn);
    scheduler.add(errorFn);

    const results = await scheduler.execute();

    expect(results).toEqual(['success', new Error('failure')]);
});

This test adds two functions to the scheduler - asyncFn which resolves with ‘success’, and errorFn which rejects with an Error. The execute method is expected to resolve with an array that contains the result of each function execution. Successful functions should resolve with their value, and failed functions should resolve with their Error.

Implementing The Functionality

Now, let’s modify our execute function to handle asynchronous functions and errors:

execute: async () => {
    const results = await Promise.allSettled(functions.map(o => o.fn(...o.args)));
    return results.map(result => result.status === 'fulfilled' ? result.value : result.reason);
},

The Promise.allSettled method returns a promise that resolves after all the given promises have either been fulfilled or rejected, with an array that contains the result of each promise. For each result, if the function was fulfilled, we return its value; if it was rejected, we return its Error.

Conclusion

Our scheduler module becomes even more robust and versatile by supporting asynchronous functions and error handling. Again, our tests gave us the confidence to enhance our code and ensured we did not break any existing functionality. This flexibility and safety is the power of TDD and why it’s an essential tool in every developer’s toolbox. Remember, mastering TDD takes practice, so keep testing and building.

Enjoyed this article? Subscribe for more!

Stay Updated

Get my new content delivered straight to your inbox. No spam, ever.

Related PostsTDD, Nodejs, Development, Jest

Pedro Alonso

I'm a software developer and consultant. I help companies build great products.
Contact me by email, and check out my MVP fastforwardiq.com for summarizing YouTube videos for free!

Get the latest articles delivered straight to your inbox.