Testing

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 beforeEach for 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.

References

Powered by Contentful