Skip to content

Top 34 Jest Interview Questions and Answers [2026]

November 14, 2026 28 min read
Jest interview guide by Second Talent
TL;DR: Jest interviews in 2026 test whether a candidate can isolate a unit with the right kind of mock, control time and promises without flaky waits, and explain why a suite passes on a laptop and fails in CI. Candidates should know Jest 30.5.2 (September 18, 2026), the Jest 30 breaking changes (removed matcher aliases, --testPathPatterns, jsdom 26), the ESM work that landed in 30.4 and 30.5, and where Vitest 5 is the better fit.

Jest 30.5.0 added mockFn.whenCalledWith(), so one mock can return different values per argument list without branching inside mockImplementation, and it made automocking and __mocks__ files work for ES modules on Node 24.9 and later.

The current release is Jest 30.5.2. The questions below are for senior JavaScript, TypeScript and Node.js interviews where the candidate will own a test suite, with four find-the-bug rounds and a Jest versus Vitest design question.

Key takeaways
  1. 1Jest 30 removed 11 matcher aliases, including toBeCalledWith and toThrowError, so old suites fail until they use the toHaveBeen... forms.
  2. 2In the Jest 30 launch benchmark, one large TypeScript app's server tests dropped from 7.8 GB to 1.8 GB peak memory and from about 1,350 to 850 seconds.
  3. 3Since Jest 30.4.0, CommonJS code can require() an ES module on Node 24.9 and later; older Node still throws ERR_REQUIRE_ESM.
  4. 4clearMocks, resetMocks and restoreMocks all default to false in Jest, while Vitest 5 turned clearMocks on by default.

Runner, Config and Isolation

1. How does Jest run a test suite, and what is isolated between files?

Jest finds test files with testMatch or testRegex, sorts them (previously failed and slow files first), and runs them across worker processes.

Each test file gets its own module registry and its own test environment instance, so a module imported in one file is a fresh copy in the next. Tests inside one file share that registry and run in order unless they use test.concurrent.

The configuration docs set maxWorkers to the number of cores minus one in a single run, and half the cores in watch mode.

A strong candidate adds what is not isolated: anything outside the module registry, such as a real database, files on disk, environment variables written by a global setup, and globals a test file forgets to clean up.

Jest 30 started warning about the last one (question 6).

2. Which config file formats does Jest 30 accept, and how do defineConfig and mergeConfig help?

Jest discovers jest.config.js, .ts, .mjs, .mts, .cjs, .cts and .json, or a "jest" key in package.json. Support for jest.config.mts arrived in 30.4.0.

Jest 30.3.0 added defineConfig and mergeConfig, which give type checking in a plain JavaScript config and a clean way to extend a shared base.

// jest.ci.config.js
const {defineConfig, mergeConfig} = require('jest');
const base = require('./jest.config');

module.exports = mergeConfig(
  base,
  defineConfig({
    collectCoverage: true,
    coverageReporters: ['text-summary', 'lcov'],
  }),
);

For TypeScript config files, the docs say Jest needs ts-node by default, or another loader named in a @jest-config-loader docblock (esbuild-register is the other supported one).

The 30.0 changelog adds that Jest skips the loader when Node runs with type stripping enabled. A weak answer says "Jest reads TypeScript natively" without knowing which of these paths a given machine takes.

3. What do the projects option and global-only options change in a monorepo?

projects lets one Jest process run several configurations, for example a Node project for the API and a jsdom project for the web app, with one combined report and one coverage run.

Each project has its own testEnvironment, transform and setupFiles.

Some options only make sense once per run and are ignored inside a project.

Jest 30.5.1 started warning about reporters, coverageReporters, workerIdleMemoryLimit, maxWorkers and coverageThreshold in a project config, where earlier versions dropped them without a word.

Jest 30.4.0 went the other way for verbose and silent, which can now be set per project. A candidate who has run a monorepo suite should know that a coverage threshold placed in one package's project config was never enforced.

4. Node or jsdom: how do you pick a test environment?

The default testEnvironment is node. Use jsdom only for code that touches the DOM, because it is slower to set up and hides missing browser APIs behind a partial implementation.

Since Jest 28 the jsdom environment is a separate package, jest-environment-jsdom, and in Jest 30 it depends on jsdom 26.

/**
 * @jest-environment jsdom
 */
test('renders the badge', () => {
  document.body.innerHTML = '<span id="badge"></span>';
  expect(document.getElementById('badge')).not.toBeNull();
});

A per-file docblock keeps the rest of the suite on node.

Senior candidates mention two limits. jsdom has no layout engine, so sizes and positions are zero, and the Jest 30 release post warns that the newer jsdom follows the spec more closely, which breaks old tricks such as reassigning window.location.

For real browser behavior, they move those tests to Playwright or a browser-mode runner.

5. A suite passes locally and times out in CI. What do you check?

Start with resources and concurrency, then open handles, then order dependence. CI machines usually have fewer cores and less memory, and Jest picks its worker count from the core count.

  • Workers and memory: set --maxWorkers to an explicit number or percentage, and use workerIdleMemoryLimit if workers grow until the container kills them. Since 30.0.5 a value of 0 restarts the worker after every test file.
  • Open handles: run with --detectOpenHandles to find servers, sockets and intervals left running. --forceExit hides the leak and is a smell in a PR.
  • Order dependence: Jest runs failed and slow files first on later runs, so the order differs between a warm laptop cache and a cold CI cache. --randomize with a fixed --seed shuffles tests within each file and reproduces the order.
  • Sharding: --shard=1/4 splits files across CI machines, which also changes which files share a worker.

A weak answer raises testTimeout (default 5,000 ms) and stops there.

6. What does globalsCleanup do, and why did Jest 30 add it?

It controls what happens to globals a test file sets and leaves behind after it finishes. The option lives in testEnvironmentOptions and has three values per the configuration docs:

  • 'soft' (default): the properties stay, but reading or writing one emits a JEST-01 deprecation warning.
  • 'on': the properties are deleted, so late readers get undefined.
  • 'off': no cleanup and no warning.

The point is memory. A global holding a large object keeps it alive for later test files in that worker, and memory that grows file by file is a common reason workers get killed in CI.

The docs add that the mode is set once per worker by the first environment it creates, so a per-file docblock only works if that file runs first. To keep a shared global alive on purpose, pass it to protectProperties from jest-util.

Mocking

7. When do you use jest.fn, jest.spyOn, jest.mock and jest.replaceProperty?

Each one replaces a different thing:

  • jest.fn() creates a standalone mock function, for callbacks and injected dependencies.
  • jest.spyOn(obj, 'method') wraps an existing method, records calls and can be restored. Pass 'get' or 'set' as a third argument to spy on an accessor.
  • jest.mock('module') replaces a whole module in the registry, with an automock or a factory.
  • jest.replaceProperty(obj, key, value) swaps a non-function property, such as a config flag, and restores it later. The property must already exist.

The design point a senior candidate makes is that dependency injection beats module mocking where it is available.

A function that receives its HTTP client needs a jest.fn() and nothing else, and the test does not depend on Jest's module registry, hoisting or ESM support.

8. What is the difference between mockClear, mockReset and mockRestore?

Per the mock function API:

  • mockClear() empties mock.calls, mock.instances, mock.contexts and mock.results. The implementation stays.
  • mockReset() does that and replaces the implementation with an empty function that returns undefined.
  • mockRestore() does that and puts back the original implementation. It only works for mocks created with jest.spyOn().

The config options clearMocks, resetMocks and restoreMocks call the matching jest.*AllMocks() before every test, and all three default to false. jest.restoreAllMocks() only restores spies and replaced properties, so a module mock from a jest.mock factory keeps its jest.fn() implementations.

Teams usually turn on restoreMocks or clearMocks in config so call counts cannot leak from one test into the next (question 28).

9. Why does jest.mock run before the imports, and what limits does that put on the factory?

With babel-jest, babel-plugin-jest-hoist moves jest.mock calls above the imports in the compiled CommonJS, so the module is mocked before anything requires it.

Because the factory runs before the file's own variables are initialized, the plugin rejects factories that reference out-of-scope variables, with one escape hatch: names that start with mock.

import {getUser} from './api';
import {loadProfile} from './profile';

const mockGetUser = jest.fn();          // allowed: name starts with "mock"

jest.mock('./api', () => ({
  ...jest.requireActual('./api'),       // keep the other exports real
  getUser: (...args) => mockGetUser(...args),
}));

test('loads the profile', async () => {
  mockGetUser.mockResolvedValue({id: 1, name: 'Ada'});
  await expect(loadProfile(1)).resolves.toMatchObject({name: 'Ada'});
});

The arrow wrapper delays reading mockGetUser until the first call, by which time the variable exists.

A strong candidate also knows that Jest 30 made jest.mock paths case-sensitive, so jest.mock('./API') no longer matches api.js, and that hoisting does not apply to native ESM (question 19).

10. Why does a spy on a module export sometimes see zero calls?

Because the code under test does not call the export. A function that calls a sibling inside the same module uses the local binding, so replacing the property on the exports object changes nothing for that internal call.

// pricing.js
export function taxFor(amount) { return amount * 0.08; }
export function total(amount) { return amount + taxFor(amount); }

// pricing.test.js
import * as pricing from './pricing';

test('uses taxFor', () => {
  const spy = jest.spyOn(pricing, 'taxFor');
  pricing.total(100);
  expect(spy).toHaveBeenCalled(); // fails: total() calls the local taxFor
});

Under native ESM the spy fails earlier, because a module namespace object is frozen and spyOn cannot redefine its properties.

Fixes, best first: test total by its output and leave taxFor alone; inject the tax function; or move taxFor to its own module and mock that module.

Rewriting production code to call exports.taxFor so a test can intercept it is the answer a reviewer should push back on.

11. How do you type mocks in TypeScript without casting?

Use jest.mocked() on the imported value, and the jest.Mocked, jest.MockedFunction and jest.Spied utility types for declarations. With @jest/globals imports, Jest 30 removed the old SpyInstance type in favor of jest.Spied.

import {jest, test, expect} from '@jest/globals';
import {fetchRates} from './rates';
import {convert} from './convert';

jest.mock('./rates');

test('converts with the fetched rate', async () => {
  jest.mocked(fetchRates).mockResolvedValue({EUR: 0.9});
  await expect(convert(100, 'EUR')).resolves.toBe(90);
});

Jest 30 also tightened the TypeScript types of the toHaveBeenCalledWith family, which is compile-time only but surfaces wrong argument shapes in existing tests during an upgrade.

A weak answer reaches for as jest.Mock everywhere, which turns off the checking these types exist for.

12. How does whenCalledWith change argument-dependent mocks?

Added in Jest 30.5.0, mockFn.whenCalledWith(...args) attaches a return value or implementation that fires only when the arguments match, including asymmetric matchers.

Before it, the same mock needed a mockImplementation with a switch on its arguments.

const getFlag = jest.fn().mockReturnValue(false);
getFlag.whenCalledWith('new-checkout').mockReturnValue(true);
getFlag.whenCalledWith(expect.stringMatching(/^beta-/)).mockReturnValue(true);

getFlag('new-checkout'); // true
getFlag('beta-search');  // true
getFlag('dark-mode');    // false, falls through to the base mock

The details from the docs that a candidate should know: arity is strict, so whenCalledWith('a') does not match fn('a', 'b'), and when a matched branch has no value left the call falls through to the base mock.

Vitest 5 shipped a similar vi.when API, which matters for teams keeping tests portable between the two (question 25).

13. What do the using keyword and jest.onGenerateMock add?

Jest 30 lets a spy be declared with using, so it is restored when the block ends even if an assertion throws. That removes a class of leaks where a spy on console or Date outlives its test because mockRestore sat after a failing line.

test('warns once', () => {
  using warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
  runMigration();
  expect(warn).toHaveBeenCalledTimes(1);
}); // console.warn is restored here

jest.onGenerateMock(callback) runs for each generated automock and can change it before it is returned, for example to give every mocked database client a connect that resolves.

It suits large codebases that rely on automocking; most teams with factories will not need it.

The using syntax needs a TypeScript or Babel setup that compiles explicit resource management, which a candidate should check before recommending it.

Async Code and Fake Timers

14. How should a test wait for a promise, and what goes wrong without await?

Return or await the promise, or await the .resolves or .rejects assertion.

If the test function returns before the assertion runs, Jest marks the test as passed and the assertion either never runs or fails later, attached to whichever test is running.

test('rejects an expired token', async () => {
  expect.assertions(1);
  await expect(verify('expired')).rejects.toThrow('token expired');
});

expect.assertions(n) is the guard for code with assertions inside catch blocks or callbacks, because it fails the test when fewer assertions ran than expected.

A senior candidate also knows the Jest 30 change to unhandled rejections: a promise that is rejected and caught later no longer fails the test, because Jest waits an extra event loop turn (waitForUnhandledRejections) before treating a rejection as unhandled.

15. How do Jest's fake timers work, and which APIs do they replace?

jest.useFakeTimers() installs @sinonjs/fake-timers (v13 in Jest 30), which replaces setTimeout, setInterval, setImmediate, Date, process.nextTick, queueMicrotask, requestAnimationFrame and related APIs with a clock that only moves when the test says so.

Per the Jest object docs, doNotFake defaults to [], so everything is faked, and timerLimit caps runAllTimers() at 100,000 recursive timers.

beforeEach(() => {
  jest.useFakeTimers({now: new Date('2026-11-09T09:00:00Z')});
});
afterEach(() => {
  jest.useRealTimers();
});

test('debounces saves', () => {
  const save = jest.fn();
  const debounced = debounce(save, 300);
  debounced(); debounced(); debounced();
  jest.advanceTimersByTime(299);
  expect(save).not.toHaveBeenCalled();
  jest.advanceTimersByTime(1);
  expect(save).toHaveBeenCalledTimes(1);
});

Native promises are not faked, which is the source of most fake-timer confusion (question 16).

Faking process.nextTick also breaks libraries that rely on it for I/O, so a candidate who has hit that adds doNotFake: ['nextTick'] rather than switching timers off.

16. Why does code with both promises and timers stall under fake timers, and how do the async APIs fix it?

A function that awaits something and then sets a timer has not scheduled the timer yet when the test calls jest.advanceTimersByTime(), because the await continuation is a pending microtask.

The sync API advances the clock, finds no timer and returns.

async function retry(fn, delayMs) {
  try {
    return await fn();
  } catch {
    await new Promise((r) => setTimeout(r, delayMs));
    return fn();
  }
}

test('retries after the delay', async () => {
  jest.useFakeTimers();
  const fn = jest.fn().mockRejectedValueOnce(new Error('503')).mockResolvedValue('ok');
  const result = retry(fn, 1000);
  await jest.advanceTimersByTimeAsync(1000);
  await expect(result).resolves.toBe('ok');
  expect(fn).toHaveBeenCalledTimes(2);
});

advanceTimersByTimeAsync, runAllTimersAsync and runOnlyPendingTimersAsync let scheduled promise callbacks run before each timer fires. They do not work with legacyFakeTimers.

A weak answer sprinkles await Promise.resolve() calls until the test passes, which breaks again when the code under test adds one more await.

17. What does setTimerTickMode add, and when do you use advanceTimers?

Jest 30.3.0 added jest.setTimerTickMode(), which switches how the fake clock moves during a test. The docs list three modes:

  • {mode: 'manual'}: time moves only on explicit calls such as advanceTimersByTime.
  • {mode: 'nextAsync'}: the clock keeps breaking the event loop and running the next timer until the mode changes.
  • {mode: 'interval', delta}: the same as advanceTimers: true, which moves time by 20 ms every 20 ms of real time unless a delta is given.

Auto-advancing modes suit integration-style tests where a library polls or retries and the test cannot know how many timers it will schedule. They cost determinism, because real time now affects the order of events.

A strong candidate keeps unit tests on manual control and reaches for nextAsync only when the timer count is out of the test's hands.

18. How do you test date-dependent code, including Temporal?

Fix the clock with jest.useFakeTimers({now}) or jest.setSystemTime() and assert against known dates.

Since Jest 30.4.0, both accept Temporal.Instant and Temporal.ZonedDateTime, and fake timers also fake Temporal.Now.*, so code that reads the current time through Temporal is controlled the same way as Date.now().

Two traps senior candidates mention. Time zone: set TZ for the test process (for example TZ=UTC in the test script) so a test written in New York does not fail on a CI runner in UTC around midnight.

And daylight saving: pick at least one fixed date on each side of a DST change for code that adds days or hours, since adding 24 hours and adding one calendar day differ on those dates.

ESM, TypeScript and Choosing a Runner

19. How do you run native ESM tests in Jest, and how is mocking different there?

Per the ESM docs, disable transforms with transform: {} (or configure the transformer to emit ESM) and run Node with --experimental-vm-modules. Jest still labels ESM support experimental.

The jest object comes from @jest/globals or import.meta.jest.

Static imports are evaluated before the test body, so hoisting cannot place jest.mock above them. Mock with jest.unstable_mockModule, whose factory is required and may be async, then load the code with a dynamic import():

import {jest, test, expect} from '@jest/globals';

jest.unstable_mockModule('node:child_process', () => ({
  execSync: jest.fn(() => 'main\n'),
}));

const {currentBranch} = await import('./git.js');

test('reads the branch', () => {
  expect(currentBranch()).toBe('main');
});

Jest 30.5.0 narrowed the gap: on Node 24.9 and later, automocking and __mocks__ files apply to ESM graphs that evaluate synchronously.

Graphs with top-level await, or projects with async-only resolvers or transformers, still need unstable_mockModule.

20. What changed for require() of ES modules in Jest 30.4?

On Node 24.9 and later, Jest 30.4.0 lets CommonJS code require() an ES module, mirroring Node's own require(esm), and evaluates ES modules without top-level await synchronously.

Before that, a CommonJS test that pulled in an ESM-only dependency failed with ERR_REQUIRE_ESM, and the usual fix was adding the package to transformIgnorePatterns exceptions so Babel would convert it.

Limits from the docs: a module whose graph contains top-level await throws ERR_REQUIRE_ASYNC_MODULE, and older Node still throws ERR_REQUIRE_ESM.

A candidate who knows this can explain why the same suite passes on a developer's Node 24 and fails on a CI image pinned to Node 22.

21. What do transform and transformIgnorePatterns do, and why do ESM-only packages break them?

transform maps file patterns to transformers such as babel-jest, ts-jest or an SWC transformer. transformIgnorePatterns lists paths that skip transformation, and its default ignores /node_modules/ and Yarn PnP files.

When a dependency ships only ESM and the suite runs as CommonJS, the untransformed import syntax fails with SyntaxError: Cannot use import statement outside a module.

// jest.config.js
module.exports = {
  transformIgnorePatterns: [
    '/node_modules/(?!(nanoid|uuid|@acme/ui)/)',
  ],
};

The negative lookahead transforms only the listed packages. With pnpm, packages live under node_modules/.pnpm/, so the pattern needs to account for that path.

Better options in 2026, when the Node version allows: require(esm) on Node 24.9+ (question 20) or native ESM mode. A weak answer removes node_modules from the ignore list entirely, which transforms every dependency and slows the cold run.

22. How do you run TypeScript tests in Jest, and what are the tradeoffs?

Three common paths:

  • babel-jest with @babel/preset-typescript: fast, strips types without checking them, and has the same limits as Babel on features such as const enum.
  • ts-jest: compiles with the TypeScript compiler and can report type errors in the test run, at a speed cost.
  • SWC or esbuild transformers: fastest transforms, no type checking.

Jest 30.5.2 added a fourth fallback: when no configured transformer claims a .ts, .mts or .cts file, Jest strips the types with Node. The default Babel transform still claims .ts files, so this applies to setups that override transform.

Jest 30 also requires TypeScript 5.4 or later for its types. The strong answer is that the test runner should not be the type checker: run tsc --noEmit as a separate CI step and keep the test transform fast.

23. How do you make a large Jest suite faster?

Measure first: --verbose with per-file times, or a reporter that writes durations, shows whether the cost is in setup, transforms or the tests. Then the usual levers:

  • Cut what each file imports. Barrel files make every test load a whole package; the Jest 30 post cites "up to 100x" gains from tools that rewrite barrel imports.
  • Use a faster transform (SWC or esbuild) and keep the transform cache warm in CI.
  • Right-size workers and shard across machines with --shard.
  • Move DOM-free tests to node from jsdom.
  • Run only what changed locally with --onlyChanged or --findRelatedTests.
  • Fix open handles and globals; Happo cut its test time from 14 to 9 minutes partly by cleaning up open handles during its Jest 30 upgrade.

24. How do you migrate a large suite from Jest 29 to Jest 30?

Upgrade Node and TypeScript first, then fix the mechanical breaks, then the behavior changes. The upgrade guide lists them:

  • Node 14, 16, 19 and 21 are dropped (the package engines are ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0), and TypeScript must be 5.4 or later.
  • Eleven matcher aliases are gone; the no-alias-methods rule in eslint-plugin-jest autofixes them.
  • --testPathPattern becomes --testPathPatterns, so CI scripts need editing.
  • jest.genMockFromModule is removed in favor of jest.createMockFromModule, and jest --init moved to npm init jest@latest.
  • Non-enumerable properties are excluded from toEqual and other object matchers.
  • Snapshots change format in places: cause appears in error snapshots, and ArrayBuffer and DataView print in readable form, so expect a snapshot update commit.
  • Packages are bundled into single files with "exports", so deep imports such as jest-runner/build/testWorker break.

A strong candidate separates the snapshot update into its own reviewed commit, so real regressions do not hide inside thousands of regenerated lines.

25. Jest or Vitest for a new project in 2026?

For a project built on Vite, or one that is ESM-first, Vitest is usually the simpler choice: it reuses the Vite config and plugin pipeline, runs ESM and TypeScript without a separate transform setup, and has a browser mode.

Vitest 5.0 (September 3, 2026) requires Node 22.12 or later and Vite 6.4 or later; the current release is 5.1.0 from November 2, 2026.

Jest still fits well where the stack is already built around it: React Native, large CommonJS codebases, custom Jest environments and reporters, or teams pinned to Node 20. The tradeoffs a senior candidate names:

  • Defaults differ. Vitest 5 clears mocks before each test by default; Jest does not. Vitest 5 also fails unawaited resolves and rejects assertions.
  • APIs are close but not identical. vi.fn mirrors jest.fn, vi.when resembles whenCalledWith, and module mocking hoists in both, but globals are opt-in in Vitest and snapshot headers differ.
  • Migration cost is mostly in module mocks, custom environments and snapshot churn, not in test bodies.

Our Vitest interview guide covers the other side of this question.

Snapshots and Coverage

26. When are snapshot tests useful, and how do you keep them from rotting?

Snapshots fit output that is large, stable and reviewed by a person: serialized ASTs, CLI output, generated SQL, error messages.

They are a poor fit for UI components that change weekly, where reviewers approve updates with -u without reading them. The snapshot docs recommend committing snapshots and treating them as code in review.

test('creates an order', () => {
  const order = createOrder({sku: 'A-1', qty: 2});
  expect(order).toMatchSnapshot({
    id: expect.any(String),
    createdAt: expect.any(Date),
  });
});

Property matchers keep generated IDs and timestamps out of the snapshot so it does not change on every run.

Habits that keep snapshots useful: small, focused snapshots or inline snapshots with toMatchInlineSnapshot, --ci in pipelines so missing snapshots fail instead of being written, and a check for obsolete snapshots.

Jest 30 also lets a class define a static SERIALIZABLE_PROPERTIES to choose which fields appear in snapshots and error messages.

27. babel or v8 coverage, and what does a coverage threshold enforce?

coverageProvider is babel by default, which instruments the code during the transform with Istanbul, or v8, which uses the coverage V8 collects while the code runs and maps it back to source.

V8 coverage skips the instrumentation step, so it is usually faster and covers code Babel never transforms; Babel instrumentation can be more precise for some branch constructs.

Jest 30.4.0 made babel-jest collect coverage from .mts and .cts files.

module.exports = {
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
  coverageThreshold: {
    global: {branches: 80, functions: 80, lines: 85},
    './src/billing/': {statements: -10},
  },
};

Positive threshold numbers are minimum percentages; negative numbers are the maximum count of uncovered items, so -10 allows at most 10 uncovered statements in the billing folder.

Without collectCoverageFrom, files no test imports never appear in the report, which inflates the number.

A senior candidate also says coverage shows what ran, not what was asserted, and uses thresholds as a floor against regressions rather than a goal.

Code Review Rounds

Each snippet below is a passing or flaky test, not a crashing one, which is what makes them hard to spot. Give the candidate five minutes per snippet and ask for the bug, why the runner did not catch it and the fix.

28. Find the bug: a mock that remembers.

import {sendEmail} from './mailer';
import {signup} from './signup';

jest.mock('./mailer');

test('sends a welcome email', async () => {
  await signup('ada@example.com');
  expect(sendEmail).toHaveBeenCalledTimes(1);
});

test('does not email a duplicate signup', async () => {
  await signup('ada@example.com');
  expect(sendEmail).toHaveBeenCalledTimes(1);
});

Bug: the second test depends on the first. Mock call history is shared across tests in a file and clearMocks defaults to false, so its assertion counts the call made in test one.

The test only passes if signup state carried over from test one makes the second call a duplicate. Run it alone with test.only and the signup is not a duplicate at all, so the duplicate path is never tested, yet the count of 1 still passes.

Fix: set clearMocks: true (or restoreMocks) in config, or call jest.clearAllMocks() in beforeEach, and make the second test create its own duplicate: call signup twice inside the test and assert one email.

A strong candidate adds that the signup store must be reset between tests too, since the mock is only half of the shared state.

29. Find the bug: an async loop.

test('every user has a valid plan', () => {
  const ids = [1, 2, 3];
  ids.forEach(async (id) => {
    const user = await fetchUser(id);
    expect(user.plan).toMatch(/^(free|pro|team)$/);
  });
});

Bug: forEach ignores the promises its callback returns, and the test function is synchronous, so Jest finishes the test before any fetchUser resolves. The test passes with zero assertions.

A failing assertion later surfaces as an unhandled rejection or is reported against a different test.

Fix: make the test async and wait for every check, with expect.assertions as a guard:

test('every user has a valid plan', async () => {
  const ids = [1, 2, 3];
  expect.assertions(ids.length);
  const users = await Promise.all(ids.map(fetchUser));
  for (const user of users) {
    expect(user.plan).toMatch(/^(free|pro|team)$/);
  }
});

For per-case failure messages, test.each(ids) is better still, and Jest 30's %$ placeholder puts the case number in each title. The require-await and jest/valid-expect lint rules catch the original pattern.

30. Find the bug: a polling test that hangs.

async function waitForJob(getStatus) {
  while ((await getStatus()) !== 'done') {
    await new Promise((r) => setTimeout(r, 500));
  }
}

test('waits for the job', async () => {
  jest.useFakeTimers();
  const getStatus = jest.fn()
    .mockResolvedValueOnce('running')
    .mockResolvedValueOnce('running')
    .mockResolvedValue('done');
  await waitForJob(getStatus);
  expect(getStatus).toHaveBeenCalledTimes(3);
});

Bug: with fake timers on, the first setTimeout never fires because nothing advances the clock, and the test awaits waitForJob directly.

The test hangs until the 5,000 ms default timeout and fails with a timeout error that says nothing about timers.

Fix: start the promise, advance the clock with the async API, then await it:

  const done = waitForJob(getStatus);
  await jest.advanceTimersByTimeAsync(1000);
  await done;
  expect(getStatus).toHaveBeenCalledTimes(3);

A candidate who suggests jest.runAllTimers() should be asked what happens: the sync version runs the one scheduled timer, but the next getStatus call has not resolved yet, so the second timer is not scheduled and the await still hangs. runAllTimersAsync works here; for open-ended polling, setTimerTickMode({mode: 'nextAsync'}) is the other option.

31. What does this print, and why?

const logger = {log: (m) => `real: ${m}`};

test('restore order', () => {
  const spy = jest.spyOn(logger, 'log').mockReturnValue('mocked');
  const fn = jest.fn(() => 'impl');

  jest.restoreAllMocks();

  console.log(logger.log('a'));
  console.log(fn());
  console.log(spy.mock.calls.length);
});

Answer: real: a, then impl, then 0. restoreAllMocks puts the original logger.log back, so the call goes to the real method and the spy records nothing.

It does not touch fn, because jest.fn() has no original to restore, so its implementation survives.

The follow-up is where weak answers fail: what about jest.resetAllMocks() instead?

Then logger.log stays a spy whose implementation is reset to return undefined, so the first line prints undefined; fn() also prints undefined; and the call count prints 1, because the reset cleared the history before the call.

Candidates who can trace this can debug the "mock works alone, fails in the suite" class of bug.

What Changed Recently

Jun 10, 2025
Jest 30.0.0 on npm: alias removals, --testPathPatterns, jsdom 26, using spies, expect.arrayOf
Oct 22, 2025
Vitest 4.0 ships
Mar 10, 2026
Jest 30.3.0 adds defineConfig, mergeConfig and setTimerTickMode
May 7, 2026
Jest 30.4.0 supports require(esm) on Node 24.9+, --collect-tests, Temporal in fake timers
Aug 28, 2026
Jest 30.5.0 adds whenCalledWith, ESM automocking and describe-level retries
Sep 3, 2026
Vitest 5.0 turns on clearMocks by default and adds vi.when
Sep 18, 2026
Jest 30.5.2 strips TypeScript types with Node when no transformer claims the file

32. Which Jest 30 features would you adopt first in an existing suite?

The ones that remove flakiness or boilerplate without a rewrite:

  • using spies for spies on globals such as console, so a failing assertion cannot leave them installed.
  • expect.arrayOf(matcher) to check every element, for example expect(ids).toEqual(expect.arrayOf(expect.any(String))).
  • test.each with %$ to number cases in titles, so a failure names the row.
  • jest.advanceTimersToNextFrame() for animation code that uses requestAnimationFrame.
  • jest.retryTimes(n, {waitBeforeRetry, retryImmediately}) for known-flaky integration tests while the root cause is fixed, never as a permanent setting.
  • Async setupFilesAfterEnv that can use top-level await, for setup that needs a server or a seeded database.

33. What did Jest 30.4 and 30.5 add for test discovery and retries?

Jest 30.4.0 added --collect-tests, which discovers and lists tests without running them, and a workerGracefulExitTimeout option for how long workers get to exit before Jest kills them.

Jest 30.5.0 made --collectTests expand test.each and describe.each cases and report counts per status.

The same release added describe-level retries with jest.retryTimes(n, {entireDescribe: true}), which reruns every test and hook in the block, for suites where tests share setup and cannot be retried one at a time.

Retries only work with the default jest-circus runner. A senior candidate uses test listing in CI to split shards by test count, and asks for a ticket number next to every retry.

34. What still does not work well in Jest's ESM support in late 2026?

Jest documents its ESM support as experimental, and the docs list divergences from Node that a candidate maintaining an ESM codebase should know:

  • jest.mock does not apply to ESM files; jest.unstable_mockModule is still the API, and its name has kept the unstable_ prefix through every 30.x release.
  • In graphs that mix ESM and CJS, CJS dependencies can run earlier relative to ESM siblings than they would in Node.
  • Writes to require.cache are ignored.
  • Importing JSON without with {type: 'json'} warns now and is slated to become an error in a future major.
  • Source phase imports for WebAssembly throw ERR_SOURCE_PHASE_NOT_DEFINED.

The practical answer is to keep the Node version at 24.9 or later in CI, so the synchronous ESM paths from 30.4 and 30.5 apply, and to consider Vitest for codebases where most tests mock ES modules.

Signs of a Strong Answer

  • They inject dependencies where they can and treat jest.mock as the fallback, and can say why a spy on a module export saw zero calls.
  • They name the difference between mockClear, mockReset and mockRestore, and set clearMocks or restoreMocks in config instead of trusting test order.
  • They reach for advanceTimersByTimeAsync when promises and timers mix, and never fix a hang by raising testTimeout.
  • They know which Node version CI runs and what that means for require(esm), ESM automocks and type stripping.
  • They use property matchers and small snapshots, run with --ci, and review snapshot diffs as code.
  • They can argue for Jest or Vitest on a specific project from its build tool, module format and Node version.

Hiring JavaScript Test Engineers

Engineers who can take a slow, flaky Jest suite and make it fast and trustworthy are hard to spot on a resume, since most JavaScript developers have written tests and few have owned a suite.

Second Talent helps US startups hire vetted AI-Native Engineers from Asia, including TypeScript developers, Node.js developers and React developers, screened with questions like these.

For dedicated testing roles, see the test automation engineer role guide and the cost to hire a QA automation engineer.

Rates run $23 to $39 USD an hour, billed monthly, with matches in 24 hours and a 90-day one-time replacement guarantee. For related loops, see our TypeScript and unit testing interview guides, or tell us what you need.

Get developers who ship with AI agents.

Starts at $23/Hour.

Start Hiring

$0 cost until you hire