Node.jsBackendFull-Stack

TDD, Mocking & Spying in Node.js

TT
TopicTrick Team
TDD, Mocking & Spying in Node.js

TDD, Mocking & Spying in Node.js

Tests that only run after code is written catch bugs. Tests written before code is written prevent bugs. Test-Driven Development flips the sequence — and in doing so, it changes the way you think about design. You write the interface you wish existed, then make the implementation satisfy it.

This module covers the TDD workflow, Jest mocks and spies in depth, module mocking, fake timers, and testing patterns that produce readable, maintainable test suites.

This is Module 28 of the Node.js Full‑Stack Developer course.


The TDD Cycle

text
1. Red   — Write a failing test for the feature you want
2. Green — Write the minimum code to make the test pass
3. Refactor — Improve the code; tests must still pass

Repeat. The tests accumulate as living documentation of every decision.


TDD Example: Building a Password Reset Service

Step 1: Red — Write the failing test

js
// features/auth/password-reset.service.test.js
import { requestPasswordReset, resetPassword } from './password-reset.service.js';

// These imports do not exist yet — that's the point
jest.mock('../../lib/email.js');
jest.mock('../../lib/redis.js');

describe('Password Reset Service', () => {
  test('requestPasswordReset stores a token and sends an email', async () => {
    const { sendPasswordResetEmail } = await import('../../lib/email.js');
    const redis = (await import('../../lib/redis.js')).default;

    await requestPasswordReset('user@example.com');

    // Redis should store a token keyed by email
    expect(redis.set).toHaveBeenCalledWith(
      expect.stringContaining('password-reset:'),
      expect.any(String),
      'EX',
      3600  // 1 hour TTL
    );

    // Email service should be called
    expect(sendPasswordResetEmail).toHaveBeenCalledWith(
      'user@example.com',
      expect.any(String)
    );
  });

  test('resetPassword throws if token is invalid', async () => {
    const redis = (await import('../../lib/redis.js')).default;
    redis.get.mockResolvedValueOnce(null); // token not found

    await expect(
      resetPassword('invalid-token', 'newpassword123')
    ).rejects.toMatchObject({ statusCode: 400, message: /invalid or expired/i });
  });
});

Run npm test → 🔴 fails (the module does not exist).

Step 2: Green — Write the implementation

js
// features/auth/password-reset.service.js
import crypto from 'crypto';
import redis from '../../lib/redis.js';
import { sendPasswordResetEmail } from '../../lib/email.js';
import { User } from '../users/users.model.js';
import { hashPassword } from '../../lib/crypto.js';
import { AppError } from '../../lib/errors.js';

export async function requestPasswordReset(email) {
  const user = await User.findOne({ email });
  if (!user) return; // Silent — don't reveal if email exists

  const token = crypto.randomBytes(32).toString('hex');
  await redis.set(`password-reset:${token}`, user._id.toString(), 'EX', 3600);
  await sendPasswordResetEmail(email, token);
}

export async function resetPassword(token, newPassword) {
  const userId = await redis.get(`password-reset:${token}`);
  if (!userId) throw new AppError('Invalid or expired reset token', 400);

  const user = await User.findById(userId);
  if (!user) throw new AppError('User not found', 404);

  user.password = await hashPassword(newPassword);
  await user.save();

  await redis.del(`password-reset:${token}`);
}

Run npm test → 🟢 passes.

Step 3: Refactor

The implementation is clean already. Add edge cases to the tests:

js
test('requestPasswordReset is silent for unknown email (no error, no email)', async () => {
  const { sendPasswordResetEmail } = await import('../../lib/email.js');
  await requestPasswordReset('nobody@example.com');
  expect(sendPasswordResetEmail).not.toHaveBeenCalled();
});

test('resetPassword deletes the token after use', async () => {
  const redis = (await import('../../lib/redis.js')).default;
  redis.get.mockResolvedValueOnce('user-id-123');
  // ... setup user ...
  await resetPassword('valid-token', 'newpassword123');
  expect(redis.del).toHaveBeenCalledWith('password-reset:valid-token');
});

jest.fn() — Mock Functions

js
// Create a mock function
const mockSend = jest.fn();

// Default return value
const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: () => ({ data: [] }) });

// Return different values on successive calls
const mockRandom = jest.fn()
  .mockReturnValueOnce(0.1)
  .mockReturnValueOnce(0.9)
  .mockReturnValue(0.5); // default for subsequent calls

// Mock implementation
const mockHash = jest.fn().mockImplementation(async (text) => `hashed:${text}`);

// Assertions
expect(mockSend).toHaveBeenCalled();
expect(mockSend).toHaveBeenCalledTimes(2);
expect(mockSend).toHaveBeenCalledWith('alice@example.com', expect.any(String));
expect(mockSend).toHaveBeenLastCalledWith('bob@example.com', 'Reset your password');
expect(mockSend).toHaveBeenNthCalledWith(1, 'alice@example.com', expect.anything());

// Reset between tests
mockSend.mockReset();   // clears calls + implementation
mockSend.mockClear();   // clears calls only
mockSend.mockRestore(); // restores original (spies only)

jest.spyOn() — Spies

A spy watches an existing function without replacing it (unless you tell it to):

js
import * as crypto from '../../lib/crypto.js';

test('createUser hashes the password', async () => {
  // Spy on the real hashPassword — records calls, still runs original
  const spy = jest.spyOn(crypto, 'hashPassword');

  await createUser({ name: 'Alice', email: 'a@example.com', password: 'plain' });

  expect(spy).toHaveBeenCalledWith('plain');
  expect(spy).toHaveBeenCalledTimes(1);

  spy.mockRestore(); // must restore to avoid leaking into other tests
});

// Replace the implementation
jest.spyOn(crypto, 'hashPassword').mockResolvedValue('$2b$12$mocked');

jest.mock() — Module Mocking

js
// Mock an entire module before any imports
jest.mock('../../lib/email.js', () => ({
  sendWelcomeEmail:      jest.fn().mockResolvedValue(undefined),
  sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
}));

// Auto-mock — replaces all exports with jest.fn()
jest.mock('../../lib/email.js');

// Access mock in tests
import { sendWelcomeEmail } from '../../lib/email.js';
expect(sendWelcomeEmail).toHaveBeenCalled();

// Override for a single test
sendWelcomeEmail.mockRejectedValueOnce(new Error('SMTP failure'));

Partial Mocking

js
// Keep some methods real, mock others
jest.mock('../../lib/redis.js', () => {
  const actual = jest.requireActual('../../lib/redis.js');
  return {
    ...actual,
    default: {
      ...actual.default,
      get: jest.fn(),
      set: jest.fn(),
    },
  };
});

Fake Timers

js
describe('Token expiry', () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test('refresh token expires after 7 days', async () => {
    const token = signRefreshToken({ sub: 'user-1', role: 'user', version: 0 });

    // Advance time by 8 days
    jest.advanceTimersByTime(8 * 24 * 60 * 60 * 1000);

    await expect(verifyRefreshToken(token)).toThrow(/expired/);
  });
});

// Test a debounced function
test('debounced search fires once after 300ms', () => {
  jest.useFakeTimers();
  const mockSearch = jest.fn();
  const debouncedSearch = debounce(mockSearch, 300);

  debouncedSearch('a');
  debouncedSearch('ab');
  debouncedSearch('abc');

  // Not called yet — debounce window hasn't passed
  expect(mockSearch).not.toHaveBeenCalled();

  jest.advanceTimersByTime(300);

  // Called once with the last value
  expect(mockSearch).toHaveBeenCalledTimes(1);
  expect(mockSearch).toHaveBeenCalledWith('abc');

  jest.useRealTimers();
});

Testing Error Handling

js
// Synchronous throw
test('throws for invalid id format', () => {
  expect(() => parseUserId('not-an-objectid')).toThrow('Invalid ID');
});

// Async rejection — correct pattern
test('rejects with 404 for unknown user', async () => {
  await expect(getUserById('000000000000000000000000'))
    .rejects.toMatchObject({ statusCode: 404, message: 'User not found' });
});

// Common mistake — do NOT do this:
test('WRONG — will not catch the rejection', async () => {
  try {
    const result = await getUserById('bad-id'); // throws here
    // expect below never runs
    expect(result).toBe(null);
  } catch (err) {
    // Error swallowed — test passes even if wrong error is thrown
  }
});

// Use expect.assertions to ensure async errors are caught
test('throws when service fails', async () => {
  expect.assertions(1); // test fails if no assertion runs
  try {
    await riskyOperation();
  } catch (err) {
    expect(err.message).toMatch(/failed/);
  }
});

Snapshot Testing for API Responses

js
test('returns expected user shape', async () => {
  const res = await request(app)
    .post('/api/v1/auth/register')
    .send({ name: 'Alice', email: 'alice@example.com', password: 'password123' });

  // First run: creates the snapshot file
  // Subsequent runs: fails if shape changes
  expect(res.body.user).toMatchSnapshot();
});

Snapshots are useful for catching unintended response shape changes. Update them intentionally with jest --updateSnapshot.


Test Factories (Seed Helpers)

js
// tests/factories/user.factory.js
import { User } from '../../src/features/users/users.model.js';
import { hashPassword } from '../../src/lib/crypto.js';

let counter = 0;

export async function createTestUser(overrides = {}) {
  counter++;
  const defaults = {
    name:     `Test User ${counter}`,
    email:    `user${counter}@test.com`,
    password: await hashPassword('password123'),
    role:     'user',
  };
  return User.create({ ...defaults, ...overrides });
}

export async function createTestAdmin(overrides = {}) {
  return createTestUser({ role: 'admin', ...overrides });
}
js
// Usage in tests
const admin = await createTestAdmin();
const user  = await createTestUser({ email: 'specific@test.com' });

Node.js Full‑Stack Course — Module 28 of 32

You now write tests that drive design. Continue to Module 29 to master environment variables and configuration management.


    Summary

    TDD and advanced Jest techniques produce a test suite that is an asset, not a burden:

    • TDD cycle: Red → Green → Refactor — write the test first, make it pass, then improve
    • jest.fn() creates standalone mock functions with controllable return values and recorded calls
    • jest.spyOn() wraps real functions to record calls while optionally replacing the implementation
    • jest.mock() replaces entire modules before they are imported — the only way to mock ES modules reliably
    • Fake timers let you test time-dependent code (debounce, retry, expiry) without real delays
    • Always await async assertions inside expect().rejects — never in a try/catch that swallows errors
    • Use expect.assertions(N) in async error tests to guarantee the assertion runs
    • Test factories produce consistent, numbered test data and keep test setup readable

    Continue to Module 29: Environment Variables & Configuration →