cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Mocks, spies & fakes

Learn how spies, stubs, fakes, fake timers, module mocks, and dependency injection isolate JavaScript tests from time and side effects.

By the end, you can
  • 01
    Name each test doubleChoose dummy, stub, spy, mock, or fake by the job it performs in a test.
  • 02
    Control side effectsReplace time, network, storage, and randomness with deterministic seams.
  • 03
    Mock without brittlenessPrefer dependency injection and useful fakes before patching globals or asserting every call.

Isolation, plainly

A good unit test should fail because your code changed, not because the network is slow, the clock crossed midnight, Math.random() picked a different branch, browser storage leaked from another test, or an analytics call reached the real service.

Definition

Mocks, spies, stubs, dummies, and fakes are test doubles: small replacements you use so a test can control or observe a collaborator. The goal is isolation: keep the code under test real while replacing time, the network, randomness, storage, or slow side-effecting systems.

The previous Unit testing lesson focuses on runners, assertions, Arrange-Act-Assert, and test-driven development. Here we focus on the seams those tests use. You will reuse ideas from closures, higher-order functions, timers, fetch, web storage, and pure functions.

What isolation removes from a test
Outside forceWhy it hurts testsTypical seam
NetworkSlow, flaky, expensive, and outside your control.Inject fetcher or return a fake Response.
ClockReal waiting makes tests slow and order-dependent.Use a fake clock for synchronous timer callbacks.
RandomnessDifferent results hide regressions.Inject random or use a seeded PRNG.
StorageBrowser state leaks across tests.Use a fresh fake storage object per test.
Side effectsEmails, payments, logs, and analytics should not fire in tests.Pass a stub or spy collaborator.

The five doubles

VOCABULARY

People often say “mock” for every replacement. In precise test language, a mock is only one member of a family. The names matter because they tell you whether the double supplies data, records calls, enforces expectations, or behaves like a small real system.

Real-life analogyStunt doubles and flight simulators

Tests use doubles the way a production team uses stunt doubles and simulators: avoid danger and cost while keeping the scene meaningful. The mistake is filming only the stunt coordinator’s checklist and forgetting the story the audience sees.

In real life: A mannequin fills a seat
In JavaScript: A dummy fills an unused parameter
In real life: A stunt double performs a planned move
In JavaScript: A stub returns a planned value
In real life: A camera records the stunt
In JavaScript: A spy records calls and returns
In real life: A director demands an exact cue
In JavaScript: A mock expects a specific interaction
In real life: A simulator behaves like the cockpit
In JavaScript: A fake has lightweight working behavior

Where the analogy stops: A film crew can watch every angle without changing the scene. A test double changes the system boundary, so use the smallest double that keeps the important behavior honest.

Test double vocabulary
DoubleJobExample
DummyFills a required parameter but is not used.sendEmail(user, nullLogger) when the logger is ignored.
StubReturns canned data so the path is predictable.stub("id-1") replaces an ID service.
SpyRecords calls while usually keeping behavior real.spy(add).calls[0].args proves the callback received 2, 3.
MockSets an expectation about an interaction.Expecting analytics.track("signup") once.
FakeA working lightweight implementation.An in-memory repository or fake localStorage.

The table gives you a quick smell test. If the double is never inspected, it is probably a dummy. If it returns a fixed value, it is a stub. If you inspect calls afterward, it is a spy. If the test declares an expected call as the behavior, it is a mock. If it implements a smaller working system, it is a fake.

Spies and stubs: observe or answer

STEP THROUGH

A spy answers “what happened?” A stub answers “what should happen next?” The tiny helpers below are real JavaScript functions, not a test-library shortcut. They make the mechanics clear before you reach for mock.fn, vi.fn, or jest.fn.

Build a spy and a stub
Step 0 of 10Ready
Your turn: follow the blue line

A spy observes real behavior. A stub supplies planned behavior. Step through the tiny helpers before using library mocks.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  const calls = [];  function wrapper(...args) {    try {      const returned = fn(...args);      calls.push({ args, returned });      return returned;    } catch (error) {      calls.push({ args, threw: error.message });      throw error;    }  }  wrapper.calls = calls;  return wrapper;} function stub(...values) {  let index = 0;  return spy(() => values[Math.min(index++, values.length - 1)]);} const add = spy((a, b) => a + b);console.log(add(2, 3));console.log(JSON.stringify(add.calls)); const nextId = stub("id-1", "id-2");console.log(nextId());console.log(nextId());console.log(JSON.stringify(nextId.calls.map((call) => call.returned)));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Line by line, the spy creates an array, calls the original function, pushes the arguments and return value, then returns the original result. The stub is intentionally simpler: it chooses a canned return value from a list. In a real test you would assert the visible result first, then use the call history only when the interaction is part of the behavior.

The helpers print real call records
Spy and stub helper sourcePop out in the code editor (opens in a new tab)JavaScript
function spy(fn = () => undefined) {  const calls = [];  function wrapper(...args) {    try {      const returned = fn(...args);      calls.push({ args, returned });      return returned;    } catch (error) {      calls.push({ args, threw: error.message });      throw error;    }  }  wrapper.calls = calls;  return wrapper;} function stub(...values) {  let index = 0;  return spy(() => values[Math.min(index++, values.length - 1)]);} const add = spy((a, b) => a + b);console.log(add(2, 3));console.log(JSON.stringify(add.calls)); const nextId = stub("id-1", "id-2");console.log(nextId());console.log(nextId());console.log(JSON.stringify(nextId.calls.map((call) => call.returned)));
Console outputreal values
  1. 5
  2. [{"args":[2,3],"returned":5}]
  3. id-1
  4. id-2
  5. ["id-1","id-2"]
Try it yourself
Output from the exact helper source

The spy forwards to the real add function and records one call. The stub returns the planned IDs and records its own returns.

Use this beside the step-through when you want to compare source and final console output.
Use spies sparingly

Spies are helpful for callbacks, event handlers, and boundaries like analytics. They become brittle when you assert every private helper call. Prefer checking the output or state change unless the call itself is the product requirement.

Fake timers: make the clock a collaborator

STEP THROUGH

Timer code is hard to test if the test must really wait. A fake clock stores callbacks, keeps a fake now(), and runs due setTimeout callbacks when the test advances time. This section covers synchronous timer callbacks. The next lesson, Testing async code, adds promises, microtasks, rejections, and flaky async timing.

Fake clock: run synchronous timers in due order
Step 0 of 8Ready
Your turn: follow the blue line

Fake timers let tests run synchronous timer callbacks immediately and deterministically. Promise timers add extra microtask rules, which the next lesson covers.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  let current = start;  let nextId = 1;  const timers = [];  const sortTimers = () => timers.sort((a, b) => a.time - b.time || a.id - b.id);   const clock = {    now: () => current,    setTimeout(callback, delay = 0) {      const id = nextId++;      timers.push({ id, time: current + Math.max(0, delay), callback });      sortTimers();      return id;    },    clearTimeout(id) {      const index = timers.findIndex((timer) => timer.id === id);      if (index >= 0) timers.splice(index, 1);    },    advance(ms) {      const target = current + Math.max(0, ms);      sortTimers();      while (timers[0] && timers[0].time <= target) {        const timer = timers.shift();        current = timer.time;        timer.callback();        sortTimers();      }      current = target;    },    tick(ms) {      clock.advance(ms);    },    install(target = globalThis) {      const realSetTimeout = target.setTimeout;      const realClearTimeout = target.clearTimeout;      const realNow = target.Date?.now;      target.setTimeout = (callback, delay = 0) => clock.setTimeout(callback, delay);      target.clearTimeout = (id) => clock.clearTimeout(id);      if (target.Date && realNow) target.Date.now = clock.now;      return () => {        target.setTimeout = realSetTimeout;        target.clearTimeout = realClearTimeout;        if (target.Date && realNow) target.Date.now = realNow;      };    },  };  return clock;} const clock = createFakeClock(100);const log = [];clock.setTimeout(() => log.push("slow@" + clock.now()), 30);clock.setTimeout(() => log.push("fast@" + clock.now()), 10);clock.advance(10);console.log(log.join(", "));clock.tick(20);console.log(log.join(", "));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The helper has two seams. Calling clock.setTimeout avoids globals entirely. Calling clock.install() temporarily replaces setTimeout, clearTimeout, and Date.now on a target object and returns a restore function. Prefer the explicit clock object; install only around code that cannot accept one.

Fake clock output
Fake clock sourcePop out in the code editor (opens in a new tab)JavaScript
function createFakeClock(start = 0) {  let current = start;  let nextId = 1;  const timers = [];  const sortTimers = () => timers.sort((a, b) => a.time - b.time || a.id - b.id);   const clock = {    now: () => current,    setTimeout(callback, delay = 0) {      const id = nextId++;      timers.push({ id, time: current + Math.max(0, delay), callback });      sortTimers();      return id;    },    clearTimeout(id) {      const index = timers.findIndex((timer) => timer.id === id);      if (index >= 0) timers.splice(index, 1);    },    advance(ms) {      const target = current + Math.max(0, ms);      sortTimers();      while (timers[0] && timers[0].time <= target) {        const timer = timers.shift();        current = timer.time;        timer.callback();        sortTimers();      }      current = target;    },    tick(ms) {      clock.advance(ms);    },    install(target = globalThis) {      const realSetTimeout = target.setTimeout;      const realClearTimeout = target.clearTimeout;      const realNow = target.Date?.now;      target.setTimeout = (callback, delay = 0) => clock.setTimeout(callback, delay);      target.clearTimeout = (id) => clock.clearTimeout(id);      if (target.Date && realNow) target.Date.now = clock.now;      return () => {        target.setTimeout = realSetTimeout;        target.clearTimeout = realClearTimeout;        if (target.Date && realNow) target.Date.now = realNow;      };    },  };  return clock;} const clock = createFakeClock(100);const log = [];clock.setTimeout(() => log.push("slow@" + clock.now()), 30);clock.setTimeout(() => log.push("fast@" + clock.now()), 10);clock.advance(10);console.log(log.join(", "));clock.tick(20);console.log(log.join(", "));
Console outputdue order
  1. fast@110
  2. fast@110, slow@130
Try it yourself
No real waiting

The 10ms callback runs when fake time reaches 110. The 30ms callback waits until fake time reaches 130.

This lesson keeps timers synchronous. The next testing lesson handles promises and microtasks with fake timers.

Dependency injection is the simplest seam

CHANGE INPUTS

Dependency injection means passing the outside thing in: fetcher, now, random, a repository, or a storage object. It can be a function parameter, an options object, or a constructor argument. The code under test stays ordinary, and the test chooses safe replacements.

Change the injected dependencies
Dependency-injected fetch, time, and randomPop out in the code editor (opens in a new tab)JavaScript
async function loadGreeting(userId, {  fetcher = fetch,  now = Date.now,  random = Math.random,} = {}) {  const response = await fetcher("/api/users/" + userId);  const user = await response.json();  const bucket = random() < 0.5 ? "A" : "B";  return user.name + " @ " + now() + " #" + bucket;} const fakeFetch = async () =>  new Response(JSON.stringify({ name: "Ada" }), {    headers: { "content-type": "application/json" },  }); loadGreeting(7, {  fetcher: fakeFetch,  now: () => 1000,  random: () => 0.25,}).then(console.log);
Selected outputdeterministic
fake userAda
now()1000
random()0.25
resultAda @ 1000 #A
Try it yourself

The source passes fetcher, now, and random as values. Tests can choose time and randomness without touching globals.

The fake fetcher returns a browser Response, so the snippet runs in Chrome's editor without a server.

Notice that the fake network still returns a real browser Response. That keeps the consuming code honest: it must still call response.json(). The test controls the body without opening a socket. The same pattern works for Date.now and Math.random because both are just functions when you pass them in.

Seeded randomness is deterministic
function mulberry32(seed) {  return function random() {    seed |= 0;    seed = (seed + 0x6D2B79F5) | 0;    let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);    value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;  };} const random = mulberry32(123);console.log(random().toFixed(3));console.log(random().toFixed(3));
Console outputseed 123

0.787, 0.179

Try it yourself
Same seed, same sequence

Injecting random is usually simplest. When a larger test needs many random-looking values, a seeded PRNG gives repeatable numbers.

The exact numbers are less important than the repeatability: seed 123 always starts with these two values.

A seeded pseudo-random generator is useful when a test needs a stream of random-looking values. For one branch, injected random: () => 0.25 is clearer. For a property test or simulation, a seed lets you reproduce the exact failing sequence.

If you patch a global, restore it in finally
Patch and restorePop out in the code editor (opens in a new tab)JavaScript
const realRandom = Math.random;try {  Math.random = () => 0.1;  console.log(Math.random());} finally {  Math.random = realRandom;}console.log(Math.random === realRandom);
What it provesrestore
inside patch0.1
restoredtrue
Try it yourself
Last-resort seam

Monkey-patching can be useful around legacy code, but one missing restore can poison every later test in the file.

The better seam is usually an injected random function. When you must patch, keep the patch tiny and always restore.

Fakes vs mocks: behavior beats choreography

SORT IT

A fake implements enough behavior to test through a boundary. An in-memory repository can save and find records. A fake localStorage can keep strings in a Map. A mock, in the narrow sense, makes exact calls the requirement. Both are useful, but they protect different things.

A fake is a tiny working implementation
In-memory fake storagePop out in the code editor (opens in a new tab)JavaScript
function createMemoryStorage() {  const data = new Map();  return {    getItem: (key) => data.get(key) ?? null,    setItem: (key, value) => data.set(key, String(value)),    removeItem: (key) => data.delete(key),  };} function saveTheme(storage, theme) {  storage.setItem("theme", theme);  return storage.getItem("theme");} const fakeStorage = createMemoryStorage();console.log(saveTheme(fakeStorage, "dark"));console.log(fakeStorage.getItem("theme"));
Console outputfake storage
  1. dark
  2. dark
Try it yourself
Fresh in-memory storage

The fake storage behaves enough like localStorage for this test, but it never touches the browser's real shared storage.

Prefer fakes when behavior matters more than exact call order. Use mocks when the interaction itself is the behavior.

Prefer a fake when callers should not care how many lower-level methods were used. Prefer a mock when the interaction is the contract: “send exactly one welcome email” or “record this analytics event with these fields.” Over-mocking happens when a test asserts internal call order that users and callers cannot observe.

Dummy, stub, spy, mock, or fake?
  • checkout(cart, nullLogger) and the logger is never read
  • nextId() always returns "id-1"
  • A wrapper keeps calls while forwarding to the real function
  • The test fails unless track("signup") happens exactly once
  • A Map-backed repository implements save and find
  • A fresh object implements getItem and setItem for each test
Try it yourself
0 of 6 correct

Sort each card by the closest double. The first bucket combines dummies and stubs because the component supports up to four buckets; the explanation tells them apart.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Mocking modules and tools

NODE + TOOLING

Module mocking is a stronger seam than dependency injection. It changes what an import resolves to, so it can help legacy code but also couples tests to file structure. ESM imports are live bindings and are evaluated before your test body, so test runners provide special APIs rather than plain assignment. Browser import maps choose module URLs up front; they are not a per-test mocking system.

Common mocking APIs in 2026-era JavaScript test tools
RunnerAPIsNotes
node:testt.mock.fn, t.mock.method, t.mock.timersBuilt into Node 22. MockTimers and mock.module are still Stability 1; module mocks need --experimental-test-module-mocks.
Vitestvi.fn, vi.spyOn, vi.mock, vi.useFakeTimersWorks with Vite projects; vi.mock is hoisted and can mock modules with a factory.
Jestjest.fn, jest.spyOn, jest.mock, jest.useFakeTimersMature API; restore timers and module mocks between tests to avoid leaks.
node:test mock output
node:test mocksJavaScript
import assert from "node:assert/strict";import { test } from "node:test"; test("mock.fn, mock.method, and mock.timers", (t) => {  const send = t.mock.fn((value) => "sent:" + value);  assert.equal(send("ok"), "sent:ok");  console.log(send.mock.callCount());  console.log(send.mock.calls[0].arguments.join(","));   const api = { now: () => 0 };  t.mock.method(api, "now", () => 42);  console.log(api.now());   t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 100 });  const events = [];  setTimeout(() => events.push("timer@" + Date.now()), 50);  t.mock.timers.tick(50);  console.log(events.join(","));});
Normalized console lineschild process
  1. 1
  2. ok
  3. 42
  4. timer@150
Try it yourself
Node-only, proved by the lesson test

Node's test runner prints TAP. The console lines from the test are 1, ok, 42, and timer@150.

This code is marked non-runnable in the browser editor because it imports Node built-ins.

In Node 22.23.1, t.mock.fn and t.mock.method are stable parts of the test runner. MockTimers and mock.module are still Stability 1 APIs. mock.module is only available with --experimental-test-module-mocks, so treat it as a last resort and document the flag in your test command.

node:test module mock needs a flagJavaScript
import assert from "node:assert/strict";import { test, mock } from "node:test"; test("mock.module replaces an ESM import", async () => {  mock.module("node:readline", {    namedExports: {      createInterface: () => ({ question: () => "fake" }),    },  });  const readline = await import("node:readline");  assert.equal(readline.createInterface().question(), "fake");  console.log(readline.createInterface().question());}); // Run with: node --experimental-test-module-mocks --test module-mock.test.mjs
Vitest module and timer APIsJavaScript
import { afterEach, expect, test, vi } from "vitest";import { loadUser } from "./load-user.js"; vi.mock("./api.js", () => ({  fetchUser: vi.fn(() => ({ id: 7, name: "Ada" })),})); afterEach(() => vi.useRealTimers()); test("Vitest doubles", () => {  vi.useFakeTimers();  const callback = vi.fn();  setTimeout(callback, 100);  vi.advanceTimersByTime(100);  expect(callback).toHaveBeenCalledOnce();});
Jest module and timer APIsJavaScript
jest.mock("./api", () => ({  fetchUser: jest.fn(() => ({ id: 7, name: "Ada" })),})); afterEach(() => jest.useRealTimers()); test("Jest doubles", () => {  jest.useFakeTimers();  const callback = jest.fn();  setTimeout(callback, 100);  jest.advanceTimersByTime(100);  expect(callback).toHaveBeenCalledTimes(1);});

Vitest and Jest examples are shown as tooling references only because this project does not install those runners. Their docs use the same pattern: create mock functions with vi.fn or jest.fn, spy on object methods, mock modules through the runner, use fake timers, then restore real timers after each test.

Common misconceptions

  • “Mock everything outside the function.” Replace only the boundary that makes the test slow, flaky, unsafe, or nondeterministic. Too many mocks test wiring, not behavior.
  • “A spy is harmless because it observes.” A spy can still make a test brittle if you assert internal calls instead of the public result.
  • “Fake timers are only for async tests.” They are also useful for synchronous setTimeout callbacks. Promise timing adds microtasks, covered next.
  • “Monkey-patching globals is the same as dependency injection.” Patching changes shared state and must be restored. Injection keeps the replacement local.
  • “Fakes are less real than mocks.” A fake can be more behavior-focused than a mock because callers exercise it through the same public API.
  • “Module mocks are always better seams.” They often depend on import order, runner transforms, and file paths. Passing a collaborator is usually simpler.
Choosing the least brittle double
QuestionPreferWhy
Do I just need a branch to return known data?StubIt is direct and keeps assertions on the result.
Do I need to know a callback fired?SpyThe call record is the relevant behavior.
Do I need storage-like behavior?FakeA small implementation lets callers use the real API shape.
Is the exact external interaction the requirement?MockThen an expectation on the call is the behavior.
Can I pass the dependency as a value?Dependency injectionIt avoids global and module patching.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict a spy record

Read the code and type the exact text printed by the final line.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function spy(fn) {
  const calls = [];
  const wrapper = (...args) => {
    const value = fn(...args);
    calls.push({ args, value });
    return value;
  };
  wrapper.calls = calls;
  return wrapper;
}
const multiply = spy((a, b) => a * b);
console.log(multiply(2, 3) + "|" + multiply.calls.length);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict a stub sequence

    What comma-separated text prints after three calls to the stub?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function stub(...values) {
      let index = 0;
      return () => values[Math.min(index++, values.length - 1)];
    }
    const color = stub("red", "blue");
    console.log([color(), color(), color()].join(","));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRun timers in due order

      Predict the order a fake clock should run these two callbacks.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const events = [];
      const timers = [
        { time: 20, run: () => events.push("slow") },
        { time: 5, run: () => events.push("fast") },
      ];
      timers.toSorted((a, b) => a.time - b.time).forEach((timer) => timer.run());
      console.log(events.join(","));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeControl randomness

        Type the two buckets printed by the injected-random example.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const bucket = (random = Math.random) => (random() < 0.5 ? "A" : "B");
        console.log(bucket(() => 0.1) + "|" + bucket(() => 0.9));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose the seam before mocking a module

          You need to test code that calls fetch. What seam should you add before reaching for a module mock or global monkey patch?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which double returns canned values but does not care how many times it was called?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this spy print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function spy(fn) {
                const calls = [];
                const wrapper = (...args) => {
                  const value = fn(...args);
                  calls.push({ args, value });
                  return value;
                };
                wrapper.calls = calls;
                return wrapper;
              }
              const add = spy((a, b) => a + b);
              console.log(add(1, 4) + "|" + add.calls.length);

              Choose an answer to see the explanation.

            3. Question 3 of 7What does the fake timer queue print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const log = [];
              const timers = [
                { time: 30, run: () => log.push("slow") },
                { time: 10, run: () => log.push("fast") },
              ];
              timers.toSorted((a, b) => a.time - b.time).forEach((timer) => timer.run());
              console.log(log.join(","));

              Choose an answer to see the explanation.

            4. Question 4 of 7Why is dependency injection usually the first seam to try?

              Choose an answer to see the explanation.

            5. Question 5 of 7What should a Node 22 test remember about mock.module?

              Choose an answer to see the explanation.

            6. Question 6 of 7Which test is over-mocked?

              Choose an answer to see the explanation.

            7. Question 7 of 7What does the injected-random snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bucket = (random = Math.random) => (random() < 0.5 ? "A" : "B");
              console.log(bucket(() => 0.1) + "|" + bucket(() => 0.9));

              Choose an answer to see the explanation.

            Key takeaways

            • Test doubles isolate code from time, network, storage, randomness, and side effects.
            • Dummies fill unused parameters; stubs answer; spies record; mocks expect; fakes work.
            • Dependency injection is usually simpler and safer than monkey-patching globals or modules.
            • Fake clocks make synchronous timer callbacks deterministic; promises need extra care next.
            • Over-mocking makes tests brittle when they assert private implementation details.

            Remember the one-liner.
            Replace only the outside force that makes a test nondeterministic, then assert the behavior your users or callers actually depend on.

            Up next: Testing async code, where fake timers meet promises, rejections, and flaky scheduling.

            CompleteFrontend Clear concepts. Working examples.