Introduction to Testing in Node.js with Jest

It's time to explore a crucial aspect of professional software development: testing. We'll dive into the testing world, focusing on using Jest, a popular JavaScript testing framework, to ensure our Node.js applications work as expected.

I always say, "People who write good tests write good code." And that's because writing good tests requires a developer to:

  • Clearly understand what the code should do
  • Think about different scenarios, including unusual ones
  • Break the code into manageable pieces

These skills directly contribute to writing good, clear, and reliable code. Essentially, the thought process behind good testing aligns closely with the thought process behind good coding.

Although some coders dislike it, it's a skill that will make you a much more effective developer.

Why Testing Matters

If being a better coder wasn't enough of a reason, before we jump into the how, let's understand the why. Testing is a critical part of the development process for several reasons:

  • It helps catch bugs early in the development cycle.
  • It ensures and proves that your code works as intended.
  • It makes refilling or adding new features easier without breaking existing functionality.
  • It serves as documentation, showing how your code is supposed to work.
  • It gives you confidence in your code, especially when working in teams or on large projects.

Types of Tests

In software development, we typically encounter three main types of tests:

Unit Tests: These test individual functions or components in isolation. They're fast, focused, and help pinpoint issues in specific parts of your code.

Integration Tests: These test how different parts of your application work together. They ensure that various components integrate properly.

End-to-End (E2E) Tests: These test the entire application flow, simulating real user scenarios. They're comprehensive but can be slower and more complex to set up.

In this section, we'll focus on unit testing, which is the foundation of a good testing strategy and is easiest to implement when starting with testing.

Introducing Jest

Jest is a "delightful" JavaScript testing framework focusing on simplicity. It works with pretty much any kind of JavaScript project you are working on so it's a skill that you can take far further than just this Node.js course.

Some key features of Jest include:

  • Zero config for most JavaScript projects
  • Fast and parallel test execution
  • Built-in code coverage reports
  • Powerful mocking capabilities

Setting Up Jest

Let's set up Jest in our project. First, we need to install it:

npm install --save-dev jest

Next, add a test script to your package.json:

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

Writing Our First Test

Let's start with a straightforward function and write a test for it. We'll create a new file called math.js with a simple addition function:

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

module.exports = { add };

Now, let's create a test file for this module. Create a new file called math.test.js:

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

test('adds 1 + 2 to equal 3', () => {
    expect(add(1, 2)).toBe(3);
});

Let's break down this test:

  1. We import the add function from our math.js file.
  2. We use the test function provided by Jest to define our test case. It takes two arguments:
    • A string describing the test
    • A function that contains the actual test
  3. Inside the test function, we use expect and toBe, which are part of Jest's assertion library:
    • expect(add(1, 2)) calls our function with arguments 1 and 2
    • .toBe(3) asserts that the result should be 3

Running the Test

To run the test, execute:

npm test

You should see the output indicating that the test passed.

Writing More Tests

Let's add more tests to cover different scenarios:

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

test('adds 1 + 2 to equal 3', () => {
    expect(add(1, 2)).toBe(3);
});

test('adds -1 + 1 to equal 0', () => {
    expect(add(-1, 1)).toBe(0);
});

test('adds 0.1 + 0.2 to equal 0.3', () => {
    expect(add(0.1, 0.2)).toBeCloseTo(0.3); // Use toBeCloseTo for floating point numbers
});

Here, we've added tests for negative numbers and floating-point numbers. Note the use of toBeCloseTo for comparing floating-point numbers, as direct equality checks can be unreliable due to how computers represent these numbers.

Rerun the tests, and everything should pass. Add some wrong results to see what failing tests look like.

Testing Asynchronous Code

Many functions in Node.js are asynchronous. Let's see how to test an asynchronous function:

// async-math.js
function asyncAdd(a, b) {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve(a + b);
        }, 100);
    });
}

module.exports = { asyncAdd };

Now, let's test this asynchronous function:

// async-math.test.js
const { asyncAdd } = require('./async-math');

test('asyncAdd adds 1 + 2 to equal 3', async () => {
    const result = await asyncAdd(1, 2);
    expect(result).toBe(3);
});

Note the use of async and await in the test function. This allows us to work with the Promise returned by asyncAdd.

Grouping Related Tests

As your test file grows, it's helpful to group related tests. Jest provides the describe function for this:

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

describe('add function', () => {
    test('adds positive numbers', () => {
        expect(add(1, 2)).toBe(3);
    });

    test('adds negative numbers', () => {
        expect(add(-1, -1)).toBe(-2);
    });

    test('adds zero', () => {
        expect(add(5, 0)).toBe(5);
    });
});

describe helps organize your tests and can make the test output more readable, especially for larger test suites.

Testing the Task Manager

Now that we've covered the basics let's return to our task manager CLI tool and write some tests for it. We'll start with a simplified version of our addTask function:

// task-manager.js
let tasks = [];

function addTask(description) {
    tasks.push({ description, completed: false });
    return 'Task added successfully.';
}

function getTasks() {
    return tasks;
}

function clearTasks() {
    tasks = [];
}

module.exports = { addTask, getTasks, clearTasks };

Let's write tests for this module:

// task-manager.test.js
const { addTask, getTasks, clearTasks } = require('./task-manager');

describe('Task Manager', () => {
    beforeEach(() => {
        clearTasks(); // Clear tasks before each test
    });

    test('addTask should add a new task', () => {
        const result = addTask('New task');
        expect(result).toBe('Task added successfully.');
        expect(getTasks()).toHaveLength(1);
        expect(getTasks()[0]).toEqual({ description: 'New task', completed: false });
    });

    test('addTask should add multiple tasks', () => {
        addTask('Task 1');
        addTask('Task 2');
        expect(getTasks()).toHaveLength(2);
        expect(getTasks()[1].description).toBe('Task 2');
    });
});

In these tests, we're using beforeEach to reset our tasks before each test, ensuring that tests don't interfere with each other. We're also using toHaveLength and toEqual matchers to check the contents of our tasks array.

Introduction to Mocking

Now that we've covered the basics, let's briefly introduce the concept of mocking. Mocking is useful when you want to test a function that depends on other functions or external services without actually calling those dependencies.

For example, if our addTask function saved tasks to a file, we might want to mock the file system operations during testing. Here's a simple example:

// task-manager.js
const fs = require('fs');

function addTask(description) {
    const tasks = loadTasks();
    tasks.push({ description, completed: false });
    saveTasks(tasks);
    return 'Task added successfully.';
}

function loadTasks() {
    try {
        const data = fs.readFileSync('tasks.json', 'utf8');
        return JSON.parse(data);
    } catch (error) {
        return [];
    }
}

function saveTasks(tasks) {
    fs.writeFileSync('tasks.json', JSON.stringify(tasks, null, 2));
}

module.exports = { addTask };

To test this without actually reading or writing files, we can use Jest's mocking capabilities:

// task-manager.test.js
const { addTask } = require('./task-manager');
const fs = require('fs');

jest.mock('fs');

describe('Task Manager with file operations', () => {
    beforeEach(() => {
        fs.readFileSync.mockReturnValue('[]');
    });

    test('addTask should add a new task', () => {
        const result = addTask('New task');
        expect(result).toBe('Task added successfully.');
        expect(fs.writeFileSync).toHaveBeenCalledWith(
            'tasks.json',
            JSON.stringify([{ description: 'New task', completed: false }], null, 2)
        );
    });
});

This test mocks the fs module, allowing us to test our addTask function without actually interacting with the file system.

Next Steps

We've covered a lot of ground in this introduction to testing with Jest. As you continue your Node.js journey, you'll want to explore:

  • More advanced Jest matchers and assertions
  • Test coverage reports
  • Integration tests for your APIs
  • More complex mocking scenarios
  • Test-driven development (TDD) practices

Good tests make your code more reliable, easier to refactor, and can even guide your design decisions. The key is to start simple and gradually add more tests as you become comfortable with the process. Happy testing!

NodejsBeginner
Avatar for Niall Maher

Written by Niall Maher

Founder of Codú - The web developer community! I've worked in nearly every corner of technology businesses: Lead Developer, Software Architect, Product Manager, CTO, and now happily a Founder.

Loading

Fetching comments

Hey! 👋

Got something to say?

or to leave a comment.