When you start building applications in Node.js, it is tempting to dive straight into writing features and only test them manually. While this can work for small projects, it quickly becomes a problem as your codebase grows. Bugs appear, existing features break when you add new ones, and manual testing becomes slow.
This is where test-driven development, or TDD, comes into play. TDD is a practice where you first write a test for a feature, watch it fail, then write the code to make it pass, and finally clean up your implementation while keeping the test green. This cycle encourages you to think clearly about what your code should do before you even write it.
In this article, we will walk through setting up a Node.js project for TDD, writing the first tests, and building a simple API with Jest and Supertest. By the end you will have a practical workflow that you can apply to 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.