Node.js NotesRohit’s interview study guide
Chapter 13

Testing

Testing async Node code with Jest and node:test — mocks, stubs, HTTP tests and what coverage really tells you.

8 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Testing async code

A test runner can only judge what it waits for. Jest calls your test function and then decides the test is over at one of three moments: when the function returns (a plain synchronous test), when the promise it returned settles, or when you call the done callback. Nothing else. If your assertion runs 50 ms later, inside a .then that nobody returned, Jest has already moved on.

A good mental model is a referee with a whistle. The moment your test function hands control back, the referee blows the whistle — unless you hand them a promise, which means "wait for this before you score it". Anything that happens after the whistle isn't scored against your test. As you'll see below, it can even be scored against the next test.

So every async test has one job beyond its assertions: make Jest wait. async/await is the clearest way; returning the promise and done are the older ways you'll still meet.

asyncFunction.js
// asyncFunction.js
function fetchData() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('data received'), 100);
  });
}

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) reject(new Error('invalid id'));
      else resolve({ id, name: 'Rohit' });
    }, 50);
  });
}

function fetchDataCallback(cb) {
  setTimeout(() => cb(null, 'data received'), 100);
}

module.exports = { fetchData, fetchUser, fetchDataCallback };
AdvancedFive ways to test async code

From my notes, the five ways to test it (the first is the one from the doc):

asyncFunction.test.js
// asyncFunction.test.js
const { fetchData, fetchUser, fetchDataCallback } = require('./asyncFunction');

test('async/await: waits for the promise', async () => {
  const data = await fetchData();
  expect(data).toBe('data received');
});

test('return the promise: Jest waits for it', () => {
  return fetchData().then((data) => {
    expect(data).toBe('data received');
  });
});

test('.resolves / .rejects matchers', async () => {
  await expect(fetchUser(7)).resolves.toEqual({ id: 7, name: 'Rohit' });
  await expect(fetchUser(0)).rejects.toThrow('invalid id');
});

test('try/catch for a rejection, with expect.assertions', async () => {
  expect.assertions(1);
  try {
    await fetchUser(-1);
  } catch (err) {
    expect(err.message).toBe('invalid id');
  }
});

test('done callback for callback-style APIs', (done) => {
  fetchDataCallback((err, data) => {
    try {
      expect(err).toBeNull();
      expect(data).toBe('data received');
      done();
    } catch (e) {
      done(e);
    }
  });
});
AdvancedOutput: the five async styles pass
Output
$ npx jest asyncFunction.test.js --verbose
PASS ./asyncFunction.test.js
  ✓ async/await: waits for the promise (103 ms)
  ✓ return the promise: Jest waits for it (103 ms)
  ✓ .resolves / .rejects matchers (106 ms)
  ✓ try/catch for a rejection, with expect.assertions (51 ms)
  ✓ done callback for callback-style APIs (102 ms)

Tests:       5 passed, 5 total
What’s happening
  1. async/await: an async function always returns a promise, so Jest gets one and waits. await fetchData() pauses the test for the real 100 ms timer, then data is 'data received' and toBe compares with Object.is. The (103 ms) is the real timer — these tests don't fake time (see Fake timers).
  2. Return the promise: the function isn't async, but it returns the .then(...) chain. Jest waits for that chain, so the expect inside .then runs before the whistle. Drop the return and the test would pass instantly without ever checking anything.
  3. .resolves / .rejects: expect(promise).resolves.toEqual(x) itself returns a promise that settles once the original promise does. That's why each line needs await. fetchUser(7) resolves to { id: 7, name: 'Rohit' } (toEqual compares structure, not identity); fetchUser(0) rejects, and .rejects.toThrow('invalid id') checks the rejection's message contains that text.
  4. try/catch: await fetchUser(-1) turns the rejection into a thrown error, so catch runs and asserts on it. expect.assertions(1) is the safety net: it says "exactly one assertion must run in this test". If fetchUser(-1) had resolved, catch would be skipped and, without that line, the test would pass with zero assertions.
  5. done: for callback APIs. Jest sees the test function declares a parameter and waits until done() is called. The try/catch matters: if an expect throws inside the callback, the error is thrown on a timer, not in the test, so you must hand it to done(e) yourself — otherwise Jest just sees done never called and reports a timeout instead of the real failure.
  6. All five pass in about 0.6 s. Which to use: async/await for anything promise-based, .rejects for "this should fail", and done only when the code under test takes a callback and you can't promisify it.
AdvancedCommon async test mistakes

Now the mistakes. Each of these is a real test I ran; the results are the interesting part.

broken.test.js
// broken.test.js — the mistakes
const { fetchUser } = require('./asyncFunction');

test('forgot to await .rejects', () => {
  expect(fetchUser(5)).rejects.toThrow('invalid id'); // no await, no return
});

test('forgot to return the .then chain', () => {
  fetchUser(5).then((user) => {
    expect(user.name).toBe('Someone else');
  });
});

test('expect.assertions catches the silent pass', async () => {
  expect.assertions(1);
  try {
    await fetchUser(5); // resolves, so catch never runs
  } catch (err) {
    expect(err.message).toBe('invalid id');
  }
});
AdvancedOutput: broken tests that still pass
Output
$ npx jest broken.test.js -t "forgot to await"
PASS ./broken.test.js
  ✓ forgot to await .rejects
  ○ skipped forgot to return the .then chain
  ○ skipped expect.assertions catches the silent pass
Tests:       2 skipped, 1 passed, 3 total
…
JestAssertionError: expect(received).rejects.toThrow()

Received promise resolved instead of rejected
Resolved to value: {"id": 5, "name": "Rohit"}
Node.js v26.7.0                                  ← process exits with code 1

$ npx jest broken.test.js -t "expect.assertions catches"
  ● expect.assertions catches the silent pass
    Expected one assertion to be called but received zero assertion calls.
What’s happening
  1. Forgot to await .rejects: the test body returns undefined synchronously, so Jest marks it ✓ passed immediately. 50 ms later fetchUser(5) resolves, the .rejects matcher fails, and its promise rejects with nobody listening. Run alone, Jest prints "PASS" and then the process dies with that unhandled rejection (exit code 1). The bug is reported — but after a green tick, which is easy to miss in CI logs.
  2. Forgot to return the .then chain: same shape. The test passes at once; the toBe('Someone else') failure happens later, outside any test.
  3. expect.assertions(1) catches the third mistake: fetchUser(5) resolves, catch never runs, zero assertions happen, and Jest fails the test with "Expected one assertion to be called but received zero assertion calls".
  4. Now the nasty part. Run all three together and Jest 30 reports tests 1 and 2 as failed — but test 3 passes. Why? Tests 1 and 2 finish synchronously, so test 3 starts straight away and awaits for 50 ms. During those 50 ms, test 2's stray .then runs its expect(...). That assertion is counted toward whichever test is running — test 3 — which satisfies its expect.assertions(1). I confirmed it: run tests 1 and 3 only and test 3 fails as it should; run tests 2 and 3 and test 3 passes.
  5. The lesson: an un-awaited assertion doesn't just fail to protect its own test, it can corrupt the result of a neighbouring one. Await or return every promise; the ESLint rules jest/valid-expect and @typescript-eslint/no-floating-promises catch most of these (see Floating promises and unhandled rejections).
AdvancedPitfalls: mixing done and promises
Does this test pass?
JavaScript
test('rejects bad ids', async () => {
  try {
    await fetchUser(3);
  } catch (err) {
    expect(err.message).toMatch(/invalid/);
  }
});
Show answer

It passes — and it shouldn't.

What’s happening
  1. fetchUser(3) resolves to { id: 3, name: 'Rohit' } after 50 ms because 3 > 0.
  2. await gets the value, no error is thrown, so the catch block never runs.
  3. The test function's promise resolves with zero assertions made. Jest has no rule that a test must assert something, so it's green.
  4. Fix: add expect.assertions(1) at the top, or better, write await expect(fetchUser(3)).rejects.toThrow(/invalid/) — which fails with "Received promise resolved instead of rejected", exactly what you want to know.

#Mocking modules and dependencies

Unit tests are meant to check one piece of logic, not your database, your mail server and the internet. So you swap the collaborators for test doubles. The vocabulary interviewers like:

DoubleWhat it doesExample
StubReturns canned answersfindByEmail always resolves null
MockRecords calls so you can assert on them"was sendWelcome called once with a@x.com?"
SpyWraps a real function and records calls, optionally replacing itjest.spyOn(globalThis, 'fetch')
FakeA working lightweight implementationan in-memory repo backed by a Map

In Jest, jest.fn() is all of the first three at once: it records every call (.mock.calls) and you can program its return value.

The mental model for jest.mock('./userRepo'): Jest owns require. Each test file runs with Jest's own module loader and registry, not Node's. When your code under test calls require('./userRepo'), Jest can hand it a different object — an automatic mock where every exported function is a jest.fn() — and the code never knows.

userService.js
// userService.js
const repo = require('./userRepo');
const mailer = require('./mailer');

async function registerUser(email) {
  const existing = await repo.findByEmail(email);
  if (existing) throw new Error('email taken');
  const user = await repo.create({ email });
  await mailer.sendWelcome(user.email);
  return user;
}

module.exports = { registerUser };
AdvancedMocking userRepo and mailer with jest.mock

The real userRepo.js and mailer.js throw 'real database called' / 'real email sent', so if a test ever reaches them we'll know.

userService.test.js
// userService.test.js
const { registerUser } = require('./userService');
const repo = require('./userRepo');
const mailer = require('./mailer');

jest.mock('./userRepo'); // replace every export with a jest.fn()
jest.mock('./mailer');

beforeEach(() => {
  jest.clearAllMocks(); // forget calls from the previous test
});

test('the automock is a jest.fn that returns undefined', () => {
  expect(jest.isMockFunction(repo.findByEmail)).toBe(true);
  expect(repo.findByEmail('x')).toBeUndefined();
});

test('creates the user and sends a welcome email', async () => {
  repo.findByEmail.mockResolvedValue(null);
  repo.create.mockResolvedValue({ id: 1, email: 'a@x.com' });

  const user = await registerUser('a@x.com');

  expect(user).toEqual({ id: 1, email: 'a@x.com' });
  expect(repo.create).toHaveBeenCalledWith({ email: 'a@x.com' });
  expect(mailer.sendWelcome).toHaveBeenCalledTimes(1);
  expect(mailer.sendWelcome).toHaveBeenCalledWith('a@x.com');
});

test('rejects a duplicate email and sends nothing', async () => {
  repo.findByEmail.mockResolvedValue({ id: 9, email: 'a@x.com' });

  await expect(registerUser('a@x.com')).rejects.toThrow('email taken');

  expect(repo.create).not.toHaveBeenCalled();
  expect(mailer.sendWelcome).not.toHaveBeenCalled();
});

test('a failing mailer surfaces as a rejection', async () => {
  repo.findByEmail.mockResolvedValue(null);
  repo.create.mockResolvedValue({ id: 2, email: 'b@x.com' });
  mailer.sendWelcome.mockRejectedValueOnce(new Error('SMTP down'));

  await expect(registerUser('b@x.com')).rejects.toThrow('SMTP down');
  expect(repo.create).toHaveBeenCalledTimes(1); // the user row was still created
});
AdvancedOutput: the mocked service tests
Output
PASS ./userService.test.js
  ✓ the automock is a jest.fn that returns undefined (1 ms)
  ✓ creates the user and sends a welcome email (1 ms)
  ✓ rejects a duplicate email and sends nothing (2 ms)
  ✓ a failing mailer surfaces as a rejection (1 ms)
What’s happening
  1. Look at the order: the require calls come before jest.mock(...) in the source, yet the mocks apply. Jest's Babel transform (on by default, via babel-jest) hoists jest.mock calls to the top of the file before anything runs. So jest.mock('./userRepo') registers the mock first, then require('./userService') runs, and its require('./userRepo') gets the mock.
  2. With no factory argument, Jest automocks: it loads the real module's shape and replaces each function with a jest.fn() that returns undefined. The first test proves it — repo.findByEmail('x') is undefined, and nothing threw "real database called".
  3. In the happy-path test, mockResolvedValue(null) is shorthand for mockImplementation(() => Promise.resolve(null)). So await repo.findByEmail(...) gives null, the if is skipped, create resolves { id: 1, email: 'a@x.com' }, and sendWelcome (still the bare automock) returns undefined, which await accepts.
  4. The assertions check interactions: toHaveBeenCalledWith({ email: 'a@x.com' }) compares the call's arguments with recursive equality; toHaveBeenCalledTimes(1) makes sure the email isn't sent twice. In the duplicate test, .not.toHaveBeenCalled() proves the early throw stopped the function before it touched create.
  5. mockRejectedValueOnce affects only the next call, then falls back to the default. The last test documents real behaviour worth knowing: the user row was created even though the email failed — a design question (outbox? retry?) the test makes visible.
  6. jest.clearAllMocks() in beforeEach wipes .mock.calls between tests. Without it, toHaveBeenCalledTimes(1) in the last test would count the create call from the happy-path test too.
AdvancedSpying on globals like fetch

Not everything is a module you can jest.mock. Globals like fetch are better spied on: jest.spyOn(object, 'method') replaces one property and remembers the original so you can put it back.

weather.test.js
// weather.js:
//   async function getTemperature(city) {
//     const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(city)}`);
//     if (!res.ok) throw new Error(`weather API returned ${res.status}`);
//     const body = await res.json();
//     return Math.round(body.tempC);
//   }
const { getTemperature } = require('./weather');

afterEach(() => {
  jest.restoreAllMocks(); // put the real fetch back
});

test('rounds the temperature from the API', async () => {
  const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => ({ tempC: 21.6 }),
  });

  await expect(getTemperature('São Paulo')).resolves.toBe(22);

  expect(fetchSpy).toHaveBeenCalledTimes(1);
  expect(fetchSpy.mock.calls[0][0]).toBe(
    'https://api.example.com/weather?city=S%C3%A3o%20Paulo'
  );
});

test('turns a non-2xx response into an error', async () => {
  jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, status: 503 });

  await expect(getTemperature('Pune')).rejects.toThrow('weather API returned 503');
});

test('restoreAllMocks put the real fetch back', () => {
  expect(jest.isMockFunction(globalThis.fetch)).toBe(false);
});
What’s happening
  1. jest.spyOn(globalThis, 'fetch') replaces globalThis.fetch with a mock function and keeps a reference to the real one. .mockResolvedValue({...}) makes it resolve to a fake Response with just the two members the code uses: ok and json(). No network request happens.
  2. getTemperature('São Paulo') builds the URL, the spy returns the fake, res.ok is true, json() gives { tempC: 21.6 }, and Math.round makes it 22.
  3. fetchSpy.mock.calls[0][0] is the first argument of the first call. Asserting on it checks the URL encoding: ã becomes %C3%A3 and the space %20.
  4. The second test programs a 503 and checks the error path. It doesn't need the fetchSpy variable, because it only cares about the result.
  5. jest.restoreAllMocks() in afterEach swaps the real fetch back. The third test proves it: isMockFunction(globalThis.fetch) is false. Forget the restore and a spy leaks into every later test in the file.
AdvancedFactory mocks: the axios example

The axios example from my notes uses the same mechanics with a factory — the second argument to jest.mock decides exactly what the module exports:

user-api.test.js
// Assumes axios is installed. (I verified this pattern in the sandbox, where axios
// isn't installed, by adding { virtual: true } as a third argument to jest.mock.)
jest.mock('axios', () => ({ get: jest.fn() }));
const axios = require('axios');

async function getUserName(id) {
  const { data } = await axios.get(`/users/${id}`);
  return data.name;
}

test('mock axios.get', async () => {
  axios.get.mockResolvedValue({ data: { id: 1, name: 'Rohit' } });

  await expect(getUserName(1)).resolves.toBe('Rohit');
  expect(axios.get).toHaveBeenCalledWith('/users/1');
});
What’s happening
  1. The factory () => ({ get: jest.fn() }) runs instead of loading axios, and whatever it returns is the module. Only get exists — call axios.post and you'd get "axios.post is not a function", which is a feature: the test fails loudly if the code starts using something you didn't plan for.
  2. mockResolvedValue({ data: {...} }) mimics axios's response shape, where the body lives under data. Get that shape wrong and you're testing your mock, not your code.
  3. toHaveBeenCalledWith('/users/1') checks the URL the code built from id.
  4. Variables used inside a hoisted factory must be defined inside it (or be named starting with mock), because the factory runs before the rest of the file — Jest throws a clear error if you reference an outer variable.
AdvancedClear vs reset vs restore mocks

Three resets that people mix up. I ran all three on a jest.fn().mockReturnValue(42) and a jest.spyOn(obj, 'greet').mockReturnValue('fake'):

CallRecorded callsProgrammed return valuesSpy replaced the real method?What I saw
jest.clearAllMocks()wipedkeptyesfn() → 42, obj.greet() → 'fake'
jest.resetAllMocks()wipedremovedyes, now returns undefinedfn() → undefined, obj.greet() → undefined
jest.restoreAllMocks()——no, original put backobj.greet() → 'real'

The config options clearMocks, resetMocks and restoreMocks do the same automatically before every test.

Dependency injection makes most of this unnecessary. If createUserService({ repo, mailer }) receives its collaborators as arguments, a test just passes in plain objects — no module-registry tricks, no hoisting, and it works the same in any runner. There's a version of exactly that in The built-in node:test runner, and the pattern itself is in Design patterns in Node: factory and dependency injection.

AdvancedLimits: over-mocking and ESM mocking
Forgot to program a mock

In the first test file, a new test calls await registerUser('a@x.com') without any mockResolvedValue calls. What happens?

Show answer

It fails with TypeError: Cannot read properties of undefined (reading 'email').

What’s happening
  1. The automocked repo.findByEmail returns undefined; await undefined is undefined, which is falsy, so the "email taken" branch is skipped.
  2. repo.create({ email }) also returns undefined, so user is undefined.
  3. mailer.sendWelcome(user.email) reads .email of undefined → TypeError. The real database is never touched, but the error points at your code, not at the missing stub — a common head-scratcher.

#Fake timers

Added

Code that waits — debounce, throttle, retries with backoff, cache TTLs, session expiry — is slow and flaky to test with real time. A retry test that sleeps 100 + 200 + 400 ms is already almost a second, and on a loaded CI machine timers fire late.

Fake timers replace setTimeout, setInterval, Date and friends with a fake clock that only moves when you tell it to. Mental model: you freeze every clock in the building and carry the only key. jest.advanceTimersByTime(300) turns the hands 300 ms forward and fires — synchronously, right there — every timer that falls due along the way.

timing.js
// timing.js
function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

async function retry(fn, { retries = 3, baseMs = 100 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= retries) throw err;
      const delay = baseMs * 2 ** attempt; // 100, 200, 400 …
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

module.exports = { debounce, retry };
Advancedtiming.test.js: debounce and retry with fake timers
timing.test.js
// timing.test.js
const { debounce, retry } = require('./timing');

beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

test('debounce calls once, with the last arguments, after the quiet period', () => {
  const save = jest.fn();
  const debouncedSave = debounce(save, 300);

  debouncedSave('a');
  jest.advanceTimersByTime(100);
  debouncedSave('ab');
  jest.advanceTimersByTime(100);
  debouncedSave('abc');

  jest.advanceTimersByTime(299);
  expect(save).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1);
  expect(save).toHaveBeenCalledTimes(1);
  expect(save).toHaveBeenCalledWith('abc');
});

test('retry backs off 100 ms, then 200 ms, then succeeds', async () => {
  const flaky = jest
    .fn()
    .mockRejectedValueOnce(new Error('ECONNRESET'))
    .mockRejectedValueOnce(new Error('ECONNRESET'))
    .mockResolvedValue('ok');

  const result = retry(flaky, { retries: 3, baseMs: 100 });

  await jest.advanceTimersByTimeAsync(99);
  expect(flaky).toHaveBeenCalledTimes(1);

  await jest.advanceTimersByTimeAsync(1); // 100 ms: second attempt
  expect(flaky).toHaveBeenCalledTimes(2);

  await jest.advanceTimersByTimeAsync(200); // 300 ms: third attempt
  expect(flaky).toHaveBeenCalledTimes(3);
  await expect(result).resolves.toBe('ok');
});

test('retry gives up after the last attempt', async () => {
  const down = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));

  const result = retry(down, { retries: 2, baseMs: 100 });
  const assertion = expect(result).rejects.toThrow('ECONNREFUSED'); // attach first

  await jest.advanceTimersByTimeAsync(300); // 100 + 200
  await assertion;
  expect(down).toHaveBeenCalledTimes(3); // first try + 2 retries
});
AdvancedOutput: the fake-timer tests
Output
PASS ./timing.test.js
  ✓ debounce calls once, with the last arguments, after the quiet period (2 ms)
  ✓ retry backs off 100 ms, then 200 ms, then succeeds (5 ms)
  ✓ retry gives up after the last attempt (1 ms)
What’s happening
  1. Debounce timeline (fake clock): at 0 the call with 'a' schedules a timer for 300. Advance to 100; the 'ab' call clears it and schedules one for 400. Advance to 200; 'abc' reschedules for 500. Advance 299 → clock 499: nothing due, save not called. Advance 1 → clock 500: the timer fires, save('abc') runs once. Real time spent: 2 ms.
  2. Retry, first attempt: retry(flaky, …) calls flaky() straight away (call 1), which returns a rejected promise. The catch runs in a microtask, and only then calls setTimeout(resolve, 100). So right after retry(...) returns, no timer exists yet.
  3. That's why the retry tests use advanceTimersByTimeAsync. It lets pending promise callbacks run before and after each timer it fires. advanceTimersByTimeAsync(99) first flushes the microtask that schedules the 100 ms timer, then moves the clock to 99: still 1 call.
  4. One more millisecond → clock 100: the timer fires, resolve() settles the await, the loop goes round, flaky() is call 2 and rejects again, and a 200 ms timer is scheduled (due at 300). Advance 200 → call 3 resolves 'ok', and retry returns it. Backoff verified: 100 ms, then 200 ms.
  5. Give-up test: retries: 2 means attempts 0, 1, 2 — three calls, waiting 100 + 200 ms between them. The rejection is created while the clock is advancing, so the test attaches the .rejects handler before advancing. Attach it afterwards and Node would see a rejection with no handler for a moment.
  6. afterEach(() => jest.useRealTimers()) hands the real clock back so a fake one can't leak into a test that expects real time.
AdvancedTrap: synchronous timer advance

Here is the trap step 3 avoids — the synchronous version on the same retry:

timing-sync.test.js
test('sync advance misses timers created after a promise settles', async () => {
  jest.useFakeTimers();
  const flaky = jest.fn().mockRejectedValueOnce(new Error('ECONNRESET')).mockResolvedValue('ok');
  const result = retry(flaky, { retries: 3, baseMs: 100 });
  jest.advanceTimersByTime(1000);
  console.log('calls after advancing 1000 ms:', flaky.mock.calls.length, 'pending timers:', jest.getTimerCount());
  await result;
}, 500);
// calls after advancing 1000 ms: 1 pending timers: 0
// ✕ … thrown: "Exceeded timeout of 500 ms for a test."
What’s happening
  1. retry(...) makes call 1, which rejects; the catch is queued as a microtask.
  2. jest.advanceTimersByTime(1000) is synchronous — microtasks can't run in the middle of it. When it looks, there are 0 pending timers, so it moves the fake clock 1000 ms and fires nothing.
  3. Then await result yields; the microtask runs and schedules the 100 ms timer — on a fake clock that nobody will ever advance again. result never settles.
  4. The test fails on the 500 ms timeout, and notice that this timeout still fired: Jest's own test timeout uses real timers, not the fake ones. Rule of thumb: when timers are scheduled after an await, use the Async variants.
AdvancedEdge cases: timers/promises and more
AdvancedRelated: debounce and retry explanations

The debounce itself is explained on the JavaScript site under Debouncing; the retry policy (and why production adds jitter) is in Retries with exponential backoff and jitter.

How many calls?

With fake timers on, const d = debounce(fn, 300); you call d(1), advance 300, call d(2), advance 299, call d(3), advance 300. How many times did fn run, with what?

Show answer

Twice: fn(1) and fn(3).

What’s happening
  1. d(1) at clock 0 schedules for 300. Advancing 300 reaches 300 exactly, so it fires: fn(1).
  2. d(2) at 300 schedules for 600. Advancing 299 reaches 599 — not due.
  3. d(3) at 599 clears the d(2) timer and schedules for 899. Advancing 300 reaches 899: fn(3).
  4. fn(2) never runs — exactly what debounce is for: only the last call of a burst survives.

#The built-in node:test runner

Added

Node ships its own test runner: node:test for writing tests, node:assert for checking, and node --test for running them. It has describe/it/test, hooks, mocks (mock.fn, mock.method, mock.timers), snapshots, parallel files, and coverage — with zero dependencies, nothing to install, no Babel transform, and native ESM and CommonJS.

The mental model: Jest is a full framework that runs your code inside its own module system (which is how jest.mock works); node:test is a library that runs your code in plain Node. That makes it fast and honest, but it can't swap a require behind your back without an experimental flag — so it rewards code that takes its dependencies as arguments.

app.test.mjs
// app.test.mjs
import { describe, it, beforeEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { createUserService } from './createUserService.js'; // CommonJS files: named
import { debounce } from './timing.js';                     // exports are detected
import { getDiscount, shippingFee } from './pricing.js';
const realRandom = Math.random;

describe('registerUser', () => {
  let repo, mailer, service;

  beforeEach(() => {
    repo = {
      findByEmail: mock.fn(async () => null),
      create: mock.fn(async ({ email }) => ({ id: 1, email })),
    };
    mailer = { sendWelcome: mock.fn(async () => {}) };
    service = createUserService({ repo, mailer });
  });

  it('creates the user and sends one welcome email', async () => {
    const user = await service.registerUser('a@x.com');

    assert.deepEqual(user, { id: 1, email: 'a@x.com' });
    assert.equal(mailer.sendWelcome.mock.callCount(), 1);
    assert.deepEqual(mailer.sendWelcome.mock.calls[0].arguments, ['a@x.com']);
  });

  it('rejects a duplicate email', async () => {
    repo.findByEmail.mock.mockImplementation(async () => ({ id: 9 }));

    await assert.rejects(service.registerUser('a@x.com'), { message: 'email taken' });
    assert.equal(repo.create.mock.callCount(), 0);
  });
});

describe('debounce', () => {
  it('fires once after the quiet period', (t) => {
    t.mock.timers.enable({ apis: ['setTimeout'] });
    const save = t.mock.fn();
    const debounced = debounce(save, 300);

    debounced('a');
    debounced('ab');
    t.mock.timers.tick(299);
    assert.equal(save.mock.callCount(), 0);

    t.mock.timers.tick(1);
    assert.equal(save.mock.callCount(), 1);
    assert.deepEqual(save.mock.calls[0].arguments, ['ab']);
  });
});

describe('pricing', () => {
  it('gives premium users 20%', () => {
    assert.equal(getDiscount({ isPremium: true }), 20);
  });

  it('charges standard shipping under $100', () => {
    assert.equal(shippingFee({ total: 40, express: false }), 5);
  });
});

it('spies on a method with mock.method', (t) => {
  const spy = t.mock.method(Math, 'random', () => 0.5);
  assert.equal(Math.random(), 0.5);
  assert.equal(spy.mock.callCount(), 1);
}); // t.mock is restored automatically when the test ends

it('Math.random is real again', () => {
  assert.equal(Math.random, realRandom);
});
Advancednode:test with dependency injection

createUserService.js is the dependency-injected version of the Jest example's userService.js: the same registerUser logic, but repo and mailer come in as arguments.

Output
$ node --test
▶ registerUser
  ✔ creates the user and sends one welcome email (0.701291ms)
  ✔ rejects a duplicate email (0.226042ms)
✔ registerUser (1.296334ms)
▶ debounce
  ✔ fires once after the quiet period (0.249541ms)
✔ debounce (0.325792ms)
▶ pricing
  ✔ gives premium users 20% (0.057917ms)
  ✔ charges standard shipping under $100 (0.050959ms)
✔ pricing (0.199459ms)
✔ spies on a method with mock.method (0.112416ms)
✔ Math.random is real again (0.038459ms)
ℹ tests 7
ℹ suites 3
ℹ pass 7
ℹ fail 0
What’s happening
  1. node --test with no file arguments searches the current directory for test files by name and runs each file in its own child process (isolation by default). It found app.test.mjs.
  2. beforeEach builds fresh fakes for every test: mock.fn(async () => null) is a recording function, like jest.fn. Because the service gets repo and mailer as arguments, there's no module mocking at all — the test just hands them in.
  3. Call records live on fn.mock: callCount() and calls[i].arguments. In the second test, repo.findByEmail.mock.mockImplementation(...) reprograms the fake (note the extra .mock compared with Jest), and assert.rejects(promise, { message }) awaits the promise and checks the rejection matches that object's properties.
  4. The t argument is the test context. t.mock.timers.enable({ apis: ['setTimeout'] }) fakes setTimeout for this test only; tick(299) then tick(1) play the same role as advanceTimersByTime. Anything created through t.mock is undone automatically when the test ends.
  5. The last two tests prove that cleanup: t.mock.method(Math, 'random', …) replaces Math.random inside one test, and in the next test Math.random is the original function again.
  6. node:assert/strict makes equal mean === and deepEqual mean strict deep equality. Plain node:assert's legacy equal uses ==, so assert.equal(1, '1') would pass — always import the strict variant.
AdvancedBuilt-in coverage report

Coverage is built in. On Node 26.7 the flag is still --experimental-test-coverage (I checked: node --test --test-coverage fails with bad option), and thresholds have their own flags:

Output
$ node --test --experimental-test-coverage
ℹ start of coverage report
ℹ ---------------------------------------------------------------------
ℹ file                 | line % | branch % | funcs % | uncovered lines
ℹ ---------------------------------------------------------------------
ℹ createUserService.js | 100.00 |   100.00 |  100.00 |
ℹ pricing.js           |  75.00 |    50.00 |   66.67 | 4 12-14
ℹ timing.js            |  50.00 |   100.00 |   75.00 | 10-20
ℹ ---------------------------------------------------------------------
ℹ all files            |  70.00 |    78.57 |   77.78 |
ℹ ---------------------------------------------------------------------
ℹ end of coverage report

$ node --test --experimental-test-coverage --test-coverage-lines=90 --test-coverage-branches=90
ℹ Error: 70.00% line coverage does not meet threshold of 90%.
ℹ Error: 78.57% branch coverage does not meet threshold of 90%.
$ echo $?
1
What’s happening
  1. The report uses V8's built-in coverage (no instrumentation step) and leaves the test file itself out of the table by default.
  2. timing.js lines 10–20 are the whole retry function: no test called it, so it's in "uncovered lines" and the file has 75% of functions (3 of 4 — debounce, its inner arrow, its timer callback, but not retry).
  3. pricing.js line 4 is return 0 (only premium users were tested) and 12–14 is formatPrice, never called. Branch 50% because each if and ternary took only one side. More on what these numbers mean in Code coverage with Istanbul.
  4. With thresholds set, the run prints which threshold failed and exits with code 1, which is what fails a CI job.
AdvancedReference: Jest vs node:test
Jest 30node:test (Node 26)
Installnpm i -D jest (hundreds of packages)built in
Module mockingjest.mock, hoisted by Babelmock.module() behind --experimental-test-module-mocks
Fake timersjest.useFakeTimers(), advanceTimersByTimeAsyncmock.timers.enable(), tick()
Matchersrich expect APInode:assert (strict)
Coverage--coverage (Babel/Istanbul or V8)--experimental-test-coverage (V8)
Default file match*.test.js, *.spec.js, __tests__/*.test.*, *-test.*, *_test.*, test-*.*, test.*, test/
AdvancedEdge case: which test files are found
AdvancedChoosing between node:test and Jest

When to choose which: node:test is a great default for libraries, small services and anything that should have few dependencies; it's also what you'll find in Node core-style codebases. Jest still wins on ecosystem (matchers, snapshot tooling, watch mode with related-test selection, module mocking that just works with CommonJS). Many teams run Jest because it was there first; neither is wrong in an interview.

#Testing HTTP endpoints

An endpoint test sends a real HTTP request to your Express app and checks the real response — status, headers, JSON body — through the whole middleware chain: body parsing, auth, validation, the route, the error handler. It's the cheapest test that catches "the route works but the JSON parser isn't mounted" or "errors leak stack traces".

The trick that makes it easy is splitting the app from the server. app.js builds and exports the Express app; only server.js calls listen(). Then supertest takes the app, binds it to a throwaway port on 127.0.0.1 (port 0 means "any free port"), sends the request, and closes it again. Chapter 7 introduces it in Testing an Express app with supertest; here it is used for the things you'd actually test.

app.js
// app.js — exports the app, does NOT call listen()
const express = require('express');

function createApp({ users }) {
  const app = express();
  app.use(express.json());

  app.get('/users/:id', async (req, res) => {
    const user = await users.findById(Number(req.params.id));
    if (!user) return res.status(404).json({ error: 'not found' });
    res.json(user);
  });

  app.post('/users', async (req, res) => {
    if (req.get('authorization') !== 'Bearer test-token') {
      return res.status(401).json({ error: 'unauthenticated' });
    }
    const { email } = req.body ?? {};
    if (typeof email !== 'string' || !email.includes('@')) {
      return res.status(400).json({ error: 'email is required' });
    }
    const user = await users.create({ email }); // may reject
    res.status(201).location(`/users/${user.id}`).json(user);
  });

  app.use((err, req, res, next) => {
    res.status(500).json({ error: 'internal error' });
  });

  return app;
}

module.exports = { createApp };
Advancedserver.js: the only file that listens
server.js
// server.js — the only file that listens
const { createApp } = require('./app');
const users = require('./usersRepo'); // the real database-backed repo
createApp({ users }).listen(process.env.PORT || 3000);
Advancedapp.test.js: endpoint tests with supertest
app.test.js
// app.test.js
const request = require('supertest');
const { createApp } = require('./app');

function fakeUsers() {
  const rows = new Map([[1, { id: 1, email: 'rohit@example.com' }]]);
  return {
    findById: async (id) => rows.get(id) ?? null,
    create: jest.fn(async ({ email }) => ({ id: 2, email })),
  };
}

let users, app;
beforeEach(() => {
  users = fakeUsers();
  app = createApp({ users });
});

test('GET /users/1 returns the user as JSON', async () => {
  const res = await request(app)
    .get('/users/1')
    .expect('Content-Type', /json/)
    .expect(200);

  expect(res.body).toEqual({ id: 1, email: 'rohit@example.com' });
});

test('GET /users/99 is a 404', async () => {
  await request(app).get('/users/99').expect(404, { error: 'not found' });
});

test('POST /users without a token is a 401', async () => {
  await request(app).post('/users').send({ email: 'a@x.com' }).expect(401);
  expect(users.create).not.toHaveBeenCalled();
});

test('POST /users validates the body', async () => {
  await request(app)
    .post('/users')
    .set('Authorization', 'Bearer test-token')
    .send({ email: 'nope' })
    .expect(400, { error: 'email is required' });
});

test('POST /users creates and returns 201 with Location', async () => {
  const res = await request(app)
    .post('/users')
    .set('Authorization', 'Bearer test-token')
    .send({ email: 'a@x.com' })
    .expect(201);

  expect(res.headers.location).toBe('/users/2');
  expect(res.body).toEqual({ id: 2, email: 'a@x.com' });
  expect(users.create).toHaveBeenCalledWith({ email: 'a@x.com' });
});

test('a rejected promise in a route reaches the error handler (Express 5)', async () => {
  users.create.mockRejectedValueOnce(new Error('connection lost'));

  await request(app)
    .post('/users')
    .set('Authorization', 'Bearer test-token')
    .send({ email: 'a@x.com' })
    .expect(500, { error: 'internal error' });
});
AdvancedOutput: the endpoint tests
Output
PASS ./app.test.js
  ✓ GET /users/1 returns the user as JSON (14 ms)
  ✓ GET /users/99 is a 404 (3 ms)
  ✓ POST /users without a token is a 401 (4 ms)
  ✓ POST /users validates the body (1 ms)
  ✓ POST /users creates and returns 201 with Location (2 ms)
  ✓ a rejected promise in a route reaches the error handler (Express 5) (1 ms)
What’s happening
  1. createApp({ users }) takes the repository as an argument, so each test builds an app around a fresh fake: a Map with user 1 and a create that's a jest.fn (so we can assert on it and make it fail).
  2. request(app).get('/users/1') builds a request; nothing is sent until it's awaited. Then supertest wraps app in http.createServer, listens on 127.0.0.1:0, sends a real HTTP request over a real socket, and closes the server when the response is done. Express, express.json() and the router all run for real.
  3. .expect('Content-Type', /json/) and .expect(200) are supertest's assertions; they throw if the response doesn't match, which rejects the awaited promise and fails the test. .expect(404, { error: 'not found' }) checks status and deep-equals the parsed JSON body.
  4. The 401 test sends no Authorization header; the route returns early, and users.create not being called proves the auth check runs before any write. The 400 test sends { email: 'nope' } — .send(object) sets Content-Type: application/json and serialises it, which is what makes req.body work.
  5. The 201 test checks the Location header (res.headers keys are lower-case) and that create got exactly { email: 'a@x.com' } — so extra fields from the body can't sneak in.
  6. The last test makes create reject. The route has no try/catch, and in Express 5 a rejected promise from a handler is forwarded to the error middleware automatically, which returns the generic 500 without leaking 'connection lost'. In Express 4 the same test would hang until timeout, because the rejection went nowhere (see Error-handling middleware).
AdvancedReading a supertest failure message

When an assertion fails, supertest's message is specific — this is a real failure from changing one test to expect 200 for a missing user:

Output
  ● wrong status

    expected 200 "OK", got 404 "Not Found"
AdvancedPitfall: Jest won't exit

#Unit, integration and end-to-end tests

The three levels differ in how much real code runs and therefore which bugs they can catch. Picture the request path of a typical service:

Output
HTTP request → Express routes/middleware → service logic → repository → database
                                                              ↘ mailer → SMTP
AdvancedReference: unit, integration and E2E
LevelWhat's realWhat's fakedCatchesCost
Unitone function or moduleits collaboratorslogic bugs, edge cases, branchesmilliseconds, very stable
Integrationseveral real pieces together (app + repo + a real DB)external third partieswrong SQL, constraint errors, serialisation, wiringtens of ms to seconds; needs a DB
End-to-endthe deployed system, from the outsideideally nothingbroken config, auth, networking, deploy problemsseconds to minutes; flaky; slow

The classic advice is the test pyramid: many unit tests, fewer integration tests, a handful of E2E tests. For HTTP services a lot of teams shift weight towards integration tests (sometimes drawn as a "trophy"), because most real production bugs live at the boundaries — the SQL, the JSON shape, the middleware order — which unit tests mock away.

Here's the gap made concrete. The endpoint tests above used a fake repo. This integration test runs the same app with the real repository on a real SQLite database (Node 26's built-in node:sqlite, in memory):

usersRepo.js
// usersRepo.js — a real repository backed by SQLite
const { DatabaseSync } = require('node:sqlite');

function createUsersRepo(db) {
  db.exec(`CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
  )`);
  const byId = db.prepare('SELECT id, email FROM users WHERE id = ?');
  const insert = db.prepare('INSERT INTO users (email) VALUES (?) RETURNING id, email');
  return {
    findById: async (id) => byId.get(id) ?? null,
    create: async ({ email }) => ({ ...insert.get(email) }),
  };
}

module.exports = { createUsersRepo, DatabaseSync };
Advancedusers.int.test.js: a real-database test
users.int.test.js
// users.int.test.js — real app + real repo + real (in-memory) database
const request = require('supertest');
const { createApp } = require('./app');
const { createUsersRepo, DatabaseSync } = require('./usersRepo');

let db, app;
beforeEach(() => {
  db = new DatabaseSync(':memory:'); // fresh database per test
  app = createApp({ users: createUsersRepo(db) });
});
afterEach(() => db.close());

test('a created user can be read back', async () => {
  const created = await request(app)
    .post('/users')
    .set('Authorization', 'Bearer test-token')
    .send({ email: 'neha@example.com' })
    .expect(201);

  const read = await request(app).get(created.headers.location).expect(200);
  expect(read.body).toEqual({ id: 1, email: 'neha@example.com' });
});

test('a duplicate email hits the UNIQUE constraint → 500', async () => {
  const post = () =>
    request(app).post('/users').set('Authorization', 'Bearer test-token').send({ email: 'a@x.com' });

  await post().expect(201);
  await post().expect(500); // the unit tests with a fake repo could never find this
});
AdvancedOutput: the integration tests
Output
PASS ./users.int.test.js
  ✓ a created user can be read back (18 ms)
  ✓ a duplicate email hits the UNIQUE constraint → 500 (2 ms)
What’s happening
  1. beforeEach opens a brand-new in-memory database, so every test starts empty and tests can't affect each other; afterEach closes it so no handle stays open.
  2. The first test does a round trip: POST creates a row (the real INSERT … RETURNING gives back { id: 1, … }), then the test follows the real Location header (/users/1) and GETs it. This proves the write path, the read path, the column names and the JSON shape all agree — none of which the fake-repo tests could prove.
  3. The second test posts the same email twice. The first insert succeeds; the second violates UNIQUE, insert.get throws, the async function rejects, Express 5 forwards it, and the error handler returns 500.
  4. That 500 is a real bug the unit tests missed: a duplicate email should be a 409 Conflict, not an internal error. The fake repo's create always succeeded, so no unit test could see it. Fix it in createApp by checking findByEmail first or mapping the constraint error — and keep this test to prove it.
  5. Real projects usually run integration tests against the same database engine as production (Postgres in a container, for example), because SQLite won't catch Postgres-specific SQL. The structure is identical.
AdvancedWhere end-to-end tests fit

End-to-end tests drive the deployed system from outside: a browser automation tool such as Playwright for a web app, or an HTTP client hitting a staging environment for an API. Keep them few and focused on the money paths (sign up, log in, pay), because they're slow and every moving part can make them flaky. Between services, contract tests check that a provider still returns what its consumers rely on, without running both together.

#Code coverage with Istanbul

From my notes: "Test Coverage: Tools like Istanbul for ensuring code coverage." Coverage records which code ran while the tests ran. That's all. It finds code no test touches — useful — but it can't tell you whether anything was checked.

How it works: Istanbul (the tool family behind nyc and Jest's default coverage) instruments your code before it runs — it rewrites each file with counters, roughly cov.s[3]++ before every statement, cov.b[1][0]++ on each side of every branch, cov.f[0]++ at the top of every function. Tests run, counters go up, and the reporter turns zero-counts into red lines. The other approach, V8 coverage, asks the engine itself which byte ranges executed — no rewriting, faster, used by node --test and by Jest with coverageProvider: 'v8'.

There are four numbers:

  • Statements — how many statements executed at least once.
  • Lines — how many source lines with code executed. A line with two statements counts once, so lines and statements can disagree.
  • Functions — how many functions were called at least once.
  • Branches — how many alternatives were taken: both sides of each if, each arm of ? :, both sides of &&/||/??, each case. The most useful number, because it measures decisions tested.
src/pricing.js
// pricing.js
function getDiscount(user) {
  if (user.isPremium) return 20;
  return 0;
}

function shippingFee(order) {
  if (order.total >= 100) return 0;
  return order.express ? 15 : 5;
}

function formatPrice(cents) {
  return `$${(cents / 100).toFixed(2)}`;
}

module.exports = { getDiscount, shippingFee, formatPrice };
Advancedpricing.test.js: a partial test suite
pricing.test.js
// pricing.test.js
const { getDiscount, shippingFee } = require('./src/pricing');

test('premium users get 20', () => {
  expect(getDiscount({ isPremium: true })).toBe(20);
});

test('standard shipping under $100', () => {
  expect(shippingFee({ total: 40, express: false })).toBe(5);
});
Advancedjest.config.js: coverage settings
jest.config.js
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  collectCoverageFrom: ['src/**/*.js'],
  coverageThreshold: {
    global: { statements: 80, branches: 75, functions: 80, lines: 80 },
  },
};
AdvancedOutput: Istanbul report with an untested file

There's also src/legacy.js, a three-line file that no test imports. The real run:

Output
$ npx jest --coverage
PASS ./pricing.test.js
  ✓ premium users get 20 (1 ms)
  ✓ standard shipping under $100

------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------|---------|----------|---------|---------|-------------------
All files   |      50 |       50 |      50 |      50 |
 legacy.js  |       0 |      100 |       0 |       0 | 3-5
 pricing.js |    62.5 |       50 |   66.66 |   66.66 | 4,13
------------|---------|----------|---------|---------|-------------------
Jest: Coverage for statements (50%) does not meet "global" threshold (80%)
Jest: Coverage for branches (50%) does not meet "global" threshold (75%)
Jest: Coverage for lines (50%) does not meet "global" threshold (80%)
Jest: Coverage for functions (50%) does not meet "global" threshold (80%)
Tests:       2 passed, 2 total
$ echo $?
1
What’s happening
  1. Statements 62.5% (5 of 8). Istanbul counts 8 statements in pricing.js: the if and return 20 on line 3, return 0 on line 4, the if and return 0 on line 8, the return on line 9, the return on line 13, and module.exports on line 16. The tests executed 5: both on line 3, the if on line 8, line 9 and line 16.
  2. Lines 66.66% (4 of 6) — and here lines and statements disagree. Line 8 counts as covered because its if ran, even though return 0 on that same line never did. Only lines 4 and 13 are listed as uncovered. That's why a one-line if (x) return y; can look fully covered when half of it never ran.
  3. Branches 50% (3 of 6). Three decisions, two sides each: the line 3 if only took true, the line 8 if only took false, the ternary only took 5. Branch coverage is the number that tells you "you never tested a non-premium user, an order over $100, or express shipping".
  4. Functions 66.66% (2 of 3) — formatPrice was never called.
  5. legacy.js 0% shows up only because of collectCoverageFrom: ['src/**/*.js']. Without it, Jest reports just the files tests happened to load, so a completely untested file is invisible and the total looks better than it is. Its branch column says 100 because a file with no branches has 0 of 0 — read that as "n/a".
  6. The thresholds fail the run: all four totals are below their limits, Jest prints why, and the exit code is 1 even though both tests passed. That's how coverage gates CI.
AdvancedComparison: the V8 coverage provider

The same files with --coverageProvider=v8:

Output
------------|---------|----------|---------|---------|-------------------
File        | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------|---------|----------|---------|---------|-------------------
All files   |   52.38 |    33.33 |      50 |   52.38 |
 legacy.js  |       0 |        0 |       0 |       0 | 1-5
 pricing.js |   68.75 |       40 |   66.66 |   68.75 | 4-5,12-14
------------|---------|----------|---------|---------|-------------------
What’s happening
  1. Same code, same tests, different numbers: V8 reports 68.75% statements and 40% branches for pricing.js, where Istanbul said 62.5% and 50%.
  2. V8 works on byte ranges, not on parsed statements. Jest converts that to per-line data, so every line inside an unexecuted range counts — including comments and closing braces: 4-5 (the return 0 and the } after it) and 12-14 (all of formatPrice). pricing.js is 16 lines, 11 counted as covered → 68.75%.
  3. Branch counting also differs: V8 counts the ranges it actually tracked, so the totals don't line up with Istanbul's 6.
  4. Practical rule: pick one provider and keep it, because switching changes your numbers overnight without any code changing. V8 is faster and needs no transform; Babel/Istanbul gives more precise statement and branch counts. Jest's default is babel.
Advanced100% coverage with a bug

And the reason no one should chase 100%. This file has a bug, and 100% coverage:

math.test.js
// src/math.js:  function add(a, b) { return a - b; } // bug
const { add } = require('./src/math');

test('calls add', () => {
  add(2, 3); // runs every line, checks nothing
});
// Coverage: 100 | 100 | 100 | 100 — and the test passes.

test('adds the inputs', () => {
  expect(add(2, 3)).toBe(5);
});
// ✕ adds the inputs — Expected: 5, Received: -1
What’s happening
  1. The first test executes every statement, line and function in math.js, so the coverage report is 100% across the board, and the test passes — it has no assertion.
  2. The second test asserts on the result: add(2, 3) returns 2 - 3 = -1, not 5, and the test fails.
  3. Coverage didn't change between the two (it was already 100%). Only the assertion found the bug. Coverage measures what ran; correctness comes from checks.
AdvancedUsing coverage thresholds well
Which number moves?

For pricing.js above, you add one test: expect(getDiscount({ isPremium: false })).toBe(0). Using Istanbul's counts, what are the new statement, line, branch and function percentages for pricing.js?

Show answer

Statements 75% (6/8), lines 83.33% (5/6), branches 66.66% (4/6), functions 66.66% (2/3).

What’s happening
  1. The new test runs line 3's if again (already counted) and, for the first time, return 0 on line 4 — one new statement: 5 → 6 of 8.
  2. Line 4 becomes covered: 4 → 5 of 6 lines. Only line 13 (formatPrice) is left.
  3. The line 3 if now has taken both sides: branches 3 → 4 of 6.
  4. No new function was called — getDiscount was already counted — so functions stay at 2 of 3.

#Test impact analysis with SeaLights

From my notes: at Pluto TV we used SeaLights. SeaLights is a commercial "quality intelligence" platform; the idea it's built on is test impact analysis (TIA) — and that's what an interviewer will want you to explain.

The problem: a big service has thousands of tests and a full run takes 40 minutes. Most commits touch one area. TIA keeps a map from each test to the code it executes (collected by running tests with coverage agents), and when a change comes in, it uses the diff plus that map to answer two questions:

  • Which tests are relevant to this change? Run those first (or only those, for fast feedback).
  • Which changed lines no test executes? That's the risk report: new or modified code with no coverage, per pull request, regardless of the repo's overall percentage.
Output
Test A → user service
Test B → order service
Test C → order service, pricing
Test D → payment service

Change: pricing.js modified  →  run C (and anything else mapped to pricing)
Changed lines 40–52 in pricing.js executed by no test  →  flag in the PR
AdvancedTrying impact analysis locally with Jest

Because coverage agents can also run while QA, integration or E2E suites exercise a deployed build, this kind of tool can merge coverage across test stages — so "covered" can mean "some test somewhere ran it", not just unit tests. (That's the general capability of the category; what a particular installation collected depends on how it was set up.)

You can see the core idea locally with Jest, which builds a dependency graph from require/import statements. In the testing folder from earlier topics:

Output
$ npx jest --listTests
…/timing-sync.test.js
…/userService.test.js
…/asyncFunction.test.js
…/timing.test.js
…/weather.test.js
…/axios.test.js
…/broken.test.js

$ npx jest --listTests --findRelatedTests mailer.js
…/userService.test.js

$ npx jest --listTests --findRelatedTests timing.js
…/timing-sync.test.js
…/timing.test.js
What’s happening
  1. --listTests prints the test files Jest would run without running them: seven files.
  2. --findRelatedTests mailer.js walks the static import graph backwards: userService.test.js requires userService.js, which requires mailer.js, so that test file is related. None of the other six import it, directly or indirectly.
  3. For timing.js, the two timer test files are related. In a Git repository, jest --onlyChanged and jest --changedSince=main do the same thing using the changed files from Git — that's how watch mode re-runs only the tests that matter.
  4. The difference from a TIA platform: Jest's graph is static (who imports whom), so it can't see code reached dynamically, through config, or over HTTP in another service. A coverage-based map records what each test actually executed, which is more precise — but it's built from past runs, so it goes stale when code moves.
AdvancedLimits of test selection

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.