Unit Testing Express Controllers and Utilities with Jest

Unit tests in Express test individual pieces of logic in isolation โ€” utility functions like sendEmail, model methods like comparePassword, and validator functions โ€” without starting the HTTP server or touching a database. Jest is the standard test runner for Node.js: it discovers test files, runs them in parallel, provides a rich assertion API, and generates coverage reports. In this lesson you will configure Jest for the Express server, write your first unit tests, and learn how to mock external dependencies so tests run fast and reliably without network calls or database connections.

Jest Setup for the Express Server

cd server
npm install --save-dev jest @types/jest
// server/package.json โ€” add test configuration
{
  "scripts": {
    "test":         "jest",
    "test:watch":   "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "testMatch":       ["**/__tests__/**/*.test.js", "**/*.test.js"],
    "collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"],
    "coverageThreshold": {
      "global": { "branches": 60, "functions": 70, "lines": 70, "statements": 70 }
    }
  }
}
Note: Jest runs each test file in its own isolated Node.js process โ€” modules are re-imported fresh for every test file, preventing state leakage between files. This is why you can mock a module in one test file without affecting another. However, within a single test file, module imports are cached โ€” always reset mocks between tests using jest.clearAllMocks() in beforeEach or configure "clearMocks": true in the Jest config.
Tip: Structure your test files to mirror your source tree: src/utils/sendEmail.js gets src/utils/sendEmail.test.js (or src/__tests__/utils/sendEmail.test.js). This co-location makes it immediately clear what each test file covers and easy to find tests when editing source files. VS Code’s Jest extension can run individual tests from within the test file without switching to the terminal.
Warning: Aim for 70โ€“80% code coverage, not 100%. 100% coverage is achievable but often requires testing trivial code paths (getter methods, simple re-exports) that add maintenance cost without adding confidence. Focus coverage on business logic, validation, error handling, and security-sensitive code. A test with 80% coverage of the right code is far more valuable than 100% coverage that includes tests for trivial one-liners.

Testing Utility Functions

// server/src/utils/validators.test.js
const { validateEmail, validatePassword } = require('./validators');

describe('validateEmail', () => {
  test('accepts valid email addresses', () => {
    expect(validateEmail('user@example.com')).toBe(true);
    expect(validateEmail('user+tag@domain.co.uk')).toBe(true);
  });

  test('rejects invalid email addresses', () => {
    expect(validateEmail('notanemail')).toBe(false);
    expect(validateEmail('@nodomain')).toBe(false);
    expect(validateEmail('')).toBe(false);
    expect(validateEmail(null)).toBe(false);
  });
});

describe('validatePassword', () => {
  test('rejects passwords shorter than 8 characters', () => {
    expect(validatePassword('short')).toBe(false);
  });

  test('accepts passwords of 8 or more characters', () => {
    expect(validatePassword('validPass1!')).toBe(true);
    expect(validatePassword('12345678')).toBe(true);
  });
});

Testing Mongoose Model Methods

// server/src/__tests__/models/User.test.js
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const User = require('../../models/User');

let mongoServer;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
});

afterAll(async () => {
  await mongoose.disconnect();
  await mongoServer.stop();
});

afterEach(async () => {
  await User.deleteMany({}); // clean slate between tests
});

describe('User model', () => {
  test('hashes password before saving', async () => {
    const user = await User.create({
      name:     'Test User',
      email:    'test@example.com',
      password: 'plaintext123',
    });
    expect(user.password).not.toBe('plaintext123'); // stored as hash
    expect(user.password).toMatch(/^\$2[ab]\$/);    // bcrypt hash format
  });

  test('comparePassword returns true for correct password', async () => {
    const user = await User.create({
      name: 'Test', email: 'test2@example.com', password: 'correctPass!',
    });
    const result = await user.comparePassword('correctPass!');
    expect(result).toBe(true);
  });

  test('comparePassword returns false for wrong password', async () => {
    const user = await User.create({
      name: 'Test', email: 'test3@example.com', password: 'correctPass!',
    });
    const result = await user.comparePassword('wrongPass');
    expect(result).toBe(false);
  });

  test('requires name, email, and password', async () => {
    await expect(User.create({})).rejects.toThrow(/validation failed/i);
  });

  test('rejects duplicate email', async () => {
    await User.create({ name: 'A', email: 'dup@example.com', password: 'pass1234' });
    await expect(
      User.create({ name: 'B', email: 'dup@example.com', password: 'pass1234' })
    ).rejects.toThrow(/duplicate key/i);
  });
});

Mocking External Dependencies

// Mock nodemailer so tests do not send real emails
jest.mock('nodemailer', () => ({
  createTransport: jest.fn(() => ({
    sendMail: jest.fn().mockResolvedValue({ messageId: 'test-id' }),
  })),
  createTestAccount: jest.fn().mockResolvedValue({
    user: 'test@ethereal.email',
    pass: 'testpass',
  }),
}));

// Mock cloudinary
jest.mock('cloudinary', () => ({
  v2: {
    config: jest.fn(),
    uploader: {
      upload:  jest.fn().mockResolvedValue({ secure_url: 'https://res.cloudinary.com/test.jpg' }),
      destroy: jest.fn().mockResolvedValue({ result: 'ok' }),
    },
  },
}));

// Test that uses the mocked modules
const sendEmail = require('../utils/sendEmail');

test('sendEmail calls transporter.sendMail with correct options', async () => {
  await sendEmail({ to: 'user@example.com', subject: 'Test', text: 'Hello', html: '<p>Hello</p>' });
  // Verify the email was "sent" (mock called)
  const { createTransport } = require('nodemailer');
  const transporter = createTransport.mock.results[0].value;
  expect(transporter.sendMail).toHaveBeenCalledWith(
    expect.objectContaining({ to: 'user@example.com', subject: 'Test' })
  );
});

Common Jest Matchers

Matcher Use
toBe(value) Strict equality (===)
toEqual(obj) Deep equality โ€” for objects/arrays
toMatch(/regex/) String matches regex
toThrow(/msg/) Function throws matching error
resolves.toBe() Promise resolves to value
rejects.toThrow() Promise rejects with error
toHaveBeenCalled() Mock was called at least once
toHaveBeenCalledWith() Mock called with specific args
expect.objectContaining() Object has at least these keys/values
expect.stringContaining() String includes substring

Common Mistakes

Mistake 1 โ€” Not cleaning up between tests

โŒ Wrong โ€” test data persists and causes later tests to fail:

test('creates a user', async () => {
  await User.create({ email: 'test@example.com', ... });
  // Next test: 'duplicate email' test fails because this user still exists!

โœ… Correct โ€” clean up in afterEach:

afterEach(async () => { await User.deleteMany({}); }); // โœ“ clean slate

Mistake 2 โ€” Not awaiting async test assertions

โŒ Wrong โ€” test passes before the async assertion runs:

test('rejects duplicate email', () => { // missing async!
  expect(User.create({ email: 'dup@example.com' })).rejects.toThrow();
  // Promise not awaited โ€” test passes even if it rejects with wrong error
});

โœ… Correct โ€” use async/await or return the promise:

test('rejects duplicate email', async () => {
  await expect(User.create({ email: 'dup@example.com' })).rejects.toThrow(); // โœ“
});

Mistake 3 โ€” Mocking too much

โŒ Wrong โ€” mocking the module under test:

jest.mock('../utils/validateEmail');
test('validates email', () => { ... }); // testing the mock, not the real function!

โœ… Correct โ€” only mock external dependencies (database, network, email). Test the code you wrote.

Quick Reference

Task Code
Run tests npm test
Watch mode npm run test:watch
Coverage npm run test:coverage
Mock a module jest.mock('moduleName', () => ({ ... }))
In-memory DB MongoMemoryServer.create()
Clean up DB afterEach(async () => Model.deleteMany({}))
Expect async throw await expect(fn()).rejects.toThrow(/msg/)

🧠 Test Yourself

Your test for the User model’s comparePassword method passes even when comparePassword is broken. You suspect the test is not actually running the assertion. What is the most likely cause?