Table of Contents
Testing in Node.js
Testing ensures your Node.js applications work correctly and helps prevent bugs in production. This lesson covers unit testing, integration testing, and proper error handling.
Setting Up Testing Environment
# Install testing frameworks
npm install --save-dev jest supertest
# Install additional testing utilities
npm install --save-dev @types/jest @types/supertest
Unit Testing with Jest
Create a simple utility module to test:
// utils/calculator.js
class Calculator {
static add(a, b) {
if (typeof a !== "number" || typeof b !== "number") {
throw new Error("Both arguments must be numbers");
}
return a + b;
}
static subtract(a, b) {
if (typeof a !== "number" || typeof b !== "number") {
throw new Error("Both arguments must be numbers");
}
return a - b;
}
static multiply(a, b) {
if (typeof a !== "number" || typeof b !== "number") {
throw new Error("Both arguments must be numbers");
}
return a * b;
}
static divide(a, b) {
if (typeof a !== "number" || typeof b !== "number") {
throw new Error("Both arguments must be numbers");
}
if (b === 0) {
throw new Error("Division by zero is not allowed");
}
return a / b;
}
}
module.exports = Calculator;
Create unit tests:
// tests/calculator.test.js
const Calculator = require("../utils/calculator");
describe("Calculator", () => {
describe("add", () => {
test("should add two positive numbers", () => {
expect(Calculator.add(2, 3)).toBe(5);
});
test("should add negative numbers", () => {
expect(Calculator.add(-2, -3)).toBe(-5);
});
test("should add positive and negative numbers", () => {
expect(Calculator.add(5, -3)).toBe(2);
});
test("should throw error for non-number inputs", () => {
expect(() => Calculator.add("2", 3)).toThrow("Both arguments must be numbers");
expect(() => Calculator.add(2, "3")).toThrow("Both arguments must be numbers");
});
});
describe("divide", () => {
test("should divide two numbers", () => {
expect(Calculator.divide(10, 2)).toBe(5);
});
test("should throw error for division by zero", () => {
expect(() => Calculator.divide(10, 0)).toThrow("Division by zero is not allowed");
});
test("should handle decimal results", () => {
expect(Calculator.divide(7, 2)).toBe(3.5);
});
});
});
Testing Async Functions
// utils/fileManager.js
const fs = require("fs").promises;
class FileManager {
static async readFile(filePath) {
try {
const content = await fs.readFile(filePath, "utf8");
return content;
} catch (err) {
throw new Error(`Failed to read file: ${err.message}`);
}
}
static async writeFile(filePath, content) {
try {
await fs.writeFile(filePath, content, "utf8");
return true;
} catch (err) {
throw new Error(`Failed to write file: ${err.message}`);
}
}
static async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch (err) {
return false;
}
}
}
module.exports = FileManager;
// tests/fileManager.test.js
const FileManager = require("../utils/fileManager");
const fs = require("fs").promises;
const path = require("path");
describe("FileManager", () => {
const testDir = path.join(__dirname, "temp");
const testFile = path.join(testDir, "test.txt");
beforeAll(async () => {
// Create test directory
await fs.mkdir(testDir, { recursive: true });
});
afterAll(async () => {
// Clean up test directory
await fs.rmdir(testDir, { recursive: true });
});
afterEach(async () => {
// Clean up test file after each test
try {
await fs.unlink(testFile);
} catch (err) {
// File might not exist, ignore error
}
});
describe("writeFile", () => {
test("should write content to file", async () => {
const content = "Hello, World!";
const result = await FileManager.writeFile(testFile, content);
expect(result).toBe(true);
// Verify file was created
const fileContent = await fs.readFile(testFile, "utf8");
expect(fileContent).toBe(content);
});
test("should throw error for invalid path", async () => {
const invalidPath = "/invalid/path/file.txt";
await expect(FileManager.writeFile(invalidPath, "content"))
.rejects.toThrow("Failed to write file");
});
});
describe("readFile", () => {
test("should read file content", async () => {
const content = "Test content";
await fs.writeFile(testFile, content, "utf8");
const result = await FileManager.readFile(testFile);
expect(result).toBe(content);
});
test("should throw error for non-existent file", async () => {
const nonExistentFile = path.join(testDir, "nonexistent.txt");
await expect(FileManager.readFile(nonExistentFile))
.rejects.toThrow("Failed to read file");
});
});
describe("fileExists", () => {
test("should return true for existing file", async () => {
await fs.writeFile(testFile, "content", "utf8");
const exists = await FileManager.fileExists(testFile);
expect(exists).toBe(true);
});
test("should return false for non-existent file", async () => {
const nonExistentFile = path.join(testDir, "nonexistent.txt");
const exists = await FileManager.fileExists(nonExistentFile);
expect(exists).toBe(false);
});
});
});
Integration Testing with Supertest
// app.js
const express = require("express");
const app = express();
app.use(express.json());
// In-memory data store for testing
let users = [];
let nextId = 1;
app.get("/api/users", (req, res) => {
res.json({ users });
});
app.post("/api/users", (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({
error: "Name and email are required"
});
}
const newUser = {
id: nextId++,
name,
email,
createdAt: new Date().toISOString()
};
users.push(newUser);
res.status(201).json({ user: newUser });
});
app.get("/api/users/:id", (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({ user });
});
// Reset function for testing
app.post("/api/reset", (req, res) => {
users = [];
nextId = 1;
res.json({ message: "Data reset" });
});
module.exports = app;
// tests/app.test.js
const request = require("supertest");
const app = require("../app");
describe("User API", () => {
beforeEach(async () => {
// Reset data before each test
await request(app).post("/api/reset");
});
describe("GET /api/users", () => {
test("should return empty array initially", async () => {
const response = await request(app)
.get("/api/users")
.expect(200);
expect(response.body.users).toEqual([]);
});
test("should return users after creating them", async () => {
// Create a user first
await request(app)
.post("/api/users")
.send({ name: "John Doe", email: "john@example.com" });
const response = await request(app)
.get("/api/users")
.expect(200);
expect(response.body.users).toHaveLength(1);
expect(response.body.users[0].name).toBe("John Doe");
});
});
describe("POST /api/users", () => {
test("should create a new user", async () => {
const userData = {
name: "Jane Smith",
email: "jane@example.com"
};
const response = await request(app)
.post("/api/users")
.send(userData)
.expect(201);
expect(response.body.user.name).toBe(userData.name);
expect(response.body.user.email).toBe(userData.email);
expect(response.body.user.id).toBeDefined();
expect(response.body.user.createdAt).toBeDefined();
});
test("should return 400 for missing name", async () => {
const response = await request(app)
.post("/api/users")
.send({ email: "test@example.com" })
.expect(400);
expect(response.body.error).toBe("Name and email are required");
});
test("should return 400 for missing email", async () => {
const response = await request(app)
.post("/api/users")
.send({ name: "Test User" })
.expect(400);
expect(response.body.error).toBe("Name and email are required");
});
});
describe("GET /api/users/:id", () => {
test("should return user by id", async () => {
// Create a user first
const createResponse = await request(app)
.post("/api/users")
.send({ name: "Test User", email: "test@example.com" });
const userId = createResponse.body.user.id;
const response = await request(app)
.get(`/api/users/${userId}`)
.expect(200);
expect(response.body.user.id).toBe(userId);
expect(response.body.user.name).toBe("Test User");
});
test("should return 404 for non-existent user", async () => {
const response = await request(app)
.get("/api/users/999")
.expect(404);
expect(response.body.error).toBe("User not found");
});
});
});
Error Handling Best Practices
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// middleware/errorHandler.js
const AppError = require("../errors/AppError");
const handleCastErrorDB = (err) => {
const message = `Invalid ${err.path}: ${err.value}`;
return new AppError(message, 400);
};
const handleDuplicateFieldsDB = (err) => {
const value = err.errmsg.match(/(["'])(\?.)*?1/)[0];
const message = `Duplicate field value: ${value}. Please use another value!`;
return new AppError(message, 400);
};
const handleValidationErrorDB = (err) => {
const errors = Object.values(err.errors).map(el => el.message);
const message = `Invalid input data. ${errors.join(". ")}`;
return new AppError(message, 400);
};
const handleJWTError = () =>
new AppError("Invalid token. Please log in again!", 401);
const handleJWTExpiredError = () =>
new AppError("Your token has expired! Please log in again.", 401);
const sendErrorDev = (err, res) => {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
};
const sendErrorProd = (err, res) => {
// Operational, trusted error: send message to client
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
// Programming or other unknown error: don't leak error details
console.error("ERROR 💥", err);
res.status(500).json({
status: "error",
message: "Something went wrong!"
});
}
};
module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";
if (process.env.NODE_ENV === "development") {
sendErrorDev(err, res);
} else {
let error = { ...err };
error.message = err.message;
if (error.name === "CastError") error = handleCastErrorDB(error);
if (error.code === 11000) error = handleDuplicateFieldsDB(error);
if (error.name === "ValidationError") error = handleValidationErrorDB(error);
if (error.name === "JsonWebTokenError") error = handleJWTError();
if (error.name === "TokenExpiredError") error = handleJWTExpiredError();
sendErrorProd(error, res);
}
};
Async Error Handling
// utils/catchAsync.js
module.exports = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
// Using catchAsync wrapper
const catchAsync = require("../utils/catchAsync");
const AppError = require("../errors/AppError");
const getUser = catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
return next(new AppError("No user found with that ID", 404));
}
res.status(200).json({
status: "success",
data: { user }
});
});
Exercise
Create a comprehensive testing suite for a blog API that includes:
- Unit tests for utility functions
- Integration tests for all API endpoints
- Error handling tests
- Authentication tests
- Database integration tests with test database
What's Next?
In the final lesson, we'll explore deployment strategies and production best practices for Node.js applications.