Node.js Interview Prep
Testing

Unit Testing with Jest

Pure Functions, Mocks, and Coverage

LinkedIn Hook

"Your code works on your machine. Will it still work at 3 AM when production goes down?"

Most backend engineers ship code with zero tests, then panic when a one-line refactor breaks checkout. The fix is not heroic debugging — it is a habit. Unit tests turn fear into confidence.

Jest is the de-facto testing framework for Node.js. It is fast, batteries-included, and ships with mocking, assertions, coverage, and watch mode out of the box. No extra config to start.

But most developers misuse it. They write integration tests and call them unit tests. They mock everything and test nothing. They chase 100% coverage on getter functions while leaving payment logic untested.

In Lesson 9.1, I break down what unit testing actually means in a Node.js backend — which functions to test in isolation, how to mock fs, axios, and database calls correctly, and why coverage numbers lie.

Read the full lesson -> [link]

#NodeJS #Jest #UnitTesting #BackendDevelopment #SoftwareTesting #InterviewPrep


Unit Testing with Jest thumbnail


What You'll Learn

  • The Jest core API: describe, it/test, expect, and matchers
  • How to test pure functions and why they are the easiest to test
  • Mocking entire modules with jest.mock and jest.fn
  • Mocking fs, databases, and HTTP clients (axios, fetch)
  • Testing async code with async/await and rejects.toThrow
  • The difference between spies, stubs, and mocks
  • Reading coverage reports with --coverage and what numbers actually mean
  • What to unit test (services, utilities) vs what to integration test (routes, db)

The Restaurant Kitchen Analogy — Why Unit Tests Matter

Imagine a restaurant kitchen. Before opening night, the head chef does not just cook a full five-course meal and hope it works. Instead, every station tests its own work in isolation. The sauce chef tastes the bechamel by itself. The pastry chef checks if the dough rises. The grill cook times a single steak. Each station verifies one thing, with controlled inputs, before any plate goes out to a customer.

That is unit testing. You test one function — a sauce — with known ingredients and check the exact taste. You do not fire up the whole restaurant (database, HTTP server, file system, payment gateway) just to verify that adding two numbers returns the right sum.

Now imagine the sauce chef refuses to taste anything until the customers arrive. The first complaint would mean shutting down the kitchen, debugging the entire menu, and apologizing to a full dining room. That is what happens when you skip unit tests and only catch bugs in production.

Jest is the kitchen timer, the measuring spoon, and the taste-test spoon all in one. It lets you isolate a function, feed it known inputs, and assert the exact output — in milliseconds, on every save.

+---------------------------------------------------------------+
|           THE TESTING PYRAMID                                 |
+---------------------------------------------------------------+
|                                                                |
|                       /\                                       |
|                      /  \      E2E Tests (few)                 |
|                     /----\     - Slow, brittle, expensive      |
|                    /      \    - Real browser + real backend   |
|                   /--------\                                   |
|                  /          \  Integration Tests (some)        |
|                 /------------\ - Routes + real db              |
|                /              \- Medium speed                  |
|               /----------------\                               |
|              /                  \ Unit Tests (many)            |
|             /--------------------\- Pure functions, services   |
|            /                      \- Fast, isolated, cheap     |
|           +------------------------+                           |
|                                                                |
|  Rule: 70% unit, 20% integration, 10% E2E                      |
|                                                                |
+---------------------------------------------------------------+

Napkin AI Visual Prompt: "Dark gradient (#0a1a0a -> #0d2e16). A pyramid divided into three layers. Bottom (largest) labeled 'Unit Tests' in Node green (#68a063). Middle labeled 'Integration Tests' in amber (#ffb020). Top (smallest) labeled 'E2E Tests' in red (#ef4444). To the right, a terminal window shows green PASS lines. White monospace labels throughout."


Jest Basics — describe, it, expect

Jest organizes tests with three primitives. describe groups related tests. it (or test) defines a single test case. expect makes an assertion. That is the entire skeleton of every test file.

Installing Jest

# Install Jest as a dev dependency
npm install --save-dev jest

# Add a test script to package.json
# "scripts": { "test": "jest", "test:watch": "jest --watch" }

# Run the tests
npm test

Jest auto-discovers any file matching *.test.js, *.spec.js, or files inside a __tests__ folder. No config needed for the common case.

The Basic Skeleton

// math.test.js
// describe groups related tests under one logical umbrella
describe('math utilities', () => {
  // it (or test) defines a single behavior to verify
  it('adds two positive numbers', () => {
    // expect creates an assertion against an actual value
    expect(2 + 3).toBe(5);
  });

  it('handles zero correctly', () => {
    expect(0 + 7).toBe(7);
  });
});

A test is just a function that throws when an assertion fails. Jest catches the throw, marks the test as failed, prints a diff, and moves on. Nothing magical.


Example 1 — Testing a Pure Function

A pure function has no side effects: same input always produces the same output, and it does not touch the file system, the network, the clock, or any global state. Pure functions are the easiest things in the world to test because you do not need to mock anything.

// src/utils/price.js
// Pure function: deterministic, no side effects, no I/O
function calculateTotal(items, taxRate) {
  // Validate inputs early — defensive programming
  if (!Array.isArray(items)) {
    throw new TypeError('items must be an array');
  }
  if (taxRate < 0 || taxRate > 1) {
    throw new RangeError('taxRate must be between 0 and 1');
  }

  // Sum the line totals (price * quantity)
  const subtotal = items.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);

  // Apply tax and round to 2 decimal places
  const tax = subtotal * taxRate;
  return Math.round((subtotal + tax) * 100) / 100;
}

module.exports = { calculateTotal };
// src/utils/price.test.js
const { calculateTotal } = require('./price');

describe('calculateTotal', () => {
  // Happy path — the most common case
  it('calculates total with tax for multiple items', () => {
    const items = [
      { price: 10, quantity: 2 },  // 20
      { price: 5,  quantity: 3 },  // 15
    ];
    // Subtotal = 35, tax at 10% = 3.5, total = 38.5
    expect(calculateTotal(items, 0.1)).toBe(38.5);
  });

  // Edge case — empty input
  it('returns 0 for an empty cart', () => {
    expect(calculateTotal([], 0.1)).toBe(0);
  });

  // Boundary case — zero tax
  it('handles zero tax rate', () => {
    const items = [{ price: 100, quantity: 1 }];
    expect(calculateTotal(items, 0)).toBe(100);
  });

  // Error case — invalid input type
  it('throws TypeError when items is not an array', () => {
    // toThrow accepts an Error class to match against
    expect(() => calculateTotal(null, 0.1)).toThrow(TypeError);
  });

  // Error case — out-of-range argument
  it('throws RangeError when taxRate is invalid', () => {
    expect(() => calculateTotal([], 1.5)).toThrow(RangeError);
  });
});

Why this is the gold standard: No mocks, no setup, no teardown. Just inputs and expected outputs. If you can refactor your business logic into pure functions, you get this kind of test for free. Aim to keep all your core domain logic (pricing, validation, formatting, parsing) as pure functions whenever possible.


Example 2 — Mocking a Module with jest.mock

Real code calls other modules. A userService calls a userRepository. An emailController calls an emailSender. To unit test the caller in isolation, you replace the dependency with a fake — a mock.

// src/services/userService.js
const userRepository = require('../repositories/userRepository');
const { hashPassword } = require('../utils/crypto');

// Service layer: business logic that depends on a repository
async function registerUser(email, password) {
  // Reject duplicates — business rule
  const existing = await userRepository.findByEmail(email);
  if (existing) {
    throw new Error('Email already registered');
  }

  // Hash the password before storing
  const hashed = await hashPassword(password);

  // Persist the new user
  return userRepository.create({ email, password: hashed });
}

module.exports = { registerUser };
// src/services/userService.test.js
const { registerUser } = require('./userService');
const userRepository = require('../repositories/userRepository');
const crypto = require('../utils/crypto');

// jest.mock replaces the entire module with auto-generated mock functions
// All exported functions become jest.fn() that return undefined by default
jest.mock('../repositories/userRepository');
jest.mock('../utils/crypto');

describe('registerUser', () => {
  // Reset mock state between tests so they do not leak
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('creates a user when the email is not taken', async () => {
    // Arrange: tell the mock what to return for this test
    userRepository.findByEmail.mockResolvedValue(null);
    crypto.hashPassword.mockResolvedValue('hashed_pw_xyz');
    userRepository.create.mockResolvedValue({ id: 1, email: 'a@b.com' });

    // Act: call the unit under test
    const result = await registerUser('a@b.com', 'plain123');

    // Assert: verify the return value
    expect(result).toEqual({ id: 1, email: 'a@b.com' });

    // Assert: verify the mock was called with the right arguments
    expect(crypto.hashPassword).toHaveBeenCalledWith('plain123');
    expect(userRepository.create).toHaveBeenCalledWith({
      email: 'a@b.com',
      password: 'hashed_pw_xyz',
    });
  });

  it('throws when the email already exists', async () => {
    // Simulate a duplicate user in the database
    userRepository.findByEmail.mockResolvedValue({ id: 99, email: 'a@b.com' });

    // rejects.toThrow asserts an async function rejects with a matching error
    await expect(registerUser('a@b.com', 'pw')).rejects.toThrow(
      'Email already registered'
    );

    // Verify we never tried to create the user
    expect(userRepository.create).not.toHaveBeenCalled();
  });
});

Key idea: The unit under test is registerUser. The repository and crypto module are dependencies, so they are mocked. We do not care if the real database works — that is the integration test's job. We only care that registerUser correctly orchestrates its dependencies.


Example 3 — Mocking the fs Module

The filesystem is a classic side effect. Reading or writing real files in unit tests is slow, brittle, and pollutes your repo. Mock it.

// src/services/configLoader.js
const fs = require('fs/promises');
const path = require('path');

// Reads and parses a JSON config file from disk
async function loadConfig(configPath) {
  // Read the file as UTF-8 text
  const raw = await fs.readFile(path.resolve(configPath), 'utf-8');

  // Parse with a friendly error message on failure
  try {
    return JSON.parse(raw);
  } catch (err) {
    throw new Error(`Invalid JSON in ${configPath}: ${err.message}`);
  }
}

module.exports = { loadConfig };
// src/services/configLoader.test.js
const fs = require('fs/promises');
const { loadConfig } = require('./configLoader');

// Mock the entire fs/promises module
// Every method becomes a jest.fn() we can control
jest.mock('fs/promises');

describe('loadConfig', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('parses valid JSON from disk', async () => {
    // Arrange: make readFile return a JSON string
    fs.readFile.mockResolvedValue('{"port": 3000, "env": "test"}');

    // Act
    const config = await loadConfig('./config.json');

    // Assert: parsed object matches the mocked content
    expect(config).toEqual({ port: 3000, env: 'test' });

    // Assert: readFile was called with the right encoding
    expect(fs.readFile).toHaveBeenCalledWith(
      expect.stringContaining('config.json'),
      'utf-8'
    );
  });

  it('throws a friendly error for malformed JSON', async () => {
    // Arrange: return invalid JSON
    fs.readFile.mockResolvedValue('{ not: valid json }');

    // Assert: the wrapper error is thrown with the file name
    await expect(loadConfig('./bad.json')).rejects.toThrow(/Invalid JSON/);
  });

  it('propagates filesystem errors (e.g. ENOENT)', async () => {
    // Simulate the file not existing
    const enoent = new Error('ENOENT: no such file');
    fs.readFile.mockRejectedValue(enoent);

    await expect(loadConfig('./missing.json')).rejects.toThrow('ENOENT');
  });
});

No real files are touched. The tests run in milliseconds and behave the same way on every machine and every CI runner.


Example 4 — Mocking axios for HTTP Calls

Hitting real HTTP endpoints in unit tests is the cardinal sin. Networks are slow, flaky, rate-limited, and non-deterministic. Mock the HTTP client.

// src/services/weatherService.js
const axios = require('axios');

// Wraps a third-party weather API
async function getTemperature(city) {
  // Make the HTTP GET request
  const response = await axios.get('https://api.weather.example/v1/current', {
    params: { city },
    timeout: 5000,
  });

  // Validate the response shape before returning
  if (typeof response.data?.temp_c !== 'number') {
    throw new Error('Invalid response from weather API');
  }

  return response.data.temp_c;
}

module.exports = { getTemperature };
// src/services/weatherService.test.js
const axios = require('axios');
const { getTemperature } = require('./weatherService');

// Mock axios so no real HTTP request is ever made
jest.mock('axios');

describe('getTemperature', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('returns the temperature for a valid city', async () => {
    // Arrange: stub the response shape
    axios.get.mockResolvedValue({
      data: { temp_c: 21.5, humidity: 60 },
    });

    // Act
    const temp = await getTemperature('Dhaka');

    // Assert: return value
    expect(temp).toBe(21.5);

    // Assert: axios was called with the correct URL and query params
    expect(axios.get).toHaveBeenCalledWith(
      'https://api.weather.example/v1/current',
      expect.objectContaining({
        params: { city: 'Dhaka' },
        timeout: 5000,
      })
    );
  });

  it('throws when the API returns an unexpected shape', async () => {
    // Simulate a malformed response (missing temp_c field)
    axios.get.mockResolvedValue({ data: { error: 'rate limited' } });

    await expect(getTemperature('Dhaka')).rejects.toThrow(
      'Invalid response from weather API'
    );
  });

  it('propagates network errors', async () => {
    // Simulate a network failure
    axios.get.mockRejectedValue(new Error('ECONNREFUSED'));

    await expect(getTemperature('Dhaka')).rejects.toThrow('ECONNREFUSED');
  });
});

For fetch (Node 18+), the same pattern applies:

// Mock the global fetch in your test setup
global.fetch = jest.fn();

// In a test:
fetch.mockResolvedValue({
  ok: true,
  json: async () => ({ temp_c: 21.5 }),
});

Example 5 — Async Tests, rejects.toThrow, and Spies

Async errors are tricky. A common bug: forgetting to await the assertion, which causes the test to pass even when the function throws. Always use await expect(...).rejects.toThrow(...).

// src/services/paymentService.js
const logger = require('../utils/logger');

// Charges a card via a payment gateway client
async function chargeCard(gateway, amount) {
  if (amount <= 0) {
    throw new Error('Amount must be positive');
  }

  try {
    // Delegate to the gateway client
    const result = await gateway.charge(amount);
    // Log the successful charge for audit
    logger.info(`Charged ${amount} -> txn ${result.txnId}`);
    return result;
  } catch (err) {
    // Log and re-throw with context
    logger.error(`Charge failed: ${err.message}`);
    throw new Error(`Payment failed: ${err.message}`);
  }
}

module.exports = { chargeCard };
// src/services/paymentService.test.js
const { chargeCard } = require('./paymentService');
const logger = require('../utils/logger');

// Spy on logger methods without replacing the whole module
jest.mock('../utils/logger', () => ({
  info: jest.fn(),
  error: jest.fn(),
}));

describe('chargeCard', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('charges successfully and logs the transaction', async () => {
    // Build a fake gateway with a stubbed charge method
    const gateway = {
      charge: jest.fn().mockResolvedValue({ txnId: 'tx_123' }),
    };

    const result = await chargeCard(gateway, 50);

    expect(result).toEqual({ txnId: 'tx_123' });
    expect(gateway.charge).toHaveBeenCalledWith(50);
    // Spy assertion: verify the success log fired
    expect(logger.info).toHaveBeenCalledWith(
      expect.stringContaining('tx_123')
    );
  });

  it('rejects with a wrapped error when the gateway fails', async () => {
    const gateway = {
      charge: jest.fn().mockRejectedValue(new Error('card declined')),
    };

    // CRITICAL: await + rejects.toThrow — never just .toThrow on a promise
    await expect(chargeCard(gateway, 50)).rejects.toThrow(
      'Payment failed: card declined'
    );

    // Verify the error was logged
    expect(logger.error).toHaveBeenCalledWith(
      expect.stringContaining('card declined')
    );
  });

  it('rejects synchronously-thrown validation errors', async () => {
    const gateway = { charge: jest.fn() };

    await expect(chargeCard(gateway, -10)).rejects.toThrow(
      'Amount must be positive'
    );

    // The gateway should never be called for invalid input
    expect(gateway.charge).not.toHaveBeenCalled();
  });
});

Spies vs Stubs vs Mocks — What Is the Difference?

These three terms get used interchangeably, but they mean different things. Jest gives you all three through one API (jest.fn, jest.spyOn, jest.mock).

+---------------------------------------------------------------+
|           SPY vs STUB vs MOCK                                 |
+---------------------------------------------------------------+
|                                                                |
|  SPY: Records calls, lets the real code run                    |
|       "I want to know IF this was called"                      |
|       jest.spyOn(obj, 'method')                                |
|                                                                |
|  STUB: Replaces a function with a canned return value          |
|        "I want this to return X"                               |
|        jest.fn().mockReturnValue(X)                            |
|                                                                |
|  MOCK: A stub that ALSO has assertions about how it was used   |
|        "I want this to return X AND verify it was called"     |
|        jest.fn() + expect(fn).toHaveBeenCalledWith(...)        |
|                                                                |
+---------------------------------------------------------------+
// SPY: wraps the real function, lets it run, records calls
const spy = jest.spyOn(console, 'log');
console.log('hello');
expect(spy).toHaveBeenCalledWith('hello');
spy.mockRestore();  // important: restore the original

// STUB: replaces the function entirely, returns canned data
const stub = jest.fn().mockReturnValue(42);
expect(stub()).toBe(42);

// MOCK: a stub PLUS behavioral verification
const mock = jest.fn().mockResolvedValue('ok');
await mock('arg1');
expect(mock).toHaveBeenCalledWith('arg1');  // <- verification

In day-to-day Jest usage, most people just say "mock" for everything. But knowing the distinction helps you reason about what you are actually testing — behavior (mock) vs return value (stub) vs side effect (spy).


Coverage Reports — What --coverage Tells You

Run Jest with --coverage and it generates a report showing which lines of your code were exercised by tests.

# Generate a coverage report (text + HTML)
npx jest --coverage

# Output example:
# ----------|---------|----------|---------|---------|-------------------
# File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
# ----------|---------|----------|---------|---------|-------------------
# All files |   87.5  |   75.0   |   90.0  |   88.2  |
#  price.js |   100   |   100    |   100   |   100   |
#  user.js  |   75.0  |   50.0   |   80.0  |   76.5  | 23-28, 41
# ----------|---------|----------|---------|---------|-------------------

The four metrics:

  • Statements: percentage of executable statements run.
  • Branches: percentage of if/else/switch paths taken.
  • Functions: percentage of functions called at least once.
  • Lines: percentage of source lines executed.

Branch coverage is the most honest metric. A function with one if and one else can have 100% line coverage with only one test — the other path is never exercised. Branch coverage forces you to test both sides.

The trap: chasing 100% coverage. A getter function get name() { return this._name } is trivially covered but tests nothing meaningful. Aim for 80-90% on business-critical files (services, validators, payment logic) and accept lower on glue code (config, bootstrap).

// jest.config.js — enforce minimum coverage in CI
module.exports = {
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/index.js',  // exclude barrel files
    '!src/config/**',    // exclude config glue
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 85,
      lines: 85,
      statements: 85,
    },
  },
};

What to Unit Test vs What to Integration Test

This is the question that separates senior engineers from juniors. Not everything deserves a unit test, and unit-testing the wrong thing wastes time.

+---------------------------------------------------------------+
|           UNIT TEST vs INTEGRATION TEST                       |
+---------------------------------------------------------------+
|                                                                |
|  UNIT TEST (mock everything external):                         |
|    - Pure functions (math, formatting, parsing)                |
|    - Validators and schemas                                    |
|    - Service layer business logic                              |
|    - Utility helpers                                           |
|    - Error mapping and translation                             |
|    - Reducer / state-transition functions                      |
|                                                                |
|  INTEGRATION TEST (use real things):                           |
|    - Express / Fastify routes (supertest)                      |
|    - Database queries (real db or testcontainer)               |
|    - ORM models and migrations                                 |
|    - Middleware chains                                         |
|    - Auth flows end-to-end                                     |
|    - Cache + db interactions                                   |
|                                                                |
|  E2E TEST (real everything):                                   |
|    - Full user journeys (signup -> checkout)                   |
|    - Cross-service workflows                                   |
|                                                                |
+---------------------------------------------------------------+

Rule of thumb: if the function's correctness depends on something outside your process (database, network, file system, clock, env vars), it is a candidate for an integration test. If it is just logic-on-data, it is a unit test. Refactor your code so that as much logic as possible lives in pure functions, then unit-test those mercilessly.


Common Mistakes

1. Forgetting to await async assertions. expect(asyncFn()).rejects.toThrow() without await will pass even if the function never throws. Always write await expect(asyncFn()).rejects.toThrow(...). This bug ships to production constantly.

2. Mocking what you do not own. Mocking axios or fs is fine. Mocking your own service inside its own test creates circular logic — the test only verifies that your mock matches your other mock. Mock at the boundary, not in the middle.

3. Sharing state between tests. Forgetting jest.clearAllMocks() in beforeEach lets call counts and return values leak between tests. Tests then pass or fail based on order — a nightmare to debug. Always reset.

4. Testing implementation details instead of behavior. Asserting that a private helper was called, or that a specific internal variable equals X, locks your tests to the current implementation. Refactors break tests even though behavior is unchanged. Test inputs and outputs, not internals.

5. Chasing 100% coverage on the wrong files. Hitting 100% on index.js barrels and config glue is meaningless. Hitting 60% on the payment service is dangerous. Focus coverage where bugs would be expensive — business logic, validators, money handling.


Interview Questions

1. "What is the difference between a unit test and an integration test, and when would you write each?"

A unit test exercises a single function or module in isolation, with all external dependencies (database, HTTP, file system) replaced by mocks. It is fast, deterministic, and runs in milliseconds. An integration test exercises multiple components together with real (or near-real) dependencies — for example, calling an Express route that hits a real test database. Integration tests are slower but verify that the wiring between components actually works. In a typical Node.js backend, I unit test pure logic (validators, formatters, service-layer rules) and integration test routes, database queries, and middleware chains. The testing pyramid principle applies: many fast unit tests at the bottom, fewer integration tests in the middle, very few E2E tests on top.

2. "How does jest.mock work, and when would you use it instead of jest.fn?"

jest.mock(modulePath) replaces the entire module with auto-generated mock functions — every exported function becomes a jest.fn() returning undefined. This happens at import time via Jest's module hoisting, so the mock is in place before your code under test imports the dependency. You use jest.mock when you want to replace a real module (like axios, fs, or one of your own repositories) without touching the caller's import statement. jest.fn() on its own creates a single standalone mock function — useful for inline dependencies you pass as arguments, callbacks, or for stubbing methods on an object you control. The two work together: jest.mock swaps the module, then you configure individual jest.fn() instances inside it with mockResolvedValue, mockReturnValue, etc.

3. "How do you correctly test an async function that is supposed to throw?"

Use await expect(asyncFn()).rejects.toThrow(...). The rejects matcher unwraps the rejected promise and asserts on the error inside. The await is critical — without it, the assertion returns a promise that Jest never waits on, and the test passes silently even if the function never throws. You can match against an error message, a regex, or an Error class: rejects.toThrow('Payment failed'), rejects.toThrow(/timeout/i), or rejects.toThrow(TypeError). Alternatively, you can use a try/catch inside the test with expect.assertions(n) to guarantee the catch block ran, but rejects.toThrow is cleaner.

4. "What is the difference between a spy, a stub, and a mock?"

A spy wraps a real function, lets it execute normally, and records every call so you can assert on call counts and arguments — used when you want to verify a side effect (like a logger call) without changing behavior. A stub replaces a function entirely with a canned return value — used when you want to control what a dependency returns without caring how it gets there. A mock is a stub with built-in expectations about how it should be called — it both replaces the function and verifies the interaction. In Jest, jest.spyOn creates a spy, jest.fn().mockReturnValue(x) creates a stub, and combining jest.fn() with expect(fn).toHaveBeenCalledWith(...) creates a mock. In practice, the Jest community uses "mock" loosely for all three.

5. "What does code coverage actually measure, and is 100% coverage a meaningful goal?"

Coverage measures what percentage of your source code was executed when tests ran. Jest reports four numbers: statement coverage, branch coverage, function coverage, and line coverage. Branch coverage is the most useful because it catches untested code paths in if/else and switch blocks — line coverage can hit 100% while still missing half the logic. 100% coverage is rarely a meaningful goal. It is easy to get to 100% by writing tests that execute code without asserting anything useful, and it gives a false sense of security. A well-tested codebase typically lands at 80-90% coverage on critical files (services, business logic, validators) with intentionally lower coverage on glue code (config, bootstrap, barrel files). Focus on testing the behaviors that would be expensive to break in production, not on chasing the green percentage.


Quick Reference — Jest Cheat Sheet

+---------------------------------------------------------------+
|           JEST CORE API                                       |
+---------------------------------------------------------------+
|                                                                |
|  STRUCTURE:                                                    |
|  describe('group', () => { it('case', () => { ... }) })        |
|  beforeEach(() => { jest.clearAllMocks() })                    |
|                                                                |
|  COMMON MATCHERS:                                              |
|  expect(x).toBe(y)              // strict equality (===)       |
|  expect(x).toEqual(y)           // deep equality               |
|  expect(x).toBeNull()                                          |
|  expect(x).toBeTruthy()                                        |
|  expect(x).toContain(item)                                     |
|  expect(x).toHaveLength(n)                                     |
|  expect(x).toMatchObject({...})                                |
|                                                                |
|  ERROR MATCHERS:                                               |
|  expect(() => fn()).toThrow('msg')                             |
|  await expect(asyncFn()).rejects.toThrow('msg')                |
|                                                                |
|  MOCK MATCHERS:                                                |
|  expect(fn).toHaveBeenCalled()                                 |
|  expect(fn).toHaveBeenCalledTimes(n)                           |
|  expect(fn).toHaveBeenCalledWith(arg1, arg2)                   |
|  expect(fn).not.toHaveBeenCalled()                             |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           MOCKING PATTERNS                                     |
+---------------------------------------------------------------+
|                                                                |
|  MOCK A MODULE:                                                |
|  jest.mock('axios')                                            |
|  axios.get.mockResolvedValue({ data: {...} })                  |
|                                                                |
|  MOCK fs/promises:                                             |
|  jest.mock('fs/promises')                                      |
|  fs.readFile.mockResolvedValue('file contents')                |
|                                                                |
|  STUB A METHOD:                                                |
|  const fn = jest.fn().mockReturnValue(42)                      |
|  const fn = jest.fn().mockResolvedValue(data)                  |
|  const fn = jest.fn().mockRejectedValue(err)                   |
|                                                                |
|  SPY ON A METHOD:                                              |
|  const spy = jest.spyOn(obj, 'method')                         |
|  spy.mockRestore()  // always restore spies                    |
|                                                                |
|  RESET BETWEEN TESTS:                                          |
|  beforeEach(() => jest.clearAllMocks())                        |
|                                                                |
+---------------------------------------------------------------+

+---------------------------------------------------------------+
|           COVERAGE                                             |
+---------------------------------------------------------------+
|                                                                |
|  npx jest --coverage                                           |
|                                                                |
|  Aim for: 80-90% branches on business logic                    |
|  Skip:    barrels, config, bootstrap                           |
|  Enforce: coverageThreshold in jest.config.js                  |
|                                                                |
+---------------------------------------------------------------+
ConceptUnit TestIntegration Test
ScopeSingle function/moduleMultiple components
DependenciesMockedReal (or test doubles)
SpeedMillisecondsSeconds
DatabaseNeverReal test db
HTTPMocked (axios/fetch)Real (supertest)
File systemMockedReal temp files
Best forPure logic, services, validatorsRoutes, queries, middleware
QuantityMany (70%)Some (20%)

Prev: Lesson 8.4 -- Security Best Practices Next: Lesson 9.2 -- Integration Testing


This is Lesson 9.1 of the Node.js Interview Prep Course -- 10 chapters, 42 lessons.

On this page