Mocking #

Mocking is the technique of replacing real dependencies with fake versions whose behavior you can control during testing. Without mocking, unit tests become integration tests — slow, non-deterministic, and hard to isolate. But excessive or wrong-type mocking is also dangerous: tests with too many mocks don’t prove the system works as a whole, and when the real implementation changes, stale mocks can keep tests green even though there’s a bug. The key to effective mocking is understanding the test double taxonomy — there are five different types, each for a different case — and choosing the simplest one for the case being tested.

The Test Double Taxonomy #

Test double is the general term for all types of replacements in testing. There are five different types with different purposes:

flowchart TD
    A[Test Double] --> B[Dummy]
    A --> C[Stub]
    A --> D[Spy]
    A --> E[Mock]
    A --> F[Fake]

    B --> B1[Fills required parameters\nbut is never used\ne.g.: null, empty objects]
    C --> C1[Returns a predetermined\nvalue — doesn't care\nhow it's called]
    D --> D1[Real implementation runs\nbut calls are recorded\nfor later verification]
    E --> E1[Real implementation replaced\nAND interaction verification\nis the test's main goal]
    F --> F1[An alternative implementation\nthat actually works\ne.g.: in-memory database]

    style C fill:#339af0,color:#fff
    style D fill:#fcc419,color:#000
    style E fill:#ff6b6b,color:#fff
    style F fill:#51cf66,color:#fff

jest.fn() — The Basic Mock Function #

jest.fn() creates an empty mock function that records all calls:

// Basic mock function
const mockKirimEmail = jest.fn();

// Verify calls
mockKirimEmail("[email protected]", "Subjek", "Isi");
expect(mockKirimEmail).toHaveBeenCalledTimes(1);
expect(mockKirimEmail).toHaveBeenCalledWith("[email protected]", "Subjek", "Isi");

// Configure return values
const mockHitungDiskon = jest.fn().mockReturnValue(90_000);
expect(mockHitungDiskon(100_000, 10)).toBe(90_000);

// Different return values per call
const mockAmbilData = jest
  .fn()
  .mockReturnValueOnce("data pertama")   // Call 1
  .mockReturnValueOnce("data kedua")     // Call 2
  .mockReturnValue("data default");      // Subsequent calls

// Async mocks
const mockFetch = jest.fn().mockResolvedValue({ status: 200, data: [] });
const mockGagal = jest.fn().mockRejectedValue(new Error("Network error"));

// Mock with a custom implementation
const mockFilter = jest.fn().mockImplementation((arr: number[], min: number) =>
  arr.filter((n) => n >= min)
);

jest.Mocked<T> — Typed Mocks for Interfaces #

When working with interfaces or classes, jest.Mocked<T> ensures all mock properties are properly typed as jest.MockedFunction:

// The interface to be mocked
interface RepositoriProduk {
  ambilById(id: string): Promise<Produk | null>;
  simpan(produk: Produk): Promise<void>;
  hapus(id: string): Promise<boolean>;
  cari(query: string): Promise<Produk[]>;
}

interface Produk {
  id: string;
  nama: string;
  harga: number;
  stok: number;
}

// Create a typed mock for the interface
function buatMockRepo(): jest.Mocked<RepositoriProduk> {
  return {
    ambilById: jest.fn(),
    simpan: jest.fn(),
    hapus: jest.fn(),
    cari: jest.fn(),
  };
}

// The service to be tested
class LayananProduk {
  constructor(private readonly repo: RepositoriProduk) {}

  async tambahStok(id: string, jumlah: number): Promise<void> {
    const produk = await this.repo.ambilById(id);
    if (!produk) throw new Error(`Produk ${id} tidak ditemukan`);
    if (jumlah <= 0) throw new RangeError("Jumlah harus positif");

    produk.stok += jumlah;
    await this.repo.simpan(produk);
  }

  async hapusProduk(id: string): Promise<void> {
    const produk = await this.repo.ambilById(id);
    if (!produk) throw new Error(`Produk ${id} tidak ditemukan`);

    const berhasil = await this.repo.hapus(id);
    if (!berhasil) throw new Error("Gagal menghapus produk dari database");
  }
}

// Tests using the typed mock
describe("LayananProduk", () => {
  let mockRepo: jest.Mocked<RepositoriProduk>;
  let layanan: LayananProduk;

  const produkContoh: Produk = {
    id: "PRD-001",
    nama: "Kurma Ajwa",
    harga: 85_000,
    stok: 50,
  };

  beforeEach(() => {
    mockRepo = buatMockRepo();
    layanan = new LayananProduk(mockRepo);
  });

  describe("tambahStok", () => {
    test("adds stock and saves the product", async () => {
      // Arrange
      mockRepo.ambilById.mockResolvedValue({ ...produkContoh }); // A copy so it isn't mutable

      // Act
      await layanan.tambahStok("PRD-001", 10);

      // Assert
      expect(mockRepo.ambilById).toHaveBeenCalledWith("PRD-001");
      expect(mockRepo.simpan).toHaveBeenCalledWith(
        expect.objectContaining({ stok: 60 }) // 50 + 10
      );
    });

    test("throws an error if the product isn't found", async () => {
      mockRepo.ambilById.mockResolvedValue(null);

      await expect(layanan.tambahStok("PRD-999", 5)).rejects.toThrow(
        "Produk PRD-999 tidak ditemukan"
      );
      expect(mockRepo.simpan).not.toHaveBeenCalled();
    });

    test("throws a RangeError for a negative quantity", async () => {
      mockRepo.ambilById.mockResolvedValue({ ...produkContoh });

      await expect(layanan.tambahStok("PRD-001", -1)).rejects.toThrow(RangeError);
      expect(mockRepo.simpan).not.toHaveBeenCalled();
    });
  });
});

jest.spyOn() — Spying on Real Implementations #

jest.spyOn() differs from jest.fn() — it keeps the original implementation while recording calls. Use it when you want to verify interactions but still want the real implementation to run, or when you want to temporarily replace an implementation:

// A module containing the function to be spied on
import * as Utils from "./utils";

// Spy without changing the implementation — still calls the real function
const spy = jest.spyOn(Utils, "formatMata");
Utils.formatMata(100_000);

expect(spy).toHaveBeenCalledWith(100_000);
// The real implementation still runs

// Spy with a temporary implementation override
jest.spyOn(Utils, "formatMata").mockReturnValue("Rp 100.000");
// After the test finishes, jest.restoreMocks() restores the original implementation

// Spying on a class method
class KalkulatorHarga {
  private hitungPPN(harga: number): number {
    return harga * 0.11;
  }

  hitungTotal(harga: number): number {
    return harga + this.hitungPPN(harga);
  }
}

test("hitungTotal uses the correct PPN", () => {
  const kalkulator = new KalkulatorHarga();

  // Spy on a private method via a cast
  const spyPPN = jest
    .spyOn(kalkulator as unknown as { hitungPPN: (h: number) => number }, "hitungPPN")
    .mockReturnValue(11_000);

  const total = kalkulator.hitungTotal(100_000);

  expect(spyPPN).toHaveBeenCalledWith(100_000);
  expect(total).toBe(111_000);
});

jest.mock() — Mocking an Entire Module #

jest.mock() replaces a whole module with a mock implementation. Jest automatically hoists jest.mock() calls to the top of the file, so they always execute before any import:

// src/layanan/email.ts — the module to be mocked
export async function kirimEmail(
  tujuan: string,
  subjek: string,
  isi: string
): Promise<void> {
  // Real implementation: call the SMTP server
  console.log(`Mengirim email ke ${tujuan}`);
}

export async function kirimEmailBulk(
  tujuan: string[],
  subjek: string
): Promise<void> {
  await Promise.all(tujuan.map((t) => kirimEmail(t, subjek, "")));
}
// src/layanan/email.test.ts
import { kirimEmail, kirimEmailBulk } from "./email";

// jest.mock is hoisted to the top — always executes before imports
jest.mock("./email");

// Get the mocked references
const mockKirimEmail = kirimEmail as jest.MockedFunction<typeof kirimEmail>;
const mockKirimEmailBulk = kirimEmailBulk as jest.MockedFunction<typeof kirimEmailBulk>;

beforeEach(() => {
  mockKirimEmail.mockResolvedValue(undefined);
  mockKirimEmailBulk.mockResolvedValue(undefined);
});

test("kirimEmail is called with the correct arguments", async () => {
  await kirimEmail("[email protected]", "Test", "Isi test");

  expect(mockKirimEmail).toHaveBeenCalledWith(
    "[email protected]",
    "Test",
    "Isi test"
  );
});

Partial Mocks — Mocking Part of a Module #

Often you only need to mock a few functions of a module, not all of them:

// Mock part of a module — use jest.requireActual for the rest
jest.mock("./utils", () => {
  const implementasiAsli = jest.requireActual<typeof import("./utils")>("./utils");

  return {
    ...implementasiAsli,             // Use the real implementation for most functions
    kirimNotifikasi: jest.fn(),       // Only mock this
    catatAudit: jest.fn(),            // And this
  };
});

Manual Mocks — The __mocks__ Folder #

For dependencies mocked across many test files, create a manual mock in the __mocks__ folder next to the original file:

src/
├── layanan/
│   ├── __mocks__/
│   │   └── database.ts    ← The manual mock is used automatically
│   └── database.ts        ← The real implementation
└── ...
// src/layanan/__mocks__/database.ts — Manual mock
import type { KoneksiDatabase } from "../database";

// A fake implementation that actually works in memory
export class KoneksiDatabaseMock implements KoneksiDatabase {
  private data = new Map<string, unknown>();

  async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
    // A simple in-memory implementation for testing
    console.log(`[DB Mock] Query: ${sql}`, params);
    return [] as T[];
  }

  async execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }> {
    console.log(`[DB Mock] Execute: ${sql}`, params);
    return { rowsAffected: 1 };
  }

  async tutup(): Promise<void> {
    this.data.clear();
  }
}

// Enable the manual mock in tests:
// jest.mock("../database");

Mocking Date and Timers #

// Code that depends on dates/times — hard to test without mocks
export function apakahKadaluwarsa(kedaluwarsa: Date): boolean {
  return new Date() > kedaluwarsa;
}

export function hitungUmurHari(tglLahir: Date): number {
  const sekarang = new Date();
  const diff = sekarang.getTime() - tglLahir.getTime();
  return Math.floor(diff / (1000 * 60 * 60 * 24));
}
// Tests with time control
describe("apakahKadaluwarsa", () => {
  beforeEach(() => {
    // Set the current time to a date we control
    jest.useFakeTimers();
    jest.setSystemTime(new Date("2025-05-07T12:00:00Z"));
  });

  afterEach(() => {
    jest.useRealTimers(); // Restore real timers
  });

  test("returns true if already expired", () => {
    const kemarin = new Date("2025-05-06T12:00:00Z");
    expect(apakahKadaluwarsa(kemarin)).toBe(true);
  });

  test("returns false if not yet expired", () => {
    const besok = new Date("2025-05-08T12:00:00Z");
    expect(apakahKadaluwarsa(besok)).toBe(false);
  });

  test("hitungUmurHari calculates correctly", () => {
    const tglLahir = new Date("2025-05-04T12:00:00Z"); // 3 days ago
    expect(hitungUmurHari(tglLahir)).toBe(3);
  });
});

// Mocking timers for async tests that depend on setTimeout
describe("with fake timers", () => {
  beforeEach(() => jest.useFakeTimers());
  afterEach(() => jest.useRealTimers());

  test("the callback is called after the delay", () => {
    const callback = jest.fn();
    setTimeout(callback, 5000);

    expect(callback).not.toHaveBeenCalled();

    jest.advanceTimersByTime(5000); // Advance 5 seconds at once

    expect(callback).toHaveBeenCalledTimes(1);
  });
});

Mocking fetch with MSW #

For mocking HTTP requests, msw (Mock Service Worker) is the best solution — it intercepts requests at the network level so production code doesn’t need to change:

npm install --save-dev msw
// src/mocks/handlers.ts — handler definitions for tests
import { http, HttpResponse } from "msw";

export const handlers = [
  // Mock the GET user endpoint
  http.get("https://api.example.com/pengguna/:id", ({ params }) => {
    const { id } = params;

    if (id === "999") {
      return HttpResponse.json({ error: "Tidak ditemukan" }, { status: 404 });
    }

    return HttpResponse.json({
      id,
      nama: "Budi Santoso",
      email: "[email protected]",
    });
  }),

  // Mock the POST endpoint
  http.post("https://api.example.com/pengguna", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: "usr-baru", ...(body as object) },
      { status: 201 }
    );
  }),
];
// src/setup-tests.ts — MSW setup for Jest
import { setupServer } from "msw/node";
import { handlers } from "./mocks/handlers";

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
afterEach(() => server.resetHandlers()); // Reset handlers after every test
afterAll(() => server.close());
// Override a handler for a specific test
test("handles timeouts correctly", async () => {
  server.use(
    http.get("https://api.example.com/pengguna/1", () => {
      return HttpResponse.error(); // Simulate a network error
    })
  );

  await expect(ambilPengguna("1")).rejects.toThrow();
});

The Anti-Pattern: Over-Mocking #

// ANTI-PATTERN: Mocking too much — this test doesn't prove anything
test("getData processes data", async () => {
  // Mock all dependencies until no real code runs
  jest.mock("./repository");
  jest.mock("./validator");
  jest.mock("./transformer");
  jest.mock("./cache");

  const { getData } = await import("./service");
  const hasil = await getData("id-123");

  // This only proves the mocks work, not the service
  expect(hasil).toBeDefined();
});

// CORRECT: Only mock external dependencies, let the business logic run for real
test("getData validates and transforms data", async () => {
  // Only mock external I/O (database, API, file system)
  const mockRepo = { ambilById: jest.fn().mockResolvedValue(rawData) };

  // The validator and transformer run for real — that's what's being tested!
  const layanan = new LayananData(mockRepo, new ValidatorNyata(), new TransformerNyata());
  const hasil = await layanan.getData("id-123");

  expect(hasil).toMatchObject({ ... }); // Verify the real transformation
});

When Fakes Are Better Than Mocks #

A fake is an alternative implementation that actually works — more complex than a mock but far more stable and expressive for large test suites:

// Fake — a working in-memory implementation
class RepositoriPenggunaFake implements RepositoriPengguna {
  private store = new Map<string, Pengguna>();

  async simpan(pengguna: Pengguna): Promise<void> {
    this.store.set(pengguna.id, { ...pengguna }); // Store a copy
  }

  async ambilById(id: string): Promise<Pengguna | null> {
    return this.store.get(id) ?? null;
  }

  async hapus(id: string): Promise<boolean> {
    return this.store.delete(id);
  }

  async cariByEmail(email: string): Promise<Pengguna | null> {
    for (const p of this.store.values()) {
      if (p.email === email) return { ...p };
    }
    return null;
  }

  // Helper for tests — reset state between tests
  bersihkan(): void {
    this.store.clear();
  }

  // Helper for inspection — check internal state
  semuaPengguna(): Pengguna[] {
    return [...this.store.values()];
  }
}

// Use the fake in multiple test suites — more stable and expressive than mocks
const repoFake = new RepositoriPenggunaFake();

beforeEach(() => repoFake.bersihkan()); // Reset before every test

test("users can be registered and retrieved", async () => {
  const layanan = new LayananPengguna(repoFake);
  await layanan.daftar("[email protected]", "Budi");

  const pengguna = await repoFake.ambilById("usr-001");
  expect(pengguna?.email).toBe("[email protected]");
});

Summary #

  • Choose the simplest test double — dummies for unused arguments, stubs for return values, spies for interaction verification while keeping the real implementation, mocks for interaction verification as the test’s main goal, fakes for alternative implementations that actually work.
  • jest.Mocked<T> for typed mocks — instead of as any or as jest.Mock, use jest.Mocked<T> which gives full type checking on all mock methods.
  • jest.spyOn() + mockReturnValue() for temporary overrides — spies keep the original implementation and restoreMocks: true in jest.config.ts automatically restores them after every test.
  • jest.mock() is hoisted automatically — Jest lifts jest.mock() to the top of the file before any imports execute; this means you can’t use local variables inside jest.mock() unless their names start with mock.
  • Partial mocks with jest.requireActual — use them to mock only a few functions of a module while keeping the rest; far safer than mocking an entire module.
  • MSW for HTTP mocking — better than manually mocking fetch because it intercepts at the network level; production code doesn’t need to change at all.
  • jest.useFakeTimers() for time control — use jest.setSystemTime() to control new Date() and jest.advanceTimersByTime() to advance time without waiting; always jest.useRealTimers() in afterEach.
  • Avoid over-mocking — if almost all dependencies are mocked, the test only proves the mocks work, not that the production code works; only mock external I/O dependencies.
  • Fakes are better for large test suites — fakes reused across many tests are more stable and expressive than mocks that need re-setup for every test; consider fakes for repositories, caches, and queues.

← Previous: Unit Test   Next: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact