Unit Testing Strategies for Node.js Applications
A practical guide to unit testing Node.js applications with Jest including test structure, assertions, mocking, async testing, error handling, and test organization.
Unit Testing Strategies for Node.js Applications
Unit tests verify that individual functions and modules work correctly in isolation. They run in milliseconds, catch bugs before they reach production, and serve as living documentation for how your code is supposed to behave. A well-tested codebase lets you refactor with confidence — if the tests pass, the behavior has not changed.
I write tests for every project. Not because I enjoy it, but because the alternative — manually testing every code path after every change — is slower and less reliable. This guide covers the testing strategies that actually work in real Node.js applications.
Prerequisites
- Node.js installed (v14+)
- Jest installed (
npm install --save-dev jest) - A project with functions to test
- Basic JavaScript knowledge
Setting Up Jest
npm install --save-dev jest
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "node",
"coverageDirectory": "coverage",
"collectCoverageFrom": [
"src/**/*.js",
"!src/**/*.test.js"
]
}
}
Test Structure
File Organization
src/
utils/
validation.js
validation.test.js # Co-located test file
services/
userService.js
userService.test.js
models/
user.js
user.test.js
Or a separate test directory:
src/
utils/validation.js
services/userService.js
tests/
utils/validation.test.js
services/userService.test.js
Test Anatomy
// validation.test.js
var validation = require("./validation");
describe("validation", function() {
describe("isEmail", function() {
test("accepts valid email addresses", function() {
expect(validation.isEmail("[email protected]")).toBe(true);
expect(validation.isEmail("[email protected]")).toBe(true);
expect(validation.isEmail("[email protected]")).toBe(true);
});
test("rejects invalid email addresses", function() {
expect(validation.isEmail("")).toBe(false);
expect(validation.isEmail("not-an-email")).toBe(false);
expect(validation.isEmail("@domain.com")).toBe(false);
expect(validation.isEmail("user@")).toBe(false);
});
test("rejects null and undefined", function() {
expect(validation.isEmail(null)).toBe(false);
expect(validation.isEmail(undefined)).toBe(false);
});
});
describe("isInRange", function() {
test("returns true for values within range", function() {
expect(validation.isInRange(5, 1, 10)).toBe(true);
expect(validation.isInRange(1, 1, 10)).toBe(true);
expect(validation.isInRange(10, 1, 10)).toBe(true);
});
test("returns false for values outside range", function() {
expect(validation.isInRange(0, 1, 10)).toBe(false);
expect(validation.isInRange(11, 1, 10)).toBe(false);
});
});
});
The AAA Pattern
Every test follows Arrange-Act-Assert:
test("calculateTotal returns sum of item prices with tax", function() {
// Arrange
var items = [
{ name: "Widget", price: 10.00, quantity: 2 },
{ name: "Gadget", price: 25.00, quantity: 1 }
];
var taxRate = 0.08;
// Act
var total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(48.60);
});
Assertion Patterns
Common Matchers
// Equality
expect(value).toBe(5); // Strict equality (===)
expect(value).toEqual({ a: 1, b: 2 }); // Deep equality
expect(value).not.toBe(3); // Negation
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3, 5); // Floating point
// Strings
expect(value).toMatch(/pattern/);
expect(value).toContain("substring");
expect(value).toHaveLength(5);
// Arrays
expect(array).toContain("item");
expect(array).toContainEqual({ id: 1 });
expect(array).toHaveLength(3);
expect(array).toEqual(expect.arrayContaining([1, 2]));
// Objects
expect(obj).toHaveProperty("name");
expect(obj).toHaveProperty("address.city", "Oakland");
expect(obj).toMatchObject({ name: "Shane", role: "admin" });
expect(obj).toEqual(expect.objectContaining({ name: "Shane" }));
Custom Matchers
expect.extend({
toBeValidEmail: function(received) {
var pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
var pass = pattern.test(received);
return {
pass: pass,
message: function() {
return "expected " + received + (pass ? " not" : "") + " to be a valid email";
}
};
}
});
test("user email is valid", function() {
expect("[email protected]").toBeValidEmail();
expect("not-an-email").not.toBeValidEmail();
});
Testing Async Code
Callbacks
test("readFile returns file content", function(done) {
readFile("test.txt", function(err, data) {
expect(err).toBeNull();
expect(data).toContain("Hello");
done();
});
});
Promises
test("fetchUser returns user data", function() {
return fetchUser(1).then(function(user) {
expect(user.name).toBe("Shane");
expect(user.id).toBe(1);
});
});
Async/Await (with function syntax)
test("fetchUser returns user data", function() {
return fetchUser(1).then(function(user) {
expect(user.name).toBe("Shane");
});
});
// Testing promise rejection
test("fetchUser throws for invalid ID", function() {
return expect(fetchUser(-1)).rejects.toThrow("Invalid user ID");
});
// Resolves/rejects helpers
test("fetchUser resolves with user data", function() {
return expect(fetchUser(1)).resolves.toMatchObject({ name: "Shane" });
});
Mocking
Function Mocks
test("processOrder calls payment service", function() {
var mockPayment = jest.fn();
mockPayment.mockReturnValue({ status: "success", transactionId: "tx123" });
var order = { id: 1, total: 99.99 };
processOrder(order, mockPayment);
expect(mockPayment).toHaveBeenCalledTimes(1);
expect(mockPayment).toHaveBeenCalledWith(99.99, expect.any(String));
});
Mock Return Values
var mockFn = jest.fn();
// Return a fixed value
mockFn.mockReturnValue(42);
// Return different values on successive calls
mockFn
.mockReturnValueOnce(1)
.mockReturnValueOnce(2)
.mockReturnValue(0); // All subsequent calls
// Return a promise
mockFn.mockResolvedValue({ data: "test" });
mockFn.mockRejectedValue(new Error("Network error"));
// Custom implementation
mockFn.mockImplementation(function(x) {
return x * 2;
});
Module Mocking
// Mock an entire module
jest.mock("./database");
var database = require("./database");
// All exported functions are now jest.fn()
database.findUser.mockResolvedValue({ id: 1, name: "Shane" });
test("getUser queries the database", function() {
var userService = require("./userService");
return userService.getUser(1).then(function(user) {
expect(database.findUser).toHaveBeenCalledWith(1);
expect(user.name).toBe("Shane");
});
});
Partial Module Mocking
jest.mock("./utils", function() {
var actual = jest.requireActual("./utils");
return Object.assign({}, actual, {
sendEmail: jest.fn().mockResolvedValue({ sent: true })
});
});
// sendEmail is mocked, everything else is real
var utils = require("./utils");
Mocking Node.js Modules
jest.mock("fs");
var fs = require("fs");
test("readConfig reads from disk", function() {
fs.readFileSync.mockReturnValue('{"port": 3000}');
var config = require("./config");
expect(config.port).toBe(3000);
expect(fs.readFileSync).toHaveBeenCalledWith(
expect.stringContaining("config.json"),
"utf-8"
);
});
Setup and Teardown
describe("UserService", function() {
var db;
beforeAll(function() {
// Runs once before all tests in this describe block
db = createTestDatabase();
});
afterAll(function() {
// Runs once after all tests
db.close();
});
beforeEach(function() {
// Runs before each test
db.clear();
db.seed({ users: [{ id: 1, name: "Shane" }] });
});
afterEach(function() {
// Runs after each test
jest.clearAllMocks();
});
test("findUser returns user by ID", function() {
var user = db.findUser(1);
expect(user.name).toBe("Shane");
});
});
Testing Error Handling
// Testing thrown errors
test("divide throws on division by zero", function() {
expect(function() {
divide(10, 0);
}).toThrow("Cannot divide by zero");
});
test("throws specific error type", function() {
expect(function() {
parseConfig("invalid json");
}).toThrow(SyntaxError);
});
// Testing async errors
test("fetchUser throws for missing user", function() {
return expect(fetchUser(999)).rejects.toThrow("User not found");
});
// Testing error properties
test("validation error includes field name", function() {
try {
validateUser({ name: "" });
// If we get here, the test should fail
expect(true).toBe(false);
} catch (err) {
expect(err.message).toContain("name");
expect(err.code).toBe("VALIDATION_ERROR");
expect(err.field).toBe("name");
}
});
Testing Patterns
Data-Driven Tests
describe("isValidAge", function() {
var testCases = [
{ input: 0, expected: false, description: "zero" },
{ input: -1, expected: false, description: "negative" },
{ input: 1, expected: true, description: "minimum valid" },
{ input: 17, expected: false, description: "under 18" },
{ input: 18, expected: true, description: "exactly 18" },
{ input: 65, expected: true, description: "senior" },
{ input: 150, expected: false, description: "unrealistic age" },
{ input: null, expected: false, description: "null" },
{ input: "25", expected: true, description: "string number" },
{ input: "abc", expected: false, description: "non-numeric string" }
];
testCases.forEach(function(tc) {
test("returns " + tc.expected + " for " + tc.description + " (" + tc.input + ")", function() {
expect(isValidAge(tc.input)).toBe(tc.expected);
});
});
});
Testing Pure Functions
// Pure functions are the easiest to test — no side effects
var math = require("./math");
describe("calculateCompoundInterest", function() {
test("calculates correctly for annual compounding", function() {
var result = math.compoundInterest(1000, 0.05, 10, 1);
expect(result).toBeCloseTo(1628.89, 2);
});
test("calculates correctly for monthly compounding", function() {
var result = math.compoundInterest(1000, 0.05, 10, 12);
expect(result).toBeCloseTo(1647.01, 2);
});
test("returns principal for zero interest rate", function() {
var result = math.compoundInterest(1000, 0, 10, 1);
expect(result).toBe(1000);
});
test("returns principal for zero years", function() {
var result = math.compoundInterest(1000, 0.05, 0, 1);
expect(result).toBe(1000);
});
});
Testing with Dependency Injection
// userService.js
function createUserService(db, emailClient, logger) {
return {
createUser: function(userData) {
var user = db.insert("users", userData);
emailClient.send(user.email, "Welcome!");
logger.info("User created: " + user.id);
return user;
}
};
}
module.exports = createUserService;
// userService.test.js
var createUserService = require("./userService");
describe("UserService.createUser", function() {
var mockDb;
var mockEmail;
var mockLogger;
var service;
beforeEach(function() {
mockDb = {
insert: jest.fn().mockReturnValue({ id: 1, email: "[email protected]", name: "Test" })
};
mockEmail = { send: jest.fn().mockResolvedValue(true) };
mockLogger = { info: jest.fn(), error: jest.fn() };
service = createUserService(mockDb, mockEmail, mockLogger);
});
test("inserts user into database", function() {
var userData = { name: "Shane", email: "[email protected]" };
service.createUser(userData);
expect(mockDb.insert).toHaveBeenCalledWith("users", userData);
});
test("sends welcome email", function() {
service.createUser({ name: "Shane", email: "[email protected]" });
expect(mockEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});
test("logs user creation", function() {
service.createUser({ name: "Shane", email: "[email protected]" });
expect(mockLogger.info).toHaveBeenCalledWith("User created: 1");
});
test("returns the created user", function() {
var result = service.createUser({ name: "Shane", email: "[email protected]" });
expect(result).toEqual({ id: 1, email: "[email protected]", name: "Test" });
});
});
Complete Working Example: Testing a Real Module
// src/cart.js
function createCart() {
var items = [];
return {
addItem: function(product, quantity) {
if (!product || !product.id || !product.price) {
throw new Error("Invalid product");
}
if (quantity < 1) {
throw new Error("Quantity must be at least 1");
}
var existing = items.find(function(item) {
return item.product.id === product.id;
});
if (existing) {
existing.quantity += quantity;
} else {
items.push({ product: product, quantity: quantity });
}
},
removeItem: function(productId) {
var index = items.findIndex(function(item) {
return item.product.id === productId;
});
if (index === -1) {
throw new Error("Item not in cart");
}
items.splice(index, 1);
},
getTotal: function() {
return items.reduce(function(sum, item) {
return sum + (item.product.price * item.quantity);
}, 0);
},
getItemCount: function() {
return items.reduce(function(sum, item) {
return sum + item.quantity;
}, 0);
},
getItems: function() {
return items.slice();
},
clear: function() {
items = [];
}
};
}
module.exports = createCart;
// src/cart.test.js
var createCart = require("./cart");
describe("Cart", function() {
var cart;
var productA;
var productB;
beforeEach(function() {
cart = createCart();
productA = { id: "a1", name: "Widget", price: 9.99 };
productB = { id: "b2", name: "Gadget", price: 24.99 };
});
describe("addItem", function() {
test("adds a product to the cart", function() {
cart.addItem(productA, 1);
expect(cart.getItems()).toHaveLength(1);
expect(cart.getItems()[0].product).toEqual(productA);
});
test("increments quantity for duplicate products", function() {
cart.addItem(productA, 1);
cart.addItem(productA, 2);
expect(cart.getItems()).toHaveLength(1);
expect(cart.getItems()[0].quantity).toBe(3);
});
test("throws for invalid product", function() {
expect(function() { cart.addItem(null, 1); }).toThrow("Invalid product");
expect(function() { cart.addItem({}, 1); }).toThrow("Invalid product");
});
test("throws for quantity less than 1", function() {
expect(function() { cart.addItem(productA, 0); }).toThrow("Quantity must be at least 1");
expect(function() { cart.addItem(productA, -1); }).toThrow("Quantity must be at least 1");
});
});
describe("removeItem", function() {
test("removes an item from the cart", function() {
cart.addItem(productA, 1);
cart.addItem(productB, 1);
cart.removeItem("a1");
expect(cart.getItems()).toHaveLength(1);
expect(cart.getItems()[0].product).toEqual(productB);
});
test("throws when item is not in cart", function() {
expect(function() { cart.removeItem("nonexistent"); }).toThrow("Item not in cart");
});
});
describe("getTotal", function() {
test("returns 0 for empty cart", function() {
expect(cart.getTotal()).toBe(0);
});
test("calculates total for single item", function() {
cart.addItem(productA, 2);
expect(cart.getTotal()).toBeCloseTo(19.98, 2);
});
test("calculates total for multiple items", function() {
cart.addItem(productA, 2);
cart.addItem(productB, 1);
expect(cart.getTotal()).toBeCloseTo(44.97, 2);
});
});
describe("getItemCount", function() {
test("returns 0 for empty cart", function() {
expect(cart.getItemCount()).toBe(0);
});
test("counts total quantity across all items", function() {
cart.addItem(productA, 3);
cart.addItem(productB, 2);
expect(cart.getItemCount()).toBe(5);
});
});
describe("clear", function() {
test("removes all items", function() {
cart.addItem(productA, 1);
cart.addItem(productB, 2);
cart.clear();
expect(cart.getItems()).toHaveLength(0);
expect(cart.getTotal()).toBe(0);
});
});
});
Common Issues and Troubleshooting
Tests pass individually but fail when run together
Shared state between tests is not being reset:
Fix: Use beforeEach to reset state before every test. Use jest.clearAllMocks() in afterEach to reset mock state. Avoid module-level variables that persist across tests.
Mock is not being applied
jest.mock() must be called before require():
Fix: Place jest.mock("./module") at the top of the test file, before any require() calls. Jest hoists jest.mock() calls automatically, but the module reference must use the same path string.
Async test times out
The test is waiting for a callback or promise that never resolves:
Fix: Ensure async operations complete. For callbacks, make sure done() is called. For promises, return the promise from the test function. Check for unhandled rejections. Increase timeout if needed: jest.setTimeout(10000).
Floating point comparison fails
expect(0.1 + 0.2).toBe(0.3) fails because of floating point precision:
Fix: Use toBeCloseTo(0.3, 5) instead of toBe(0.3). The second argument is the number of decimal digits to check.
Best Practices
- Test behavior, not implementation. Tests should verify what a function does, not how it does it. If you refactor the implementation and tests break, the tests were too tightly coupled.
- One assertion per test when possible. Tests with single assertions have clearer failure messages and are easier to debug. Multiple related assertions in one test are acceptable.
- Name tests descriptively. The test name should read like a specification: "returns empty array when no users match the filter." When a test fails, the name should tell you what broke.
- Use
beforeEachfor shared setup. Do not repeat setup code in every test. But keep test-specific setup in the test itself so each test is self-documenting. - Mock external dependencies, not your own code. Mock databases, HTTP clients, and file systems. Do not mock the module under test — that defeats the purpose.
- Keep tests fast. Unit tests should run in milliseconds. If a test needs a database or network, it is an integration test, not a unit test.
- Test edge cases and error paths. Empty arrays, null values, boundary conditions, and error scenarios are where bugs hide. Happy-path-only tests miss real-world failures.