It can be tempting to jump right into coding features and solely testing them by hand when you first start developing applications in Node.js. Small projects may benefit from this, but as your codebase expands, it soon becomes an issue. When you add new features, existing ones break, bugs occur, and manual testing slows down.


Test-driven development, or TDD, is useful in this situation. TDD is the process of writing a test for a feature, seeing it fail, writing the code to pass it, and then cleaning up your implementation while maintaining a green test score. This cycle pushes you to consider your code's purpose carefully before writing it.

This post will explain how to set up a Node.js project for TDD, write the initial tests, and use Jest and Supertest to create a basic API. You will have a useful workflow at the end that you may use for your own projects.

Why TDD Matters in Node.js?
Node.js is often used for building backends and APIs. These systems typically interact with databases, handle multiple requests, and address edge cases such as invalid inputs or timeouts. If you rely only on manual testing, it is very easy to miss hidden bugs.

With TDD, you get:

  • Confidence that your code works as expected.
  • Documentation of your intended behavior through test cases.
  • Refactoring freedom, since you can change implementation details while ensuring nothing breaks.
  • Fewer regressions because tests catch mistakes early.

Let us start building a small project using this approach.

Step 1. Setting Up the Project
Create a new folder for the project and initialize npm:
mkdir tdd-node-example
cd tdd-node-example
npm init -y


This creates a package.json file that will hold project metadata and dependencies.

Now install Jest, which is a popular testing framework for Node.js:
npm install --save-dev jest

Also, install Supertest, which will help us test HTTP endpoints:
npm install --save-dev supertest

To make things easier, add a test script in package.json:
{
  "scripts": {
    "test": "jest"
  }
}


This allows you to run tests with npm test.

Step 2. Writing the First Failing Test

Let us create a simple module that manages a list of tasks, similar to a basic to-do list. Following TDD, we will start with the test.

Inside a tests folder, create taskManager.test.js:
const TaskManager = require("../taskManager");

describe("TaskManager", () => {
  it("should add a new task", () => {
    const manager = new TaskManager();
    manager.addTask("Learn TDD");
    const tasks = manager.getTasks();
    expect(tasks).toContain("Learn TDD");
  });
});


We have not written taskManager.js yet, so that this test will fail. That is the point.

Run the test:
npm test

Jest will complain that ../taskManager it cannot be found. That confirms we need to write the implementation.

Step 3. Making the Test Pass

Now create taskManager.js at the root:
class TaskManager {
  constructor() {
    this.tasks = [];
  }

  addTask(task) {
    this.tasks.push(task);
  }

  getTasks() {
    return this.tasks;
  }
}

module.exports = TaskManager;

Run npm test again. This time the test passes. Congratulations, you just completed your first TDD cycle: red → green.

Step 4. Adding More Tests

Now, let us expand our tests. Modify taskManager.test.js:
const TaskManager = require("../taskManager");

describe("TaskManager", () => {
  it("should add a new task", () => {
    const manager = new TaskManager();
    manager.addTask("Learn TDD");
    expect(manager.getTasks()).toContain("Learn TDD");
  });

  it("should remove a task", () => {
    const manager = new TaskManager();
    manager.addTask("Learn Jest");
    manager.removeTask("Learn Jest");
    expect(manager.getTasks()).not.toContain("Learn Jest");
  });

  it("should return an empty list initially", () => {
    const manager = new TaskManager();
    expect(manager.getTasks()).toEqual([]);
  });
});

Now rerun the tests. The one for removeTask will fail since we have not implemented it.

Update taskManager.js:
class TaskManager {
  constructor() {
    this.tasks = [];
  }

  addTask(task) {
    this.tasks.push(task);
  }

  removeTask(task) {
    this.tasks = this.tasks.filter(t => t !== task);
  }

  getTasks() {
    return this.tasks;
  }
}

module.exports = TaskManager;

Run npm test again. All tests pass. Notice how the tests guided the implementation.

Step 5. Refactoring Safely
One beauty of TDD is that you can refactor with confidence. For example, we could change how tasks are stored internally. Maybe instead of an array, we want a Set to avoid duplicates.

Update the class
class TaskManager {
  constructor() {
    this.tasks = new Set();
  }

  addTask(task) {
    this.tasks.add(task);
  }

  removeTask(task) {
    this.tasks.delete(task);
  }

  getTasks() {
    return Array.from(this.tasks);
  }
}

module.exports = TaskManager;


Run the tests again. If they all pass, you know your refactor did not break behavior.

Step 6. Testing an API with Jest and Supertest

Unit tests are important, but most Node.js applications expose APIs. Let us use Express and Supertest to apply TDD to an endpoint.

First, install Express:
npm install express

Create app.js:
const express = require("express");
const TaskManager = require("./taskManager");

const app = express();
app.use(express.json());

const manager = new TaskManager();

app.post("/tasks", (req, res) => {
  const { task } = req.body;
  manager.addTask(task);
  res.status(201).json({ tasks: manager.getTasks() });
});

app.get("/tasks", (req, res) => {
  res.json({ tasks: manager.getTasks() });
});

module.exports = app;


Now, create a test file tests/app.test.js:
const request = require("supertest");
const app = require("../app");

describe("Task API", () => {
  it("should add a task with POST /tasks", async () => {
    const response = await request(app)
      .post("/tasks")
      .send({ task: "Write tests" })
      .expect(201);

    expect(response.body.tasks).toContain("Write tests");
  });

  it("should return all tasks with GET /tasks", async () => {
    await request(app).post("/tasks").send({ task: "Practice TDD" });

    const response = await request(app)
      .get("/tasks")
      .expect(200);

    expect(response.body.tasks).toContain("Practice TDD");
  });
});

Run npm test. Both tests should pass, confirming that our API works.

To actually run the server, create server.js:
const app = require("./app");

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});


Now you can try node server.js to use a tool like Postman or curl to send requests.

Step 7. Common Pitfalls in TDD

Writing too many trivial tests: Do not test things like whether 2 + 2 equals 4. Focus on meaningful business logic.

  • Forgetting the cycle: Always follow the red → green → refactor cycle. Jumping ahead can lead to sloppy tests.
  • Slow tests: Keep unit tests fast. If you hit a database or external API, use mocks or stubs.
  • Unclear test names: Use descriptive test names that act as documentation.

Step 8. Best Practices

  • Keep your tests in a separate tests folder or alongside the files they test.
  • Run tests automatically before pushing code. You can set up a Git hook or CI pipeline.
  • Use coverage tools to measure how much of your code is tested. With Jest, run npm test -- --coverage.
  • Write tests that are independent of each other. Do not let one test rely on data from another.

Conclusion
Test-driven development with Node.js may feel slow at first, but it quickly pays off by giving you confidence in your code. By starting with a failing test, writing just enough code to pass, and then refactoring, you create a safety net that allows you to move faster in the long run. We walked through setting up Jest, writing unit tests for a TaskManager class, refactoring safely, and even testing API endpoints using Supertest. The process is the same no matter how big your application grows.

If you are new to TDD, begin small. Write a few tests for a utility function or a simple route. With practice, the habit of writing tests before code will become second nature, and your Node.js projects will be more reliable and easier to maintain.

HostForLIFE.eu Node.js Hosting
HostForLIFE.eu is European Windows Hosting Provider which focuses on Windows Platform only. We deliver on-demand hosting solutions including Shared hosting, Reseller Hosting, Cloud Hosting, Dedicated Servers, and IT as a Service for companies of all sizes. We have customers from around the globe, spread across every continent. We serve the hosting needs of the business and professional, government and nonprofit, entertainment and personal use market segments.