Jest Interview Questions and Answers
Last updated:
Check out 45 of the most common Jest interview questions, then take an AI-powered practice interview
Q1What is Jest and why did it become the default JavaScript testing framework?
BasicFundamentals
Answer
Jest is a JavaScript testing framework built by Facebook (now Meta) in 2014, originally to test the React codebase, and open-sourced soon after. It became the de-facto standard for three reasons: (1) it's batteries-included, test runner, assertion library, mocking, code coverage (Istanbul), and snapshot testing all ship in one package, so you don't have to wire together Mocha + Chai + Sinon + Istanbul + Karma like the pre-Jest era required, (2) it runs tests in parallel across worker processes by default, which made the React monorepo's test suite (tens of thousands of tests) tractable to run in reasonable wall-clock time, (3) it has zero-config support for Babel/TypeScript/JSX out of the box, clone any React repo and `jest` Just Works. Before Jest, every JS project had a different testing setup; after Jest, the question became 'which Jest config do we use' instead of 'which testing libraries do we glue together.'
In 2026, every major React shop in India (Razorpay, Swiggy, CRED, Freshworks, Zomato, Cure.fit) uses Jest, or its modern challenger Vitest, as the unit-test runner. The framework is maintained by OpenJS Foundation since 2022 (Facebook transferred ownership to ensure long-term community stewardship).
Key Points
- Built at Facebook in 2014 for the React codebase
- Batteries-included: runner + assertions + mocks + coverage + snapshots
- Parallel test execution by default (one worker per CPU)
- Zero-config for Babel/TypeScript/JSX
Q2What's the difference between describe, test, and it in Jest?
BasicStructure
Answer
`describe` groups related tests into a block, it doesn't run anything itself, it just creates a logical scope (and a label in the test output). `test` and `it` are aliases for the same function, they declare an individual test case. `it` is preferred in BDD-style codebases because it reads naturally with the description (`it('should add two numbers')`), while `test` is more common in newer code. You can nest `describe` blocks, and `beforeEach`/`afterEach` hooks inside a block apply only to tests in that block and its descendants. A detail interviewers probe: Jest runs each file in two phases.
Every `describe` callback executes synchronously first, top to bottom, purely to build the test tree; only after that collection pass finishes does the runner execute the `test` bodies. That single fact explains two classic bugs. Code written directly inside a `describe` body (say `const user = buildUser()`) runs during collection, before any `beforeEach` and before any mock is configured, and the same object is then shared by every test in the block, so move it into `beforeEach`.
And a `describe` callback must be synchronous: marking it `async` or returning a promise makes Jest fail the file with 'Returning a Promise from describe is not supported'. The description strings concatenate into the full test name, which is exactly what `jest -t 'pattern'` matches and what `--verbose` prints, so naming blocks after the unit ('POST /orders') and tests after the behaviour ('rejects a negative amount') gives you names you can grep in CI logs. Related variants worth knowing: `describe.only`/`test.only` to focus, `.skip` to disable, `test.todo('handles refunds')` to register a name with no body, `test.each` for table-driven cases, `test.concurrent` to run async tests in one file simultaneously, and `test.failing` (Jest 29.6+), which passes only when the body fails and is useful for pinning a known bug without deleting the test.
describe('Calculator', () => {
describe('add()', () => {
test('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
});
});
Key Points
- describe = grouping, doesn't execute
- test and it are aliases
- Nested describes inherit hooks from parents
Q3What is the difference between toBe and toEqual? (the most-asked matcher question)
BasicMatchers
Answer
`toBe` uses `Object.is` (essentially `===` but with NaN === NaN being true), it checks reference equality for objects and arrays, and value equality for primitives. `toEqual` recursively walks the structure and compares values field by field, it's what you want for objects and arrays. Using `toBe` on objects is the #1 Jest mistake: `expect({a: 1}).toBe({a: 1})` ALWAYS fails because they're different references in memory. Use `toBe` only for primitives (numbers, strings, booleans, null, undefined).
For deep equality with `undefined`-vs-missing-key distinctions, use `toStrictEqual`, it also checks that both sides have the same constructor (a class instance vs a plain object are NOT strictly equal even if their fields match) and won't treat `{a: undefined}` as equal to `{}`. Interviewers ask this question constantly because it's the single most common source of cargo-culted Jest tests, junior devs reach for `toBe` because it's shorter, then are confused why their object-comparison tests fail. Two further details senior interviewers push on.
First, `toBe` is `Object.is`, not `===`: `expect(NaN).toBe(NaN)` passes (where `NaN === NaN` is false) and `expect(-0).toBe(0)` fails (where `-0 === 0` is true). Second, `toEqual` deliberately ignores properties whose value is `undefined`, ignores the prototype and constructor, and treats a sparse array hole as equal to `undefined`; `toStrictEqual` tightens all three. `toEqual` compares `Map`, `Set`, `Date` and typed arrays structurally and handles cyclic references, so it is safe on real API payloads. When the payload contains values you cannot predict, do not fall back to field-by-field assertions: use asymmetric matchers inside `toEqual`, for example `expect(order).toEqual({ id: expect.any(String), total: 499, createdAt: expect.any(Date) })`, or `toMatchObject` when you only care about a subset of keys.
The usual follow-up is 'your test compares a Mongoose document to a literal and fails, why': the document is a model instance, not a plain object, so `toStrictEqual` fails the constructor check and `toEqual` fails on the extra `_id` and `__v` keys. Convert with `.toObject()` or assert with `toMatchObject`.
// Primitives, use toBe
expect(2 + 2).toBe(4); // ✅
expect('hello').toBe('hello'); // ✅
// Objects/arrays, use toEqual
expect({a: 1, b: 2}).toBe({a: 1, b: 2}); // ❌ fails (different refs)
expect({a: 1, b: 2}).toEqual({a: 1, b: 2}); // ✅
expect([1, 2]).toEqual([1, 2]); // ✅
// Strict equality, for {a: undefined} vs {}
expect({a: undefined}).toEqual({}); // ✅ (lenient)
expect({a: undefined}).toStrictEqual({}); // ❌ fails
Q4What are the most common Jest matchers and when do you use each?
BasicMatchers
Answer
Beyond `toBe`/`toEqual`, the matchers you'll reach for daily: `toBeNull`/`toBeUndefined`/`toBeDefined` for null/undefined checks, `toBeTruthy`/`toBeFalsy` for boolean coercion, `toBeGreaterThan`/`toBeLessThan` for numeric comparisons, `toBeCloseTo(value, digits)` for floating-point (because `0.1 + 0.2 !== 0.3`), `toMatch(/regex/)` for string patterns, `toContain` for array/string includes, `toHaveLength(n)` for array/string length, `toThrow()` for asserting a function throws. For functions/mocks: `toHaveBeenCalled`, `toHaveBeenCalledTimes(n)`, `toHaveBeenCalledWith(args)`. Use `.not` to negate any matcher: `expect(x).not.toBe(5)`.
The traps hiding in that list are what actually get asked. `toBeTruthy`/`toBeFalsy` pass for `''`, `0` and `[]`, so they mask bugs where you meant an exact value; prefer `toBe(false)` over `toBeFalsy()`. `toContain` compares with SameValueZero (effectively `===`), so it never matches an object literal inside an array of objects, use `toContainEqual` for a deep comparison. `toHaveLength` just reads the `.length` property, which means it silently 'works' on a function (returning its arity) and fails on `Set`/`Map`, where you want `expect(set.size).toBe(3)`. `toThrow('boom')` is a substring match on the message, not an equality check, while `toThrow(new Error('boom'))` compares the message exactly and `toThrow(TypeError)` compares the constructor; a bare `toThrow()` passes on any throw, including the `TypeError: fn is not a function` you never intended to assert. `toBeCloseTo` defaults to 2 decimal digits, so `toBeCloseTo(0.3)` is a much weaker assertion than `toBeCloseTo(0.3, 10)`. For nested data reach for `toMatchObject` (partial deep match) and `toHaveProperty('user.address.city', 'Noida')`, which accepts a dot path. Finally, `.resolves` and `.rejects` turn any matcher into an async assertion: `await expect(fetchUser(1)).resolves.toMatchObject({ id: 1 })`.
expect(value).toBeNull();
expect(value).toBeTruthy();
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);
expect('hello world').toMatch(/world/);
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect(() => { throw new Error('boom'); }).toThrow('boom');
expect(mockFn).toHaveBeenCalledWith('expected-arg');
Q5How do beforeEach, afterEach, beforeAll, and afterAll work?
BasicSetup
Answer
These are lifecycle hooks that let you share setup and teardown. `beforeEach`/`afterEach` run before/after EVERY test in the current `describe` block (and its descendants). `beforeAll`/`afterAll` run ONCE for the whole block, before/after all tests. Use `beforeEach` for state that should be fresh per test (in-memory DB, mock reset, new instance of a class). Use `beforeAll` for expensive one-time setup (starting a server, connecting to a real DB).
Hooks nest: a parent's `beforeEach` runs before the child's `beforeEach`. The biggest mistake is sharing mutable state across tests via `beforeAll`, leads to flaky tests where order matters. Order is worth stating precisely: for a nested block Jest runs the outer `beforeAll`, then the inner `beforeAll`, then for each test the outer `beforeEach` followed by the inner `beforeEach`, and it unwinds `afterEach` inner first, then outer, then the `afterAll` hooks.
Hooks are registered during the collection phase, so a `beforeEach` written at the bottom of a file still applies to every test above it. Hooks are async-aware: return a promise or mark the hook `async` and Jest waits for it, but the default 5000 ms `testTimeout` applies to hooks too, so a slow container or DB startup fails with 'Exceeded timeout of 5000 ms for a hook', fixed with `jest.setTimeout(30000)` or the `testTimeout` config key rather than by shortening the setup. If a `beforeEach` throws, every test in that block is reported as failed, which makes a broken hook look like a broken feature.
Two production habits: put cleanup in `afterEach` rather than at the end of the test body, because a failed assertion aborts the body and leaks state into the next test; and remember Jest parallelises across files but not within one file, so a `beforeAll` that binds a fixed port or writes a fixed temp path collides across workers. Bind port 0 or namespace the path with `process.env.JEST_WORKER_ID`.
describe('UserService', () => {
let service;
beforeAll(async () => {
await db.connect(); // expensive, once
});
beforeEach(() => {
service = new UserService(); // fresh per test
jest.clearAllMocks(); // reset call history
});
afterEach(async () => {
await db.query('DELETE FROM users'); // clean state
});
afterAll(async () => {
await db.disconnect();
});
test('creates user', () => { /* ... */ });
});
Q6How do you test asynchronous code in Jest?
BasicAsync
Answer
Three patterns. (1) Return a promise from the test function, Jest waits for it: `test('fetches', () => fetchData().then(d => expect(d).toBe(...)))`. (2) Use async/await, cleanest and most common: `test('fetches', async () => { const d = await fetchData(); expect(d).toBe(...) })`. (3) For callback-style code, accept a `done` callback and call it when finished, though async/await has made this nearly obsolete in 2026. The most common bug is forgetting to `await` or `return`, without it, the test ends before the assertion runs and FALSE-PASSES (the test reports green even though the assertion never executed). A second pattern that helps: `expect.assertions(n)` at the top of an async test tells Jest 'this test MUST run exactly n assertions or it fails', catches the case where a promise rejection skips your assertion entirely.
For rejected promises, use `await expect(fn()).rejects.toThrow(...)`, the `rejects` modifier is critical because without it, the rejection bubbles up and the test fails with an uncaught error instead of a clean assertion failure. ESLint's `jest/valid-expect-in-promise` and `jest/no-conditional-expect` catch most of the missing-await bugs at lint time. The other failure mode is the opposite one: a promise that never settles.
Jest's default per-test budget is 5000 ms, so the test dies with 'Exceeded timeout of 5000 ms for a test. Add a timeout value to this test to increase the timeout, if this is a long-running test.' Raise it per test with a third argument (`test('slow', async () => {...}, 20000)`) only when the wait is genuinely real; if it is not, the usual causes are an unresolved mock (`mockResolvedValue` never configured, so the mock returns `undefined` and `await undefined` is fine but the assertion fails), fake timers swallowing the timer that would have resolved it, or an open handle such as a DB pool. `jest --detectOpenHandles` names the offender. Use `test.concurrent` when several independent async tests in a file each wait on IO and you want them overlapped, but never with shared mutable state.
// ✅ Async/await, preferred
test('fetches user', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Saksham');
});
// ✅ Returning the promise
test('fetches user', () => {
return fetchUser(1).then(user => {
expect(user.name).toBe('Saksham');
});
});
// ❌ DANGER, missing await, false pass
test('fetches user', async () => {
const user = fetchUser(1); // not awaited!
expect(user.name).toBe('Saksham'); // never runs, undefined.name throws after test ends
});
Q7What is jest.fn() and how do you use it?
BasicMocking
Answer
`jest.fn()` creates a mock function, a stand-in for a real function that records every call. You use it to (1) replace a function in code under test (a callback, an event handler, an injected dependency), (2) assert it was called with the right arguments, (3) control what it returns. The returned mock has properties like `.mock.calls` (array of every call's arguments, where `.mock.calls[0]` is the first call's args), `.mock.results` (array of return values or thrown errors), `.mock.instances` (for constructor calls), and methods like `.mockReturnValue(x)`, `.mockResolvedValue(x)` (for async functions), `.mockRejectedValue(err)` (for async failures), and `.mockImplementation(fn)` for full custom behavior.
The `Once` variants (`mockReturnValueOnce`, `mockResolvedValueOnce`) queue values consumed one per call, useful for testing retry logic where the first attempt fails but the second succeeds. Without arguments, `jest.fn()` returns `undefined` for every call, and `jest.fn(impl)` sets a default implementation up front. Details that separate a confident answer from a memorised one: `.mock.calls` stores references, not copies, so if the code under test mutates the object it passed in (a reducer that mutates state, an axios config that gets decorated by an interceptor), your later `toHaveBeenCalledWith` inspects the mutated value and either false-passes or false-fails; snapshot the argument with `structuredClone` inside a `mockImplementation` when this matters. `mockImplementationOnce` queues implementations that are consumed one per call and, once the queue empties, calls fall back to the base implementation, which is exactly how you model 'first attempt 429s, retry succeeds'. `mockClear` wipes `.mock.calls` only, `mockReset` also drops implementations and queued values, and `mockRestore` exists only on spies created by `jest.spyOn`.
In TypeScript, wrap the import with `jest.mocked(fetchUser)` (built in since Jest 27.4, replacing `ts-jest/utils`) to get `mockResolvedValue` typed against the real signature instead of casting to `any`. Give long-lived mocks a name with `.mockName('fetchUser')` so failure output reads 'fetchUser' instead of 'jest.fn()'. Jest 29 also added `.mock.lastCall`, which reads better than `.mock.calls[mock.mock.calls.length - 1]`.
test('callback fires', () => {
const callback = jest.fn();
[1, 2, 3].forEach(callback);
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenNthCalledWith(1, 1, 0, [1, 2, 3]);
expect(callback.mock.calls[1][0]).toBe(2); // second call, first arg
});
const api = jest.fn().mockResolvedValue({ id: 1, name: 'Razorpay' });
await api(); // resolves to {id: 1, name: 'Razorpay'}
Q8How does jest.mock() work and when do you use it?
BasicMocking
Answer
`jest.mock('./path-to-module')` replaces an entire module with an auto-generated mock, every exported function becomes a `jest.fn()` returning `undefined`. You use it to isolate the unit under test from its dependencies (database, network, file system). The call is hoisted to the top of the file by Jest's Babel transform, so it runs BEFORE the imports, that's why you can put it anywhere in the file and it still works.
To control what the mock returns, pass a factory: `jest.mock('./api', () => ({ fetchUser: jest.fn().mockResolvedValue({...}) }))`. The hoisting is done by `babel-plugin-jest-hoist`, and it is also the source of the most-hit error in Jest: 'The module factory of jest.mock() is not allowed to reference any out-of-scope variables'. Because the factory runs before your `const` declarations, only variables whose name starts with `mock` (plus globals) are allowed inside it, so `const mockFetch = jest.fn(); jest.mock('./api', () => ({ fetchUser: mockFetch }))` works while renaming it to `fetchStub` throws.
Mocking a module whose default export is a function needs the interop shape `jest.mock('./api', () => ({ __esModule: true, default: jest.fn() }))`, otherwise the import resolves to the namespace object. A factory is not always needed: dropping a file at `__mocks__/axios.js` next to `node_modules` auto-mocks that package for every test file (node modules are automatic; your own modules still need an explicit `jest.mock('./api')`), and `{ virtual: true }` lets you mock a path that does not exist on disk. When you need the mock to depend on runtime state, use `jest.doMock`, which is not hoisted, together with a `require` inside the test. Under native ESM none of this hoisting exists and you must use `jest.unstable_mockModule` plus a dynamic `await import()`.
import { fetchUser } from './api';
import { getUserName } from './userService';
jest.mock('./api'); // every export becomes a jest.fn()
test('getUserName uses api.fetchUser', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'Swiggy' });
const name = await getUserName(1);
expect(name).toBe('Swiggy');
expect(fetchUser).toHaveBeenCalledWith(1);
});
Q9What is jest.spyOn() and how is it different from jest.fn()?
BasicMocking
Answer
`jest.spyOn(object, 'methodName')` wraps an existing method so you can spy on its calls, but UNLIKE `jest.fn()` or `jest.mock()`, the original implementation is still called by default. This is useful when you want to verify a method was invoked but still let it run (e.g., verifying `console.error` was called during error handling). You can override the behavior with `.mockImplementation(fn)` and restore the original with `.mockRestore()`.
Always pair `spyOn` with `mockRestore` in `afterEach`, otherwise the spy leaks into other tests, and note that `clearMocks` and `resetMocks` do NOT undo a spy: only `mockRestore()` or `restoreMocks: true` puts the original method back. Mechanically, `spyOn` redefines the property on the object you hand it, so it only works when that property is writable or configurable. Spying on a named ES module export usually fails with 'TypeError: Cannot redefine property' or 'Cannot assign to read only property', because ESM bindings are read-only getters; the fixes are to spy on the object the method actually lives on (`jest.spyOn(UserService.prototype, 'save')`, `jest.spyOn(axios, 'get')`), to partially mock the module with `jest.requireActual`, or to restructure toward dependency injection.
Jest 29.3 added a third argument for accessors, `jest.spyOn(config, 'apiUrl', 'get')`, and Jest 29.4 added `jest.replaceProperty(obj, 'key', value)` for plain non-function properties with automatic restore. Practical uses that come up in interviews: asserting `console.error` was called during an error path while keeping the output silent with `.mockImplementation(() => {})`, freezing `Math.random` or `Date.now` for deterministic output, and verifying an analytics call fired without stubbing the whole analytics module. In jsdom, `window.location` is the notable exception: it is not configurable in older jsdom, so teams delete and redefine it rather than spy on it.
test('logs error on bad input', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
parseInput('garbage');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('invalid'));
errorSpy.mockRestore(); // always restore!
});
Q10How do you check that a function throws an error in Jest?
BasicMatchers
Answer
Wrap the call in a function and pass that to `expect`, then use `.toThrow()`. You can match by error message (string substring or regex), by error type, or just assert that any error was thrown. The critical detail: you must wrap in a function, `expect(throwingFn()).toThrow()` actually calls the function during expect and throws BEFORE the matcher runs, leading to test failure for the wrong reason.
For async functions, use `await expect(asyncFn()).rejects.toThrow(...)`. Know the four matching modes precisely, because they are not equivalent: a string argument is a SUBSTRING match on `error.message`, a regex is tested against the message, a class checks `instanceof`, and an error instance compares the message for exact equality (it does not compare the class or any custom fields). A bare `toThrow()` passes on literally any throw, which is how a test meant to assert 'rejects a negative amount' quietly starts passing because of a `TypeError: Cannot read properties of undefined`.
Asserting on a custom error class with extra fields (`err.statusCode`, `err.code === 'INSUFFICIENT_BALANCE'`) needs either `toThrow(AppError)` plus a second assertion inside a try/catch guarded by `expect.assertions(2)`, or `await expect(fn()).rejects.toMatchObject({ code: 'INSUFFICIENT_BALANCE' })`, which reads better. `.rejects` accepts either the promise or a function returning one. Forgetting `await` in front of `expect(...).rejects` is the standard trap: the assertion becomes a floating promise, the test passes, and Node later prints an unhandled rejection warning that fails an unrelated file. `toThrowErrorMatchingInlineSnapshot()` is useful when the message itself is the contract, for example a validation library's formatted output, since it keeps the expected text in the test file where reviewers see it change.
// Sync
expect(() => divide(1, 0)).toThrow();
expect(() => divide(1, 0)).toThrow('division by zero');
expect(() => divide(1, 0)).toThrow(/division/);
expect(() => divide(1, 0)).toThrow(RangeError);
// Async, note the await
test('rejects on bad input', async () => {
await expect(fetchUser(-1)).rejects.toThrow('invalid id');
});
Q11What is snapshot testing and how do you use it?
BasicSnapshots
Answer
Snapshot testing serializes a value (usually a React component's rendered tree, but also any object) and writes it to a `.snap` file on first run. On subsequent runs, Jest compares the new output to the snapshot, if they differ, the test fails with a colored diff showing what changed. You review the diff and either fix the code (if the change was a regression) or update the snapshot with `jest -u` (if the change was intentional).
Snapshots are great for catching unintentional UI changes, someone deleting a button, changing an error message, or breaking conditional rendering, but become a maintenance nightmare for large components. Every CSS class tweak fails twenty tests; developers learn to run `jest -u` reflexively without reading the diff; snapshots become rubber-stamped and provide zero value. Use small, focused snapshots that capture meaningful invariants (the formatted output of a date function, the error message text, the URL of a generated link); for big trees, prefer explicit assertions with role-based queries from RTL, or `toMatchInlineSnapshot()` so the expected value lives directly in the test file where it's visible in code review.
import { render } from '@testing-library/react';
import Button from './Button';
test('Button renders correctly', () => {
const { container } = render(<Button label='Save' />);
expect(container.firstChild).toMatchSnapshot();
});
// Inline, easier to review in PRs
test('formatPrice', () => {
expect(formatPrice(1234.5)).toMatchInlineSnapshot(`"₹1,234.50"`);
});
Q12How do you run Jest and what are common CLI flags?
BasicCLI
Answer
`jest` runs the whole suite. The flags you'll use daily: `jest --watch` (re-runs tests on file change, watches only files affected by changes since the last commit), `jest --watchAll` (watches everything), `jest path/to/file.test.js` (run a single file), `jest -t 'pattern'` (run only tests with names matching the pattern), `jest --coverage` (generates a coverage report via Istanbul), `jest -u` (updates snapshots), `jest --runInBand` (runs tests serially, useful in CI when parallelism causes flakiness or in low-resource environments), `jest --detectOpenHandles` (warns about leaked handles like un-closed timers/connections, useful when tests don't exit). The CI-specific flags matter just as much. `--ci` changes snapshot behaviour: instead of silently writing a missing `.snap` file and passing, Jest fails, which is the only thing stopping a forgotten snapshot from being auto-created on the CI machine. `--shard=2/4` (Jest 28+) splits the suite deterministically across four parallel CI jobs. `--maxWorkers=50%` accepts a percentage and is usually better than the default on a 2-core CI container, where Jest's default of cores minus one plus jsdom memory causes swapping. `--onlyChanged` and `--changedSince=origin/main` restrict the run to files touched by the diff, and `--findRelatedTests src/cart.ts` is what you wire into lint-staged for a pre-commit hook. `--bail=1` aborts on the first failure to save CI minutes, `--silent` suppresses `console.log` from the code under test, `--randomize` (Jest 29.6+) shuffles test order inside each file to surface order dependence, and `--listTests` prints what would run so you can verify a `testPathIgnorePatterns` change. When a transform change appears not to take effect, `jest --clearCache` clears the on-disk transform cache in the OS temp directory, and `jest --showConfig` prints the fully resolved config, which settles most 'my moduleNameMapper is being ignored' arguments in seconds.
# Run a single test by name
jest -t 'creates user'
# Run a single file
jest src/auth/login.test.ts
# Watch mode (only files affected by git diff)
jest --watch
# Coverage + threshold check
jest --coverage --coverageThreshold='{"global":{"lines":80}}'
# CI mode
jest --ci --runInBand --reporters=default --reporters=jest-junit
Q13How do you focus or skip tests with test.only, test.skip and test.todo?
BasicStructure
Answer
`test.only` (alias `fit`) and `describe.only` (alias `fdescribe`) mark a test or block as the only one that should run. The detail almost everyone gets wrong in an interview: `.only` is scoped to the FILE it appears in. Jest still loads and runs every other test file in the suite, it just skips the non-focused tests inside that one file.
To narrow the run across files you need the CLI: a path argument (`jest src/cart`) or `-t 'coupon'` to filter by test name. `test.skip` (aliases `xit`, `xtest`, and `xdescribe` for blocks) keeps the test registered so it appears in the summary as skipped, which is strictly better than commenting it out, because a commented test disappears from review and from the count. `test.todo('rejects an expired coupon')` takes a name and NO callback (passing one throws 'Todo must be called with only a description'), and is the right way to record a case you have identified but not written. `test.failing` (Jest 29.6+) inverts the result: it passes while the body fails, so you can commit a reproduction for a known bug and have CI tell you the day someone fixes it. The production risk is a committed `.only`, which turns a whole file green while testing one case. Guard it in CI with eslint-plugin-jest's `jest/no-focused-tests` set to error, and `jest/no-disabled-tests` as a warning so skips stay visible.
// Focus one test WITHIN this file; other files still run
test.only('applies the ₹500 coupon', () => { /* ... */ });
// Registered and reported as skipped, not silently deleted
test.skip('handles partial refunds', () => { /* ... */ });
xit('same thing, older alias', () => { /* ... */ });
// Name only, no callback: shows up as 'todo' in the summary
test.todo('rejects an expired coupon');
// Passes ONLY while the body fails (Jest 29.6+)
test.failing('known bug: rounds 0.005 down', () => {
expect(round(0.005)).toBe(0.01);
});
// Narrowing across FILES belongs on the CLI, not in the source
// jest src/cart --testNamePattern='coupon'
Q14How does Jest decide which files are test files (testMatch, roots, testPathIgnorePatterns)?
BasicConfiguration
Answer
By default Jest picks up two patterns: any `.js`/`.ts`/`.jsx`/`.tsx` file inside a `__tests__` directory, and any file ending in `.test.` or `.spec.` with those extensions. That default lives in `testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)']`, which uses glob syntax. `testRegex` is the regex-based alternative, and setting both fails immediately with 'Configuration options testMatch and testRegex cannot be used together'. `roots` limits where Jest scans (setting `roots: ['<rootDir>/src']` stops it from crawling `dist` and `coverage`), and `testPathIgnorePatterns`, which defaults to `['/node_modules/']`, is a list of REGEX strings matched against the full absolute path, not globs. That last point causes real confusion: patterns are unanchored, so `'/e2e/'` also excludes `/src/e2e-helpers/`, and because it matches the absolute path, a checkout directory whose name contains the pattern silently excludes the whole project.
The error you will actually hit is 'Your test suite must contain at least one test', which means a file matched the pattern but declared no `test`, typically a shared `__tests__/helpers.ts` or a fixture file; move it out of `__tests__` or rename it. Verify any change with `jest --listTests`, which prints the resolved file list without running anything, and `jest --showConfig`, which prints the patterns Jest actually merged from your config, the CLI and any preset.
// jest.config.js
module.exports = {
rootDir: '.',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
// regex strings, matched against the absolute path
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/playwright/'],
};
// Confirm what will run before you trust it
// npx jest --listTests
// npx jest --showConfig
Q15How do you write table-driven tests with test.each and describe.each?
BasicStructure
Answer
`test.each` runs the same body once per row of data and reports each row as its own test, so a failure names the exact input instead of hiding it inside a loop with one shared assertion. Three input formats. An array of arrays spreads each row into the callback arguments and the title uses printf placeholders: `%s` string, `%i` integer, `%f` float, `%o` object, `%j` JSON, `%p` pretty-format, `%#` the row index, and `%%` a literal percent.
An array of objects passes one object and lets the title interpolate fields by name with `$plan` or `$order.total`, which reads far better in CI output. The tagged-template form takes a table with a header row and `${value}` cells, and is the most readable option for a truth table. `describe.each` does the same for a whole block, which is how you run one suite of tests against several implementations or several feature-flag states. The mistakes interviewers watch for: a mismatch between placeholders and arguments produces 'Not enough arguments supplied for given title'; the callback is `async`-friendly, so `test.each([...])('...', async (input) => ...)` works and Jest awaits each row; and you still get one shared `beforeEach` per row, so mutable fixtures must be rebuilt inside it. Combine with `.only` and `.skip` (`test.only.each`) while debugging one row.
// Rows as arrays, printf placeholders in the title
test.each([
[499, 18, 89.82],
[1000, 5, 50],
[0, 18, 0],
])('gstFor(%i, %i%%) is %f', (amount, rate, expected) => {
expect(gstFor(amount, rate)).toBeCloseTo(expected, 2);
});
// Rows as objects, $field interpolation
test.each([
{ plan: 'gold', months: 3, price: 2997 },
{ plan: 'gold', months: 12, price: 9999 },
])('$plan for $months months costs $price', ({ plan, months, price }) => {
expect(priceOf(plan, months)).toBe(price);
});
// Tagged template table
test.each`
input | expected
${'9876543210'} | ${true}
${'12345'} | ${false}
`('isValidPhone($input) is $expected', ({ input, expected }) => {
expect(isValidPhone(input)).toBe(expected);
});
Q16How do you mock a specific function while keeping the rest of the module real?
IntermediateMocking
Answer
Three approaches depending on the situation. (1) Use `jest.mock('./module', () => ({ ...jest.requireActual('./module'), specificFn: jest.fn() }))`, this is the cleanest pattern, uses `requireActual` to get the real module and overrides only what you need. (2) Use `jest.spyOn(module, 'specificFn')` and call `.mockImplementation(fn)`, works for objects but not for ES module imports because import bindings are read-only. (3) For ES modules with named exports, the `requireActual` pattern is the only reliable way. Test isolation matters here: always `jest.restoreAllMocks()` in `afterEach` so the partial mock doesn't bleed into other tests. Two mechanics decide whether a partial mock actually takes effect.
First, spreading the real module (`...jest.requireActual('./userApi')`) copies property VALUES, so any export defined as a getter is evaluated once at spread time and loses its laziness; for those, mock the specific binding rather than rebuilding the namespace. Second, and this is the one that wastes afternoons: if the module under test destructures at import time in compiled CommonJS (`const { fetchUser } = require('./userApi')`), it captured the original function reference before your `mockImplementation` ran, so the override appears to do nothing. Calling through the namespace (`api.fetchUser(...)`) keeps the indirection and makes the mock visible, which is why teams that mock heavily prefer namespace imports or plain dependency injection.
In TypeScript, `jest.mocked(api.fetchUser).mockResolvedValue(...)` keeps the return type honest; a mismatched shape here is a common source of tests that pass while production breaks. Reach for a partial mock when a module mixes IO with pure helpers, for example an API client that also exports a formatter. If you find yourself partially mocking the same module in ten files, that is the interviewer's cue to ask whether the module should be split, and the correct answer is yes.
// Partial mock, replace fetchUser, keep formatUser real
jest.mock('./userApi', () => {
const actual = jest.requireActual('./userApi');
return {
...actual,
fetchUser: jest.fn(),
};
});
import { fetchUser, formatUser } from './userApi';
test('displays user', async () => {
fetchUser.mockResolvedValue({ id: 1, name: 'cred' });
// formatUser is the REAL implementation
const display = formatUser(await fetchUser(1));
expect(display).toBe('CRED');
});
Q17How do you test code that uses setTimeout or setInterval?
IntermediateTimers
Answer
Use Jest's fake timers. Call `jest.useFakeTimers()` (defaults to 'modern' in Jest 27+, which mocks `Date`, `performance.now`, `queueMicrotask`, `requestAnimationFrame`, etc., not just timers). Then use `jest.advanceTimersByTime(ms)` to move clock forward, `jest.runAllTimers()` to run every pending timer, or `jest.runOnlyPendingTimers()` to run only the ones already scheduled.
Call `jest.useRealTimers()` in `afterEach` (or globally in setup) to restore. The biggest trap: fake timers DON'T automatically work with async/await because microtasks (Promise resolution) are separate. Use `jest.advanceTimersByTimeAsync()` (Jest 29+) or manually flush promises with `await Promise.resolve()` after advancing.
A few more controls worth naming. `jest.useFakeTimers({ now: new Date('2026-04-01'), doNotFake: ['performance', 'queueMicrotask'] })` pins the clock and lets you exempt specific APIs, which is the standard fix when faking `queueMicrotask` or `process.nextTick` makes an otherwise fine `await` chain stall. `jest.getTimerCount()` tells you how many timers are still pending, useful for asserting that a component cleaned up its interval on unmount. `jest.advanceTimersToNextTimer()` steps one timer at a time when you do not want to reason about exact milliseconds. Be careful with `jest.runAllTimers()` on code that reschedules itself, such as a polling `setInterval` or an exponential-backoff retry: it aborts with 'Aborting after running 100000 timers, assuming an infinite loop' and you want `runOnlyPendingTimers()` instead. Two ordering rules cause most confusion.
Call `jest.useFakeTimers()` before the module under test schedules anything, since a module that captured `setTimeout` into a local at import time keeps the real one. And with React Testing Library v14+, `userEvent` uses timers internally, so you must pass `userEvent.setup({ advanceTimers: jest.advanceTimersByTime })`, otherwise clicks hang forever under fake timers, which is one of the most-reported 'my test just times out' issues.
describe('debouncedSearch', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
test('fires after 300ms', () => {
const callback = jest.fn();
const debounced = debounce(callback, 300);
debounced('a'); debounced('ab'); debounced('abc');
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('abc');
});
});
Q18What's the difference between 'legacy' and 'modern' fake timers?
IntermediateTimers
Answer
Pre-Jest 27, fake timers only intercepted `setTimeout`/`setInterval`/`setImmediate`, `Date.now()` and `performance.now()` kept ticking with real time. 'Modern' fake timers (now the default since Jest 27, internally powered by `@sinonjs/fake-timers`) ALSO mock `Date`, `performance`, `queueMicrotask`, `requestAnimationFrame`, `requestIdleCallback`, `process.nextTick`. This matters because code that does `const now = Date.now(); setTimeout(() => doWork(), 1000)` only works correctly with modern fake timers, legacy mocks `setTimeout` but `Date.now()` returns wall-clock time, so the test logic gets confused.
In 2026, you'll only see legacy timers in old codebases that haven't migrated. Use `jest.useFakeTimers({legacyFakeTimers: true})` only if you have an explicit reason. Implementation detail that answers the follow-up 'how does it actually work': modern timers install `@sinonjs/fake-timers` onto `globalThis`, replacing the real functions for the duration of the test and restoring them on `jest.useRealTimers()`.
Because the replacement is that broad, it can be too broad. Faking `performance.now` breaks libraries that measure elapsed time (some animation and instrumentation code divides by a zero delta), and faking `queueMicrotask` or `process.nextTick` can stall promise chains that Jest itself relies on. That is why the config accepts `doNotFake`, for example `jest.useFakeTimers({ doNotFake: ['performance', 'nextTick'] })`, an option modern timers support and legacy timers do not.
Modern timers also give you `jest.setSystemTime(date)` mid-test, so you can simulate a token expiring between two calls without touching the code under test. The other practical difference is `requestAnimationFrame`: under modern timers it is faked and advances at roughly 16 ms per frame when you advance the clock, so a component driven by rAF can be stepped deterministically. Migration advice for an old suite: flip to modern globally with `timers: 'modern'` or `fakeTimers: { enableGlobally: true }` in `jest.config.js`, then fix the handful of tests that were secretly relying on real `Date.now()` while their timers were faked.
// Modern (default in Jest 27+)
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01'));
expect(Date.now()).toBe(1767225600000); // works
// Legacy, Date is NOT mocked
jest.useFakeTimers({legacyFakeTimers: true});
expect(Date.now()).toBe(1767225600000); // FAILS, returns real wall-clock time
Q19How do you test a React component with Jest and React Testing Library?
IntermediateReact Testing
Answer
React Testing Library (RTL) is the de-facto standard companion to Jest for React testing, it ships with Create React App, Next.js, and Vite templates. Its philosophy: query the DOM the way a user would (by role, label, text), not by implementation details (class names, refs). The core API: `render(<Component />)` mounts the component, `screen.getByRole`/`getByLabelText`/`getByText` find elements, `userEvent` (from `@testing-library/user-event`) simulates real user interactions (click, type, keyboard).
For async UI changes, use `findBy*` (returns a promise, retries until found) or `waitFor(() => expect(...))` for arbitrary conditions. In 2026, prefer `@testing-library/user-event` v14+, it returns promises and simulates events more realistically than the older `fireEvent`. Interviewers usually ask you to justify the query you picked, so know the priority ladder: `getByRole` (with the accessible `name` option) first, then `getByLabelText` for form fields, then `getByPlaceholderText`, `getByText`, `getByDisplayValue`, and `getByTestId` only as an escape hatch, because a test that queries by role fails when the component stops being accessible, which is a bug you want to hear about.
Know the three prefixes too: `getBy*` throws immediately if nothing matches, `queryBy*` returns `null` and is the only correct choice for asserting absence, and `findBy*` returns a promise that retries. The `render` result gives you `rerender` for prop changes, `unmount` for cleanup assertions, and a `wrapper` option for wrapping every render in `QueryClientProvider` or a theme provider, which is normally hoisted into a shared `renderWithProviders` helper. Add `@testing-library/jest-dom` in `setupFilesAfterEnv` so `toBeInTheDocument` and friends exist. Two failure messages you will meet: 'Unable to find an accessible element with the role button', usually because the element is a `div` with an onClick and needs a real `button`, and 'Found multiple elements', fixed with the `name` option rather than by switching to a test id. `screen.debug()` prints the current DOM when a query fails.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('shows error on empty submit', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});
Q20How do you test asynchronous UI updates in React Testing Library?
IntermediateReact Testing
Answer
Three tools, in order of preference: (1) `findBy*` queries, return a promise that resolves when the element appears (default timeout 1000ms). Use these when waiting for a specific element. (2) `waitFor(callback)`, repeatedly runs the callback until it stops throwing. Use for arbitrary conditions like 'this assertion passes' or 'this mock has been called'. (3) `waitForElementToBeRemoved`, for waiting on disappearance (loading spinners).
NEVER use `await new Promise(r => setTimeout(r, 500))` to 'wait', it's flaky and slow. Common mistake: putting `expect` inside `waitFor` but with multiple unrelated assertions, waitFor will retry ALL of them, masking real failures. Keep waitFor callbacks small and focused.
It helps to know how `waitFor` works: it re-runs the callback on a 50 ms interval and on every DOM mutation (via `MutationObserver`) until it stops throwing or the 1000 ms timeout expires, then rethrows the last error. `findByX` is literally `waitFor` wrapped around `getByX`, which is why `findBy` gives you a much better failure message than a hand-rolled poll. Three rules follow from the mechanism. Never put a side effect such as `userEvent.click` or a `fetch` call inside `waitFor`, because it will be executed repeatedly.
Never make your first assertion a negative one: `await waitFor(() => expect(screen.queryByText('Saved')).toBeNull())` passes instantly before the component has even started rendering, so anchor on something that appears and then assert absence synchronously. And under fake timers, `waitFor` cannot advance the clock by itself, so pass `advanceTimers` to `userEvent.setup()` or wrap advances in `act`. Global defaults live in `configure({ asyncUtilTimeout: 5000 })` from `@testing-library/dom`, which is better than sprinkling `{ timeout: 5000 }` at call sites. Finally, an 'update was not wrapped in act(...)' warning during an async test almost always means a state update landed after the assertions ran, which is the shape of a test that passes locally and flakes in CI.
test('loads and displays users', async () => {
render(<UserList />);
// ✅ findBy, waits for the element to appear
expect(await screen.findByText('Saksham')).toBeInTheDocument();
// ✅ waitFor for arbitrary conditions
await waitFor(() => expect(api.fetchUsers).toHaveBeenCalledTimes(1));
// ✅ waitForElementToBeRemoved for disappearance
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
});
Q21What is mock pollution and how do you avoid it?
IntermediateMocking
Answer
Mock pollution = a mock set up in one test leaks call history or implementations into the next test, causing tests to pass/fail depending on order. The classic example: `jest.fn().mockResolvedValueOnce(A)` queues a value that another test consumes if the first test didn't use it. Three defenses, all important: (1) `jest.clearAllMocks()` in `beforeEach`, resets call history (`.mock.calls`, `.mock.results`) but keeps implementations. (2) `jest.resetAllMocks()`, also removes any `mockImplementation` / `mockReturnValue`, returning mocks to no-op state. (3) `jest.restoreAllMocks()`, undoes `jest.spyOn` calls, restoring originals.
The standard config: set `clearMocks: true` in `jest.config.js` so every test starts with clean call history. Add `restoreMocks: true` if you use `spyOn` heavily. Worth being precise about scope, because interviewers use this to test your mental model: each test FILE gets its own module registry and its own worker sandbox, so mocks cannot leak from one file to another.
If a test passes alone but fails in the suite, the shared thing is inside the file (a module-level `let`, a queued `mockResolvedValueOnce` nobody consumed, an unrestored spy) or genuinely external (a real database row, a temp file, `process.env`, something written to `globalThis`). Those external cases survive even `--runInBand`, which is a useful diagnostic split: reorder-only failures point at in-file pollution, while everything-fails points at shared external state. `jest --randomize` (Jest 29.6+) shuffles test order within each file so this class of bug surfaces in CI rather than three months later. Two more traps: `restoreAllMocks` does not undo a `jest.mock()` factory, only `spyOn`, so a module mocked for the file stays mocked; and `mockResolvedValueOnce` queues are the single most common cause of 'the third test in the file gets the second test's data'. If a mock needs different behaviour per test, set it in the test rather than in `beforeEach`, and let `clearMocks` handle the reset.
// jest.config.js, set-and-forget defaults
module.exports = {
clearMocks: true, // clear call history before each test
restoreMocks: true, // restore spyOn'd originals
resetModules: false, // typically false, only true for module-level state isolation
};
Q22How does Jest's module resolution and resetModules work?
IntermediateModules
Answer
Jest caches each `require`d module (just like Node), so importing the same module twice in a test file returns the same instance. This becomes a problem when modules have top-level state (e.g., a config that reads `process.env` once at import time): you can't change `process.env` mid-test and re-import to see the new value. `jest.resetModules()` clears the registry, so the next `require` re-executes the module. Combine with `jest.isolateModules(() => { ... })` to scope the reset.
This is essential for testing config-loading code, singleton patterns, or anything that captures environment state at import. The registry is per test file, created fresh by the worker, which is why a singleton can never leak across files but reliably leaks across tests inside one file. A practical constraint: static `import` statements are hoisted and evaluated once before any test runs, so re-importing inside `isolateModules` only works with `require()` (or, under native ESM, `await import()` inside `jest.isolateModulesAsync`, added in Jest 29.5).
Reach for this pattern when testing a config module that snapshots `process.env` at load, a feature-flag client that memoises its first response, a rate limiter holding an in-memory counter, or any `export const client = new SomeSDK(...)` that must be constructed twice with different inputs. Prefer the scoped helpers over the global `resetModules: true` config flag, because resetting the registry globally also drops the mock registry and makes hoisted `jest.mock()` factories behave in surprising ways; `jest.isolateModules` keeps the blast radius to a few lines. There is also a memory angle worth mentioning: the registry retains every module a file loads, so a file that isolates and re-requires a heavy dependency dozens of times is a real contributor to worker heap growth in CI. If you need the isolation everywhere in a file, consider splitting it into two smaller test files instead.
// config.js reads process.env at import time
test('uses test API URL', () => {
jest.isolateModules(() => {
process.env.API_URL = 'http://test.local';
const config = require('./config');
expect(config.apiUrl).toBe('http://test.local');
});
});
test('uses prod API URL', () => {
jest.isolateModules(() => {
process.env.API_URL = 'https://api.goodspace.ai';
const config = require('./config');
expect(config.apiUrl).toBe('https://api.goodspace.ai');
});
});
Q23How do you enforce code coverage thresholds in Jest?
IntermediateCoverage
Answer
Run `jest --coverage` and Jest instruments your code via Istanbul to track which lines/branches/functions/statements were executed. Output goes to `coverage/` as HTML (open in a browser), `lcov` (for SonarQube/Codecov), and `text-summary` in stdout. To enforce thresholds, add `coverageThreshold` to `jest.config.js`, Jest fails the run if coverage drops below the threshold.
You can set thresholds globally, per-file glob, or even at the directory level. Common 2026 baseline in Indian startups: 70-80% line coverage globally, 90% for critical paths (auth, payments, billing). DON'T chase 100%, testing every getter/setter is a treadmill that produces low-value tests.
The detail that separates people who have actually run this in CI: without `collectCoverageFrom`, Jest only reports files that some test imported, so a module with zero tests is invisible and your global number is measured against a shrinking denominator. Adding a glob that includes untested files typically drops a reported 85% to something honest in the sixties, and that is the number to threshold. The other choice is `coverageProvider`, which is `'babel'` (Istanbul instrumentation, accurate branch mapping, slower) or `'v8'` (V8's built-in coverage, much faster and the practical option with `@swc/jest`, ESM, or a large suite, at the cost of occasionally odd branch attribution on transpiled code).
Know the four metrics well enough to argue about them: statements and lines are nearly the same thing and are easy to inflate, functions counts entry points, and branches is the one that actually correlates with bugs, because it forces the `else`, the `??`, and the early return to be exercised. Set branches lower than lines only if you plan to raise it. `/* istanbul ignore next */` above a defensive `default:` case is legitimate; scattering it to hit a threshold is how coverage gates lose credibility. A ratchet in CI, where the threshold rises with the current number and never falls, works better than a fixed target.
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.{js,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
],
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
'./src/auth/': {
statements: 95, // higher bar for auth
lines: 95,
},
},
};
Q24What's the difference between toBeInTheDocument, toBeVisible, and toBeInTheViewport?
IntermediateReact Testing
Answer
These are from `@testing-library/jest-dom`, an extension that adds DOM-specific matchers (installed separately as `@testing-library/jest-dom`, and imported in a Jest setup file via `import '@testing-library/jest-dom'`). `toBeInTheDocument()` checks the element is attached to the document (it's in the DOM tree), doesn't care about visibility. `toBeVisible()` checks it's in the DOM AND has no `display: none`, no `visibility: hidden`, no `opacity: 0`, no `hidden` attribute, and all parents pass these checks too. `toBeInTheViewport()` does NOT exist as a built-in matcher, jsdom (the headless DOM that Jest uses) doesn't implement CSS layout, so you can't check actual viewport position or element bounding boxes in Jest. If you need viewport-aware tests (scroll behavior, sticky headers, intersection observers actually firing), you need Playwright or Cypress, both of which run a real browser. Other useful jest-dom matchers: `toHaveTextContent`, `toHaveValue` (for form inputs), `toHaveAttribute`, `toBeDisabled`, `toBeRequired`, `toBeChecked`, `toHaveClass`, `toHaveFocus`, `toHaveStyle`.
Most of these have far better error messages than equivalent assertions like `element.classList.contains('foo')`, when a test fails, you'll see 'Expected element to have class "foo", but it has classes ["bar", "baz"]' instead of just 'false'. Setup detail that trips people on a fresh repo: install `@testing-library/jest-dom` and import it once from a file listed in `setupFilesAfterEnv`, not inside each test. In v6 the old `@testing-library/jest-dom/extend-expect` entry point is gone, so `import '@testing-library/jest-dom'` is the current form, and TypeScript users who see 'Property toBeInTheDocument does not exist on type JestMatchers' are almost always missing that import from the setup file or missing the setup file from `tsconfig.json`'s `include`. The same package works unchanged under Vitest with `import '@testing-library/jest-dom/vitest'`.
// jest.setup.ts (referenced from setupFilesAfterEnv)
import '@testing-library/jest-dom';
// in a test
const save = screen.getByRole('button', { name: /save/i });
expect(save).toBeInTheDocument(); // attached to the DOM
expect(save).toBeVisible(); // and actually displayed
expect(save).toBeDisabled(); // disabled attribute or fieldset
expect(save).toHaveAccessibleName('Save');
expect(screen.getByLabelText('Email')).toHaveValue('a@goodspace.ai');
expect(screen.getByRole('alert')).toHaveTextContent(/payment failed/i);
expect(screen.getByTestId('badge')).toHaveClass('badge--gold');
// jsdom has no layout engine: this is NOT possible in Jest
// expect(save).toBeInTheViewport(); // use Playwright for real layout
Q25How do you mock a global like fetch or localStorage in Jest?
IntermediateMocking
Answer
For `fetch`: in 2026, prefer `msw` (Mock Service Worker) over hand-mocking, MSW intercepts at the network layer and lets you write fixtures as real request handlers, so the same mocks work in tests AND in dev. If you do mock `fetch` directly: `global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({...}), ok: true })`. For `localStorage`: jest-environment-jsdom (the default for React projects) provides a working `localStorage` already.
For globals like `crypto`, `Notification`, or `IntersectionObserver`, define them on `global` in a `jest.setup.js` file referenced from `setupFilesAfterEnv` in your config. ALWAYS restore globals in `afterEach` or `afterAll`, leaking a mocked `fetch` between tests is a debugging nightmare. Prefer `jest.spyOn(global, 'fetch')` over raw assignment, because the spy is undone by `restoreMocks: true` while `global.fetch = jest.fn()` survives until something overwrites it.
Note also that on Node 18+ `fetch` is a real global (undici), so under `testEnvironment: 'node'` an unmocked call escapes to the network and turns a unit test into a flaky integration test; MSW closes that hole with `onUnhandledRequest: 'error'`, which fails the test instead of letting it hit production. The jsdom gaps you will actually hit are `window.matchMedia` ('TypeError: window.matchMedia is not a function', thrown by almost every responsive hook), `IntersectionObserver` and `ResizeObserver` (used by virtualised lists and chart libraries), `window.scrollTo` ('Error: Not implemented: window.scrollTo'), and navigation, where jsdom logs 'Not implemented: navigation (except hash changes)' when code assigns to `window.location.href`. Polyfill these once in the setup file with `Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(...) })`. Remember `localStorage` is shared by every test in a file, so clear it in `beforeEach` or the third test inherits the first one's auth token.
// MSW (preferred)
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/users/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Razorpay' })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Hand-rolled fetch mock (works but feels brittle)
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, name: 'Razorpay' }),
});
});
Q26When does snapshot testing go wrong, and what should you do instead?
IntermediateSnapshots
Answer
Snapshot testing fails when (1) snapshots are too large, every CSS class tweak breaks 50 tests, devs run `jest -u` blindly, snapshots become rubber-stamped, (2) snapshots include volatile data (timestamps, UUIDs, animation classes), every CI run produces a different snapshot, (3) snapshots are used as a substitute for explicit assertions, you can't tell from `toMatchSnapshot()` what the test is actually checking. The fixes: prefer `toMatchInlineSnapshot()` so the expected value lives in the test file (visible in PRs), keep snapshots small by snapshotting individual elements not whole trees, use `expect.any(String)` or `serializer.replaceProperty` for volatile fields, and use snapshots only for content-oriented things (error messages, complex strings), never as a substitute for `toHaveTextContent` or role-based queries. Concretely, the volatile-data problem has a first-class fix: property matchers, `expect(order).toMatchSnapshot({ id: expect.any(String), createdAt: expect.any(Date) })`, which snapshot the stable fields and type-check the unstable ones.
For repeated shapes, register a custom serializer with `expect.addSnapshotSerializer` or the `snapshotSerializers` config key so every snapshot in the suite normalises the same way, which is also how you strip absolute paths that differ between a macOS laptop and a Linux CI runner. Two CI settings matter. `--ci` makes a MISSING snapshot a failure rather than silently writing one, without which a snapshot committed by nobody gets created on the runner and passes forever. And obsolete snapshots are only reported, not removed, until someone runs `jest -u`, so a `.snap` file quietly accumulates entries for deleted tests; `--ci` plus a periodic `jest -u` on a clean branch keeps them honest. Expect churn on upgrades too: Jest 29 changed `snapshotFormat` defaults (`printBasicPrototype: false`), which rewrites nearly every existing snapshot on the version bump, so do that in its own commit rather than mixed with a feature change.
// Volatile fields: match their type, snapshot the rest
test('creates an order', async () => {
const order = await createOrder({ amount: 499 });
expect(order).toMatchSnapshot({
id: expect.any(String),
createdAt: expect.any(Date),
});
});
// Inline snapshot: expected value is visible in code review
test('formats the invoice line', () => {
expect(formatLine({ qty: 3, price: 499 }))
.toMatchInlineSnapshot(`"3 x ₹499.00 = ₹1,497.00"`);
});
// Better than snapshotting a whole tree
test('shows the failure banner', () => {
render(<Checkout status='failed' />);
expect(screen.getByRole('alert')).toHaveTextContent('Payment failed');
});
Q27How do you set up Jest for a TypeScript project?
IntermediateSetup
Answer
Two paths in 2026. (1) `ts-jest`, a Jest transformer that compiles TS on the fly. Reliable, has type-checking during tests, slower for large codebases. Install `ts-jest`, set `preset: 'ts-jest'` in jest.config.js. (2) `@swc/jest` or `babel-jest` with `@babel/preset-typescript`, strips types instead of compiling. ~10× faster than ts-jest, no type checking during tests (relies on tsc in a separate CI step).
Most modern setups (Next.js, Vite + Jest) ship with SWC for tests. For path aliases (`@/components/...`), mirror your `tsconfig.json` paths in `jest.config.js` under `moduleNameMapper`, Jest doesn't read tsconfig. If you use Next.js, `next/jest` handles all of this automatically.
Config specifics people get wrong: ts-jest options moved out of the `globals` block into the transform tuple (`transform: { '^.+\.tsx?$': ['ts-jest', { isolatedModules: true }] }`), and leaving them under `globals` prints a deprecation warning that eventually becomes an error; `isolatedModules: true` roughly doubles ts-jest's speed by skipping cross-file type checking, which is the right trade when `tsc --noEmit` already runs as its own CI job. You also need `moduleNameMapper` entries for non-code imports, `'\.(css|scss)$': 'identity-obj-proxy'` and a stub for images and SVGs, otherwise Jest tries to parse CSS as JavaScript and throws a syntax error on the first `{`. The other recurring error is 'SyntaxError: Cannot use import statement outside a module' coming from a dependency inside `node_modules` that ships only ESM; the fix is `transformIgnorePatterns: ['node_modules/(?!(nanoid|query-string)/)']` so that package is transformed too. Finally, choose between `@types/jest` globals and `import { describe, expect, test } from '@jest/globals'` with `injectGlobals: false`, which is stricter and avoids the classic clash between `@types/jest` and `@types/mocha` in one workspace.
// jest.config.ts (SWC, fast)
import type { Config } from 'jest';
const config: Config = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
transformIgnorePatterns: ['node_modules/(?!(nanoid)/)'],
};
export default config;
Q28How do you debug a failing or flaky Jest test?
IntermediateDebugging
Answer
Standard playbook: (1) Run the single test with `jest -t 'test name' --verbose` to isolate it from the rest of the suite. (2) Run with `--runInBand` to remove parallelism, if it passes in band but fails in parallel, you have shared state between tests (often via globals, modules with top-level state, or the database). (3) Use `screen.debug()` from RTL to dump the rendered DOM at the point of failure, this is the React-test equivalent of `console.log` and shows exactly what the test sees. You can also pass an element: `screen.debug(screen.getByRole('button'))` to debug a specific subtree. (4) For flakes, run the test 100× with `jest --runInBand --testNamePattern='X' --maxWorkers=1` (or use `jest --bail=10` to stop after 10 failures) and look for the failure pattern, does it always fail on a specific iteration count, or randomly? Random flakes usually mean a race condition. (5) Set `jest --detectOpenHandles` to find leaked timers/connections/sockets that delay test completion, common culprit is forgetting to close a database pool or clear a `setInterval` in `afterAll`. (6) For React tests: check for `act()` warnings in the console, they signal state updates outside React's tracking, which causes flakes because the next render hasn't happened yet when your assertion runs. In 2026 with RTL v14+, `act` is mostly handled automatically by `userEvent` and `findBy*`, but you'll still see warnings for setState calls in `useEffect` cleanup or in unmocked async code. (7) `jest --logHeapUsage` shows memory per test, sudden spikes pinpoint leaks.
Q29What is the difference between the jsdom and node test environments, and how do you set one per file?
IntermediateConfiguration
Answer
`testEnvironment` decides what globals your test code sees. `'node'` has been the default since Jest 27 and gives you a plain Node context with no `window` and no `document`, which is what you want for Express handlers, workers, CLI code and pure logic. `'jsdom'` boots a full in-memory DOM implementation per test file so React components, DOM libraries and anything touching `window` can run. Since Jest 28 jsdom is no longer bundled: you install `jest-environment-jsdom` separately, and a missing install fails with 'Test environment jest-environment-jsdom cannot be found. Make sure the testEnvironment configuration option points to an existing node module.'
You can override the environment for a single file with a docblock at the very top, `/** @jest-environment jsdom */`, and pass options with `@jest-environment-options {"url": "https://goodspace.ai/jobs"}`, which is how you control `window.location` for a test. The reason to care is cost and honesty. Booting jsdom adds real startup time and memory per FILE, so running a backend suite under jsdom wastes minutes in CI; splitting the run with `projects` gives each package the right environment in one `jest` invocation.
And jsdom has no layout engine or canvas, so `getBoundingClientRect` returns zeros, `offsetWidth` is 0, and scroll or intersection behaviour never fires on its own. Those need Playwright. MSW v2 under jsdom also needs `testEnvironmentOptions: { customExportConditions: [''] }` or its Node interceptors do not load.
/**
* @jest-environment jsdom
* @jest-environment-options {"url": "https://goodspace.ai/jobs"}
*/
// the docblock must be the first thing in the file
// jest.config.js: mix both environments in one run
module.exports = {
projects: [
{
displayName: 'web',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/apps/web/**/*.test.tsx'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testEnvironmentOptions: { customExportConditions: [''] }, // MSW v2
},
{
displayName: 'api',
testEnvironment: 'node',
testMatch: ['<rootDir>/services/api/**/*.test.ts'],
},
],
};
Q30Your Jest run fails with 'SyntaxError: Cannot use import statement outside a module'. How do you fix it?
IntermediateDebugging
Answer
Read the stack trace first, because the fix depends entirely on WHICH file threw. If the path is inside `node_modules`, the cause is that Jest executes CommonJS by default and `transformIgnorePatterns` defaults to `['/node_modules/']`, so a dependency that ships only ESM (nanoid v4+, react-markdown, query-string v8, many `@scope/ui` packages) is handed to Node untransformed. The fix is a negative lookahead that lets that one package through the transform: `transformIgnorePatterns: ['node_modules/(?!(nanoid|react-markdown)/)']`.
With pnpm the real files live under `node_modules/.pnpm`, so the pattern has to account for that path too. A lighter alternative is `moduleNameMapper` pointing the specifier at the package's CJS build, which avoids transforming anything. If the failing path is your OWN source file, the transform never ran: usually a missing `babel.config.js` at the project root (a `.babelrc` is file-relative and is not picked up for files outside its package), a missing `@babel/preset-env`, or a `transform` key that overwrote the default and no longer matches `.ts`/`.tsx`. `npx jest --showConfig` prints the resolved `transform` and `transformIgnorePatterns`, which settles it in one command.
The third option is to stop transpiling and run Jest as real ESM with `NODE_OPTIONS=--experimental-vm-modules`, but that changes mocking (`jest.mock` stops working, `jest.unstable_mockModule` replaces it), so most teams keep the transform. Remember to run `jest --clearCache` after changing transform config, since stale transformed output is cached on disk.
// SyntaxError: Cannot use import statement outside a module
// at node_modules/nanoid/index.js:1
// Fix 1: transform just that dependency
module.exports = {
transformIgnorePatterns: [
'node_modules/(?!(nanoid|react-markdown|@goodspace/ui)/)',
],
};
// Fix 2: map the specifier to a CJS build, no transform needed
// moduleNameMapper: { '^nanoid$': '<rootDir>/test/stubs/nanoid.cjs' }
// Fix 3: run Jest as native ESM (changes how you mock)
// package.json
// "scripts": { "test": "NODE_OPTIONS=--experimental-vm-modules jest" }
// After any transform change:
// npx jest --clearCache && npx jest --showConfig
Q31What are asymmetric matchers, and how do you write a custom matcher with expect.extend?
IntermediateMatchers
Answer
Asymmetric matchers are placeholder objects that describe a value instead of specifying it, and they compose inside any equality check: `toEqual`, `toMatchObject`, `toHaveBeenCalledWith`, even nested several levels deep. The set you need: `expect.any(Constructor)` (checks `instanceof`, and note `expect.any(String)` matches a primitive string too), `expect.anything()` (anything except `null` and `undefined`), `expect.objectContaining` and `expect.not.objectContaining` for partial objects, `expect.arrayContaining` for a subset of elements in any order, `expect.stringContaining` and `expect.stringMatching(/re/)`, and `expect.closeTo(0.3, 5)` (Jest 29.2+) for floats inside an object. They exist because the alternative, pulling the object apart and asserting field by field, produces a test that fails with 'expected 4 to be 5' and no context, while an asymmetric matcher fails with a full diff of the object.
When the concept you are asserting repeats across the codebase (a valid GSTIN, a rupee-formatted string, an ISO timestamp), promote it to a custom matcher with `expect.extend`. A matcher receives the actual value plus your arguments and returns `{ pass, message }`, where `message` is a FUNCTION so it is only built on failure, and where you must handle `this.isNot` so the `.not` form prints a sensible sentence. Register them in a file listed under `setupFilesAfterEnv`, and in TypeScript declare them by augmenting `jest.Matchers` so `expect(x).toBeValidGstin()` type-checks.
// Asymmetric matchers compose inside any equality assertion
expect(saveOrder).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.any(String),
amount: 499,
tags: expect.arrayContaining(['premium']),
}),
expect.stringMatching(/^idem_/),
);
// Custom matcher, registered from setupFilesAfterEnv
expect.extend({
toBeValidGstin(received) {
const pass = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(received);
return {
pass,
message: () =>
`expected ${this.utils.printReceived(received)} ${this.isNot ? 'not ' : ''}to be a valid GSTIN`,
};
},
});
expect('27AAPFU0939F1ZV').toBeValidGstin();
expect('nope').not.toBeValidGstin();
Q32How do you test a custom React hook with Jest?
IntermediateReact Testing
Answer
Use `renderHook` from `@testing-library/react`. It moved into the core package in v13.1, so the separate `@testing-library/react-hooks` package is deprecated and should not be installed for React 18 or 19; seeing it in a codebase is usually a sign the test setup predates the React 18 upgrade. `renderHook(() => useCart())` mounts a throwaway component that calls your hook and returns `{ result, rerender, unmount }`. Read state through `result.current` every time, never destructure it: `const { total } = result.current` captures the value from that render and will keep reporting the stale number after an update, which is the single most common failing test with hooks.
Any call that triggers a state update must be wrapped in `act(...)` so React flushes the re-render before your assertion; for asynchronous hooks (data fetching, debounced values), skip manual `act` and use `await waitFor(() => expect(result.current.isSuccess).toBe(true))`. `rerender(newProps)` combined with the `initialProps` option tests how the hook responds to changing inputs, which is where dependency-array bugs surface. The `wrapper` option supplies context providers such as `QueryClientProvider` or a Redux store. `unmount()` is how you prove cleanup works: assert the interval was cleared or the `AbortController` fired. One judgement call interviewers like: if a hook only makes sense with the DOM behaviour of its consumer, test it through a small component instead, because `renderHook` gives you an interface test, not a usage test.
import { renderHook, act, waitFor } from '@testing-library/react';
test('adds an item and recomputes the total', () => {
const { result } = renderHook(() => useCart());
act(() => {
result.current.add({ id: 'p1', price: 499 });
});
expect(result.current.total).toBe(499); // always via result.current
});
test('responds to a changed prop', () => {
const { result, rerender } = renderHook(
({ coupon }) => useDiscount(coupon),
{ initialProps: { coupon: 'NONE' } },
);
rerender({ coupon: 'GS500' });
expect(result.current).toBe(500);
});
test('clears its interval on unmount', () => {
jest.useFakeTimers();
const { unmount } = renderHook(() => usePoll(1000));
expect(jest.getTimerCount()).toBe(1);
unmount();
expect(jest.getTimerCount()).toBe(0);
});
Q33How do you test an Express API route with Jest and supertest?
IntermediateBackend Testing
Answer
Split the app from the server first. `app.js` builds and exports the Express instance; `server.js` does `app.listen(port)`. Tests import `app` only, so nothing binds a port and files can run in parallel across workers. Then `supertest` drives the app in-process: `await request(app).post('/api/orders').set('Authorization', 'Bearer ' + token).send({ amount: 499 }).expect(201)`, and the resolved response gives you `res.body`, `res.status` and `res.headers` for normal Jest assertions.
Set `testEnvironment: 'node'` for these files, since jsdom adds startup cost and a `window` that will mislead anything doing environment detection. The design decision the interviewer is actually probing is what you mock. Mocking the route's service layer with `jest.mock('../services/orderService')` gives a fast test of routing, validation, status codes and error mapping, and tells you nothing about SQL.
Running against a real disposable database (Testcontainers or a schema-per-worker created from `process.env.JEST_WORKER_ID`) tests the query too, at the cost of seconds per file. Most teams do both, in separate `projects`. Two operational details: wrap DB truncation in `afterEach` rather than the test body, and if the run prints 'Jest did not exit one second after the test run completed', a pool or socket is still open, so close it in `afterAll` and use `--detectOpenHandles` to find it. For auth, generate a real JWT with the test secret rather than mocking the middleware, so the middleware stays covered.
// app.js exports the app; server.js calls listen()
import request from 'supertest';
import app from '../app';
import * as orderService from '../services/orderService';
jest.mock('../services/orderService');
describe('POST /api/orders', () => {
afterAll(async () => { await pool.end(); }); // stop the open-handle warning
test('returns 201 with the created order', async () => {
jest.mocked(orderService.create).mockResolvedValue({ id: 'o_1', amount: 499 });
const res = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${signTestToken()}`)
.send({ amount: 499 })
.expect('Content-Type', /json/)
.expect(201);
expect(res.body).toEqual({ id: 'o_1', amount: 499 });
expect(orderService.create).toHaveBeenCalledWith({ amount: 499 });
});
test('rejects a negative amount with 400', async () => {
const res = await request(app).post('/api/orders').send({ amount: -1 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/amount/i);
});
});
Q34How do you make tests deterministic when the code depends on Date, Math.random, timezone or locale?
IntermediateDebugging
Answer
Four sources of nondeterminism, four specific fixes. Time: `jest.useFakeTimers().setSystemTime(new Date('2026-04-01T00:00:00Z'))` freezes `Date.now()`, `new Date()` and `performance.now()` under modern fake timers, so a 'is this subscription expiring in 7 days' branch is testable; remember to call `jest.useRealTimers()` in `afterEach`. Randomness: `jest.spyOn(Math, 'random').mockReturnValue(0.42)` for a single value, a queued sequence with `mockReturnValueOnce` for shuffles, and for ids `jest.spyOn(crypto, 'randomUUID').mockReturnValue(...)` or a seeded generator injected as a dependency.
Timezone: this is the one that catches Indian teams specifically, because a laptop on `Asia/Kolkata` and a CI container on UTC disagree about which calendar day 18:30 UTC belongs to, so a 'start of day' test passes all afternoon and fails after 05:30 IST. Set `process.env.TZ = 'UTC'` in `globalSetup`, not inside a test file, because Node caches the zone before your file runs; then test the IST behaviour explicitly with a zone-aware library rather than relying on the machine. Locale: `toLocaleString('en-IN')` groups in lakhs (`123456` becomes `1,23,456`) and currency output can vary with the ICU build shipped in the CI Node image, so assert against an explicitly constructed `Intl.NumberFormat` or pin the expected string per locale. The structural fix behind all four is injecting a clock and an id generator instead of reaching for globals.
// jest.globalSetup.js: TZ must be set before Node caches it
module.exports = async () => {
process.env.TZ = 'UTC';
};
// jest.config.js -> globalSetup: '<rootDir>/jest.globalSetup.js'
describe('renewalBanner', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-04-01T00:00:00Z'));
jest.spyOn(Math, 'random').mockReturnValue(0.42);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
test('warns when the plan expires within 7 days', () => {
expect(renewalBanner({ expiresAt: '2026-04-05' })).toBe('expiring-soon');
});
test('formats lakhs the Indian way', () => {
expect(new Intl.NumberFormat('en-IN').format(123456)).toBe('1,23,456');
});
});
Q35What is the difference between setupFiles, setupFilesAfterEnv, globalSetup and globalTeardown?
IntermediateConfiguration
Answer
They run at four different points and confusing them produces errors that look unrelated to config. `globalSetup` runs ONCE for the entire Jest run, in the main process, before any worker or test environment exists. It is the place to start a Docker container, run migrations, or set `process.env.TZ`. Because it lives outside the test environment, `expect`, `beforeEach` and the `jest` object are not available there, and it must export a function (async is fine). `setupFiles` runs once per test FILE, inside the environment, BEFORE the testing framework is installed: good for polyfills and for anything that must exist before your modules are imported, but calling `beforeEach` or `expect` there throws because those globals do not exist yet. `setupFilesAfterEnv` runs once per test file AFTER the framework is installed, which is where `@testing-library/jest-dom`, `expect.extend`, `jest.setTimeout(20000)`, a global `beforeEach(() => jest.clearAllMocks())` and MSW's `server.listen()` belong. `globalTeardown` mirrors `globalSetup` at the end of the run.
The consequence people miss: each test file runs in a separate worker PROCESS, so a value assigned to a variable in `globalSetup` is not visible in tests. The reliable channel is `process.env` (strings only) or a temp file written in setup and read in the tests. That is exactly why a Testcontainers port is usually published as `process.env.DB_PORT` rather than a module export.
// jest.config.js
module.exports = {
globalSetup: '<rootDir>/test/globalSetup.js', // once, before workers
globalTeardown: '<rootDir>/test/globalTeardown.js',// once, at the end
setupFiles: ['<rootDir>/test/polyfills.js'], // per file, pre-framework
setupFilesAfterEnv: ['<rootDir>/test/setup.js'], // per file, post-framework
};
// test/globalSetup.js
module.exports = async () => {
const container = await startPostgres();
globalThis.__PG__ = container; // readable in globalTeardown
process.env.DB_URL = container.getUri(); // the channel tests can see
};
// test/setup.js
import '@testing-library/jest-dom';
import { server } from './msw/server';
jest.setTimeout(20000);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { server.resetHandlers(); jest.clearAllMocks(); });
afterAll(() => server.close());
Q36How does Jest compare to Vitest, and which should you pick in 2026?
AdvancedComparison
Answer
Vitest is the modern challenger that's been steadily winning the JS testing market since 2022. Key differences: (1) **Speed**, Vitest runs on top of Vite/esbuild, no Babel transform, and parallelizes more aggressively. On a real-world Razorpay-sized monorepo (5k tests), Vitest is 3-5× faster than Jest. (2) **ESM-native**, Vitest handles ES modules without `--experimental-vm-modules` hacks.
Jest's ESM support has improved but still requires config gymnastics for many setups. (3) **TypeScript**, Vitest type-checks tests with `vitest --typecheck`. (4) **API compatibility**, Vitest's API is intentionally Jest-compatible: `describe`, `test`, `expect`, `vi.fn()` (instead of `jest.fn()`), `vi.mock`. Migrations are mostly mechanical. **When to pick Jest**: existing codebase with heavy investment, Next.js Pages Router projects (Jest is the default), large team familiarity. **When to pick Vitest**: new project, Vite/Astro/SvelteKit stack, ESM-first codebase, team values speed. By late 2026, Vitest is the default in many Indian shops (Razorpay's newer services, CRED) while Jest still dominates legacy React codebases.
Be ready for the follow-up 'what actually breaks in a migration', because 'it is API compatible' is not a senior answer. The mechanical part is a find-and-replace of `jest.` with `vi.` plus imports from `vitest` (or `globals: true` in the config to keep bare `describe`/`expect`). The parts that need thought: `jest.mock` factories become `vi.mock`, which is hoisted the same way and has the same out-of-scope-variable rule, but `vi.mock` does not auto-mock a module's exports, so an empty factory that used to yield `jest.fn()` stubs now yields nothing and you need `vi.importActual` or the `__mocks__` convention; `jest.requireActual` becomes `await vi.importActual`, which is async and forces the surrounding factory to be async; `jest.setTimeout` becomes `vi.setConfig({ testTimeout: 20000 })`; and setup lives under the `test` key of `vite.config.ts` or a separate `vitest.config.ts` rather than `jest.config.js`.
Everything from the Testing Library side (`@testing-library/react`, `jest-dom` via its `/vitest` entry point, MSW) transfers untouched. Jest 30 narrowed the raw speed gap, so in 2026 the honest tiebreaker is usually your bundler: if the app already builds with Vite, Vitest reuses that config and plugin chain, and that saved duplication matters more than the benchmark.
// Jest // Vitest
// jest.fn() -> vi.fn()
// jest.spyOn(obj, 'm') -> vi.spyOn(obj, 'm')
// jest.useFakeTimers() -> vi.useFakeTimers()
// jest.requireActual('./api') -> await vi.importActual('./api')
// jest.setTimeout(20000) -> vi.setConfig({ testTimeout: 20000 })
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // keep bare describe/test/expect
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
coverage: { provider: 'v8', thresholds: { lines: 80 } },
},
});
Key Points
- Vitest = 3-5× faster on real codebases (esbuild transform, better parallelism)
- Vitest is ESM-native, no experimental-vm-modules dance
- API is intentionally Jest-compatible (vi.fn vs jest.fn)
- Pick Vitest for new projects, Jest for legacy
Q37How does Jest compare to Mocha, Jasmine, and AVA?
AdvancedComparison
Answer
**Mocha** is the oldest mature option (2011). It's the LEAST opinionated: bring your own assertion library (Chai), mocking library (Sinon), coverage (nyc/Istanbul), and reporter. This makes it flexible but high-effort, you wire it all together.
Backend Node.js teams that started pre-Jest often still use Mocha because the stack is theirs to control. **Jasmine** is older still (2010), is fully batteries-included like Jest, but was designed before async/await was standard, its API feels dated, and most teams that used Jasmine have migrated. Karma + Jasmine was the Angular default before Jest. **AVA** is the speed-purist option, every test file runs in its own Node process, tests within a file run concurrently. It's fast and the API is minimal, but it forces test-isolation discipline that's awkward for stateful test suites (it requires every test to be independent).
In 2026 in India, you'll see Jest dominate React/frontend; Mocha + Chai survives in older Node.js backends; AVA is rare; Jasmine is essentially legacy-only. Concrete API differences are what the interviewer is really checking. Mocha gives you `describe`/`it` but no `expect`, so assertions come from Chai (`expect(x).to.deep.equal(y)`, `assert.strictEqual`) and doubles from Sinon (`sinon.stub()`, `sinon.useFakeTimers()`, and a `sandbox` you must `restore()` yourself); per-test timeouts use `this.timeout(5000)`, which silently fails if you wrote the test as an arrow function because `this` is not the Mocha context.
Jasmine has `jasmine.createSpy`, `spyOn(...).and.returnValue(...)` and `jasmine.clock()`. AVA uses `t.is`, `t.deepEqual` and `t.throwsAsync`, with no globals at all. The genuinely new competitor in 2026 is not any of these three: it is the runner built into Node itself, `node --test` with `node:test` and `node:assert`, plus its `--experimental-test-coverage` and `mock.method` helpers, and `bun test`, which is Jest-API-compatible and extremely fast.
Both are attractive for a dependency-free backend service and neither has a jsdom story, so React work stays on Jest or Vitest. The useful framing for an answer: Jest and Vitest bundle opinions, Mocha composes them, the Node runner removes the dependency entirely, and you pick based on whether you need a DOM and how much configuration you are willing to own.
Q38What are the worst Jest gotchas you've debugged in production?
AdvancedGotchas
Answer
Five real-world ones that bite teams in India regularly. (1) **`jest.useFakeTimers()` + async/await**: fake timers don't advance promise microtasks, so `await debouncedFn()` hangs forever. Fix: use `jest.advanceTimersByTimeAsync()` (Jest 29+) or manually `await Promise.resolve()` after advancing. (2) **Mock hoisting surprise**: `jest.mock(...)` is hoisted to the TOP of the file, so any variable you reference inside the factory must be declared with `var` (hoisted too) or named with the `mock` prefix, `const mockApi = ...; jest.mock('./api', () => mockApi)` throws an out-of-scope error. (3) **Module reset between tests**: setting `resetModules: true` looks reasonable but breaks `jest.mock(...)` declarations because the mock registry is also reset. Use `jest.isolateModules` instead for surgical resets. (4) **Snapshot pollution in CI**: snapshots committed from one OS (macOS) contain platform-specific paths and break on CI (Linux).
Use serializers to normalize paths. (5) **Memory leaks killing CI**: Jest by default keeps every test's modules in memory; a 10k-test suite eats 8 GB. Set `workerIdleMemoryLimit: '500MB'` (Jest 29+) so workers restart before OOMing. (6) **Timezone drift between a laptop and CI**: developers in India run with `TZ=Asia/Kolkata` while the CI container runs UTC, so `new Date('2026-03-15').toISOString().slice(0, 10)` or any 'start of day' helper produces a different date and a date test passes locally and fails only in CI (or worse, only between 00:00 and 05:30 IST). Pin it: set `process.env.TZ = 'UTC'` in `globalSetup` (setting it inside a test file is too late, the runtime has already cached the zone) and freeze the clock with `jest.setSystemTime`. (7) **`--detectOpenHandles` lying about the culprit**: it reports the stack that CREATED the handle, so a connection pool opened in a shared helper points at the helper, not the test that forgot to close it; combine it with `--runInBand` and bisect by file. A good answer to this question names the symptom, the mechanism and the config key, not just the fix.
// Gotcha 2: jest.mock is hoisted above the imports
// ❌ ReferenceError: Cannot access 'apiStub' before initialization
const apiStub = jest.fn();
jest.mock('./api', () => ({ fetchUser: apiStub }));
// ✅ the mock-prefixed name is exempt from the out-of-scope check
const mockFetchUser = jest.fn();
jest.mock('./api', () => ({ fetchUser: mockFetchUser }));
// Gotcha 1: fake timers do not flush microtasks
jest.useFakeTimers();
const promise = retryWithBackoff(fetchUser);
await jest.advanceTimersByTimeAsync(1000); // Jest 29+, awaits microtasks
await expect(promise).resolves.toEqual({ id: 1 });
// Gotcha 6: pin the timezone in globalSetup, not in a test file
module.exports = async () => { process.env.TZ = 'UTC'; };
Key Points
- Fake timers + async = hang; use advanceTimersByTimeAsync
- jest.mock is hoisted, only mock-prefixed names work inside the factory
- resetModules: true wipes mock registry too
- Snapshots break across OSes, use serializers
- workerIdleMemoryLimit for large suites in CI
Q39How would you architect a testing strategy for a large React monorepo?
AdvancedArchitecture
Answer
The pyramid in 2026 for a monorepo at Swiggy/Razorpay scale: **(1) Unit tests** (70%), Jest + RTL, every component and pure function. Run in parallel, target <30s for the per-package suite. Coverage threshold 80% per package, enforced in CI. **(2) Integration tests** (20%), Jest with MSW for network mocking, testing whole pages or feature flows.
Run after unit tests. **(3) E2E tests** (10%), Playwright, real browser, real backend (or staging). Slow (5-10 min total), run on every PR to main, in cron nightly. **Monorepo specifics**: use Turborepo or Nx to cache test runs per package, if `packages/ui` hasn't changed, don't re-test it. Configure Jest projects in the root `jest.config.js` to run each package's tests with its own config, lets you have `jsdom` environment for React packages and `node` for backend packages in the same `jest` invocation. **Shared setup**: a `@goodspace/test-utils` package with custom renders (wrapping `ThemeProvider`, `QueryClientProvider`), MSW handlers, and fixture factories, avoids 100 different copies of `renderWithProviders`. **CI**: shard tests across N workers with `--shard=k/N` (Jest 28+); a 5k-test suite that takes 6 min in 1 worker takes 90s in 4 workers.
Q40What changed in the latest Jest releases, and what's coming?
AdvancedRoadmap
Answer
Jest 29 (2022) brought modern fake timers as the default, improved ESM support, and dropped Node 12. Jest 30 (mid-2025) was the long-awaited major rewrite focused on ESM-first execution, drop of `babel-jest` as the default in favor of `@swc/jest`, native `import.meta` support, and `expect` extraction into `@jest/expect` for use outside Jest. It also introduced parallel snapshot updates and faster startup.
The community direction is convergence with Vitest's developer experience while leveraging Jest's ecosystem maturity, `@testing-library`, `msw`, and `jest-dom` work identically on both. **What's coming**: official Bun runtime support, better source-map handling for stack traces in TS, deprecation of legacy fake timers, and shadow-realm-based test isolation (an experimental V8 feature). The honest read for Indian teams in 2026: if you're on Jest 28 or below, plan an upgrade, security patches and Node 22 support require 29+. If you're greenfield, evaluate Vitest first; you'll spend less time on configuration and the API is 95% the same.
If you are the person doing the upgrade, the practical checklist is short. Bump `jest`, `jest-environment-jsdom` and `babel-jest`/`ts-jest` together, because `jest-environment-jsdom` has been a separate install since Jest 28 and a mismatched pair fails with 'Test environment jest-environment-jsdom cannot be found'. Check the Node floor first, since each Jest major raises it and a CI image pinned to an old Node fails at install rather than at test time.
Run `npx jest --showConfig` before and after so you can see which defaults moved. Expect snapshot churn from `snapshotFormat` changes and commit that separately from any behaviour change, otherwise the diff is unreviewable. Replace the deprecated matcher aliases (`toBeCalled`, `toBeCalledWith`, `toReturn`) with the `toHaveBeen*` forms while you are in there. And if the suite still uses `jest.useFakeTimers('legacy')` or the `timers: 'legacy'` config, budget time for it: modern timers also fake `Date`, and tests written against the old behaviour often assert on wall-clock deltas that are now frozen at zero.
Q41How does Jest actually isolate tests and run them in parallel?
AdvancedInternals
Answer
Jest builds a dependency graph (the haste map) of your project, then hands whole FILES to a pool of child processes managed by `jest-worker`, defaulting to CPU count minus one. Files are the unit of parallelism: tests inside one file always run sequentially unless you opt into `test.concurrent`. Each file gets a brand new test environment instance (a fresh Node vm context, or a fresh jsdom window) and a fresh module registry, so `require` caches, module-level singletons and globals are per file.
That is the mechanism behind several behaviours worth naming in an interview. Mocks and module state cannot leak between files, only within one, so a suite-order-dependent failure is either in-file pollution or genuinely external state such as a shared database row or temp file. Anything outside the process IS shared, which is why per-worker namespacing with `process.env.JEST_WORKER_ID` is the standard fix for parallel DB tests.
Objects constructed outside the test realm can fail `instanceof` even when they look right, which is the classic 'expected Error, received Error' confusion; structural matchers like `toMatchObject` sidestep it. Scheduling is not round-robin: Jest orders files longest-first using timings cached from previous runs, so the first run after `--clearCache` is often slower. `--runInBand` executes everything in the main process with no IPC serialisation, which is why it both fixes and hides concurrency bugs, and why it is the right flag under a debugger.
// One worker process per file; the id is stable inside that worker
const schema = `test_${process.env.JEST_WORKER_ID}`; // test_1, test_2, ...
beforeAll(async () => {
await db.query(`CREATE SCHEMA IF NOT EXISTS ${schema}`);
});
// Files run in parallel; these two overlap inside ONE file
test.concurrent('fetches jobs', async () => { /* ... */ });
test.concurrent('fetches profile', async () => { /* ... */ });
// Cross-realm identity is the trap
expect(err).toBeInstanceOf(Error); // realm sensitive
expect(err).toMatchObject({ message: 'boom' }); // realm safe
// --runInBand -> main process, no IPC, deterministic order
// --maxWorkers=1 -> still a child process
Key Points
- The file is the unit of parallelism, not the test
- Fresh module registry and environment per file, so no cross-file mock leakage
- External state (DB, filesystem) is shared: namespace with JEST_WORKER_ID
- Files are scheduled longest-first from cached timings
Q42Your Jest suite takes 18 minutes in CI. How do you diagnose and cut it down?
AdvancedPerformance
Answer
Measure before changing anything. `jest --json --outputFile=report.json` gives per-file start and end times, and sorting that list almost always shows a handful of files owning most of the wall clock. Then work through the usual causes in order of payoff. Worker count: Jest reads the HOST's core count, not the container's cgroup limit, so a 2-vCPU CI runner spawns 15 workers that thrash; `--maxWorkers=2` or `--maxWorkers=50%` frequently halves the time by itself.
Transform cost: `babel-jest` and especially `ts-jest` with full type checking dominate startup, so move to `@swc/jest` and keep `tsc --noEmit` as its own parallel job. Cache: Jest's transform cache lives in the OS temp directory and is thrown away on every fresh container, so set `cacheDirectory` inside the workspace and restore it with your CI cache action. Environment: jsdom boots per file, so any suite that does not touch the DOM belongs in a `node` project via `projects`.
Coverage: instrumentation adds real overhead, so run it on the main branch only, or switch `coverageProvider` to `'v8'`. Fan-out: `--shard=1/4` across four CI jobs turns 18 minutes into roughly 5 without touching a single test. Finally, fix the tests themselves: real `setTimeout` waits, per-test database truncation instead of transactions, and a barrel `index.ts` import that drags the entire design system into every file.
# 1. Find the expensive files
npx jest --json --outputFile=report.json
# 2. CI containers lie about core count
npx jest --maxWorkers=2 # or --maxWorkers=50%
# 3. Fan out across 4 CI jobs (Jest 28+)
npx jest --shard=1/4 # then 2/4, 3/4, 4/4
# 4. PR runs: only what the diff touches, no coverage
npx jest --changedSince=origin/main
// jest.config.js
module.exports = {
transform: { '^.+\\.(t|j)sx?$': ['@swc/jest'] },
cacheDirectory: '<rootDir>/.jest-cache', // cache this path in CI
coverageProvider: 'v8',
workerIdleMemoryLimit: '512MB',
projects: ['<rootDir>/apps/web', '<rootDir>/services/api'],
};
Q43How do you run native ESM tests in Jest, and what breaks when you do?
AdvancedModules
Answer
Jest executes CommonJS by default and reaches ESM through Node's VM modules API, which is still flagged. You opt in with `NODE_OPTIONS=--experimental-vm-modules jest`, plus either `"type": "module"` in package.json or `extensionsToTreatAsEsm: ['.ts']` for TypeScript, and you drop the transform (or use `ts-jest`'s `default-esm` preset with `useESM: true`). Running without the flag produces 'You need to run with a version of node that supports ES Modules in the VM API'.
What breaks is mocking, and this is the heart of the question. `jest.mock()` only works because a Babel plugin hoists it above the `import` statements; native ESM imports are resolved and evaluated before any module code runs, so there is nothing to hoist into and Jest throws 'The module factory of jest.mock() is not allowed to reference any out-of-scope variables' or simply never applies the mock. The replacement is `jest.unstable_mockModule(specifier, factory)` called BEFORE a dynamic `await import()` of the module under test, which means your imports move into the test body and top-level await becomes normal. The `jest` object itself is no longer a global, so you `import { jest } from '@jest/globals'`. Other differences: `__dirname` and `require` do not exist (use `import.meta.url`), `jest.requireActual` becomes `await import`, and relative specifiers need file extensions, which is why ESM projects carry a `moduleNameMapper` that strips `.js` from TypeScript-emitted paths.
// package.json
// { "type": "module",
// "scripts": { "test": "NODE_OPTIONS=--experimental-vm-modules jest" } }
// jest.config.js
export default {
extensionsToTreatAsEsm: ['.ts'],
transform: {},
moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' },
};
// mock BEFORE the dynamic import; jest is no longer a global
import { jest } from '@jest/globals';
jest.unstable_mockModule('./api.js', () => ({
fetchUser: jest.fn(async () => ({ id: 1, name: 'Swiggy' })),
}));
const { fetchUser } = await import('./api.js');
const { getUserName } = await import('./userService.js');
test('uses the mocked module', async () => {
await expect(getUserName(1)).resolves.toBe('Swiggy');
expect(fetchUser).toHaveBeenCalledWith(1);
});
Q44How do you debug a JavaScript heap out of memory failure in a large Jest suite?
AdvancedPerformance
Answer
The symptom is 'FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed, JavaScript heap out of memory', usually only in CI, because each worker is a separate process and total usage is roughly per-worker heap times `maxWorkers`. Start by attributing it: `jest --logHeapUsage --runInBand` prints the heap after every file, and the shape of the number tells you the cause. A sawtooth that returns to baseline is fine; a monotonic climb across unrelated files is a retained-environment leak. `node --expose-gc node_modules/.bin/jest --runInBand --detectLeaks` goes further and flags files whose environment is still reachable after teardown, though it is experimental and produces false positives with some libraries.
The usual culprits, in the order I check them: a module that stashes `document`, a DOM node or a large fixture on `globalThis` or in a module-level cache, so jsdom cannot be collected; timers and subscriptions never cleared, since a live `setInterval` roots everything it closes over; barrel imports (`import { Button } from '@company/ui'`) pulling an entire design system into every test file; mocks on hot functions accumulating every argument in `.mock.calls` for the whole file; and `console.log` of large objects, which the reporter buffers. Practical mitigations while you fix the root cause: `workerIdleMemoryLimit: '512MB'` restarts a worker once it crosses the limit, lowering `--maxWorkers` reduces the multiplier, and `NODE_OPTIONS=--max-old-space-size=4096` buys headroom. Splitting one enormous test file into several also helps, because the registry is released per file.
# Attribute the growth: watch the heap per file
npx jest --logHeapUsage --runInBand
# Flag environments that are still reachable after teardown (experimental)
node --expose-gc node_modules/.bin/jest --runInBand --detectLeaks
# Buy headroom and cap the multiplier while you fix the cause
NODE_OPTIONS=--max-old-space-size=4096 npx jest --maxWorkers=2
// jest.config.js: recycle a worker before it OOMs
module.exports = {
workerIdleMemoryLimit: '512MB',
};
// A common root cause: a live timer roots everything it closes over
afterEach(() => {
jest.clearAllTimers();
cache.clear(); // module-level Map that outlived the test
});
Q45Coverage is at 85% but bugs still ship. How do you measure whether the tests are any good?
AdvancedTest Quality
Answer
Coverage measures which lines executed, not whether anything was checked. A test that calls `gstFor(499, 18)` and asserts nothing reports the same 100% as a test with five assertions, and Istanbul cannot tell them apart. The direct measurement is mutation testing: Stryker (`@stryker-mutator/core` with `@stryker-mutator/jest-runner`) makes small edits to your source, flipping `>` to `>=`, replacing a return value, removing a statement, and re-runs the tests.
A mutant that survives is a line your suite executes but does not verify, and the mutation score (killed over total) is a far harder number than coverage. It is expensive, since the suite runs many times, so scope it with `mutate: ['src/pricing/**']`, enable `coverageAnalysis: 'perTest'` so only relevant tests run per mutant, and schedule it nightly rather than per PR. Complement it with cheaper signals. `expect.hasAssertions()` in `setupFilesAfterEnv` fails any test that asserts nothing; eslint-plugin-jest's `expect-expect`, `no-conditional-expect` and `no-standalone-expect` catch structural mistakes at lint time.
Track per-test flake rate in CI, because a test failing 2% of the time trains the team to re-run rather than investigate, which is worse than having no test. And apply the one check that needs no tooling: for every bugfix PR, revert the source change locally and confirm the new test goes red. A test that passes against the broken code documents nothing.
// 100% line coverage, zero value: nothing is asserted
test('calculates gst', () => {
gstFor(499, 18);
});
// setupFilesAfterEnv: make that test fail
beforeEach(() => {
expect.hasAssertions();
});
// stryker.config.json
// {
// "testRunner": "jest",
// "coverageAnalysis": "perTest",
// "mutate": ["src/pricing/**/*.ts", "!src/**/*.test.ts"],
// "thresholds": { "high": 80, "low": 60, "break": 50 }
// }
// npx stryker run
// Survived mutant src/pricing/gst.ts:12
// - if (amount > 0)
// + if (amount >= 0)
// -> the zero-amount branch is executed but never asserted
Key Points
- Coverage counts execution, not assertions
- Stryker mutation score finds covered-but-unverified lines
- expect.hasAssertions() plus eslint-plugin-jest catch assertion-free tests
- Revert the fix locally: a bugfix test must go red against the old code
Frequently Asked Questions
Is Jest still relevant in 2026 with Vitest gaining ground?
Yes, Jest still has the largest install base in JS testing (npm downloads ~25M/week vs Vitest ~10M/week as of mid-2026) and is the default for Next.js Pages Router, Create React App migrations, and most React codebases older than 2023. Vitest is the better choice for new projects on Vite/Astro/SvelteKit, but Jest skills remain essential for the millions of existing React codebases, and the API is nearly identical, so the skill transfers.
How much does a frontend developer with strong Jest skills earn in India?
₹5-18 LPA in 2026 for mid-level React developers, with strong testing skills (Jest + RTL + Cypress/Playwright) bumping you toward the upper end. Companies that pay well for testing rigor: Razorpay, Swiggy, CRED, Freshworks, Postman, Zomato. Senior-level (5+ years) with a focus on testing infrastructure can clear ₹25 LPA at FAANG offices in Bengaluru.
Should I learn Jest or Vitest first if I'm new to JS testing?
Learn the concepts (matchers, mocking, async testing, RTL patterns), they transfer between both tools. If you're learning to get a job in India, start with Jest because most interview questions and existing codebases use it. After 2-3 months, try Vitest for a side project; you'll find 90% of what you learned applies directly.
How long does it take to prepare Jest for an interview?
If you already build React components daily, about two weeks of evenings gets you interview-ready: three or four days on matchers and async patterns (toBe vs toEqual, rejects.toThrow, expect.assertions), four or five days on mocking (jest.fn, jest.mock hoisting, jest.spyOn, partial mocks with requireActual), three days on React Testing Library queries and findBy vs waitFor, and the rest on config and debugging (moduleNameMapper, transformIgnorePatterns, fake timers, flaky tests). From zero JavaScript testing experience, budget four to six weeks. The efficient path is not reading docs, it is adding tests to a repo you already own until you have hit the real errors yourself: 'Cannot use import statement outside a module', 'not wrapped in act(...)', 'Exceeded timeout of 5000 ms', 'The module factory of jest.mock() is not allowed to reference any out-of-scope variables'. Interviewers can tell within two questions whether you have debugged those or only read about them.
What is expected from a fresher versus an experienced engineer in a Jest interview?
A fresher is asked to write a test live for a pure function, explain toBe versus toEqual, use jest.fn for a callback, handle an async function with async/await, and say what beforeEach is for. Getting the mechanics right is enough. At two to five years the questions move to judgement: which mocking strategy for a given dependency, why a test passes alone and fails in the suite, how you would test a component that fetches on mount, what your coverage policy is and why it is not 100%, and how you fixed a specific flaky test. At six years and beyond it becomes ownership of the test system: monorepo projects config, sharding and worker limits in CI, jsdom memory, whether to migrate to Vitest and what that migration actually costs, and the strongest signal of all, a clear answer to what you deliberately do NOT test and why. Bring one real story of a test suite you made faster or less flaky, with the before and after numbers.
Is Jest enough, or do I also need React Testing Library, Cypress or Playwright?
Jest is the runner and assertion layer; it is almost never listed alone. For frontend roles in India the expected pairing is Jest plus React Testing Library, because Jest by itself cannot query a DOM the way a user would, and interviewers ask RTL questions (getByRole versus getByTestId, findBy versus waitFor) inside what they call a Jest interview. Add MSW for network mocking, since hand-rolled fetch mocks are increasingly treated as a smell. Above that sits end-to-end testing, where Playwright has largely taken the ground Cypress held: it runs a real browser, so it covers exactly the things jsdom cannot (layout, real navigation, cross-tab, file downloads). A realistic 2026 resume line is Jest plus RTL plus MSW for unit and integration work, and Playwright for a thin end-to-end layer. Backend engineers substitute supertest and Testcontainers for RTL. Knowing when each tool is the wrong choice is worth more in an interview than listing all four.
Introduction
Jest has been the dominant JavaScript testing framework for the better part of a decade. Created at Facebook (now Meta) in 2014 to test the React codebase, it spread across the JS ecosystem because it was the first testing tool that worked out of the box: no Mocha + Chai + Sinon + Istanbul stitching, no Karma + Jasmine boilerplate, just install and write tests. By 2026, every major Indian React shop, Razorpay, Swiggy, CRED, Freshworks, Airbnb engineering offices in Bengaluru, uses Jest (or its modern challenger, Vitest) as the default unit-test runner.
If you're interviewing for a frontend or full-stack role in India today, expect deep questions on matchers (toBe vs toEqual is asked in 60%+ of frontend interviews), mocking strategies (jest.fn, jest.mock, jest.spyOn), async testing patterns, and how Jest fits with React Testing Library. Coverage thresholds, snapshot pitfalls, and the Vitest comparison are also recurring themes, interviewers want to know if you understand WHY a test fails, not just how to write one.
This page covers 45 Jest interview questions asked in 2026, ordered basic first, then intermediate, then advanced. Each answer includes the underlying mechanics, the production failure modes (mock pollution, fake timers with async, jsdom memory in CI), the config keys and error strings you will actually see, and code examples where they make the point clearer. The later sections go into Jest internals: worker processes and module registries, native ESM with jest.unstable_mockModule, heap-limit crashes in CI, and mutation testing as a check on whether the suite asserts anything at all.
Ready to practice Jest interviews?
Don't just read, practice these Jest questions live with an AI interviewer that asks follow-ups and scores your answers.