25 — Testing
Q1. What best characterizes the difference between unit, integration, and end-to-end (E2E) tests?
- Unit tests exercise the whole app through the UI, so they give the highest confidence but run slowest
- Unit tests verify a single function/module in isolation and run fastest; integration tests verify multiple units working together; end-to-end tests drive the real app like a user and give the highest confidence but are slowest and most brittle
- Integration tests are always slower than end-to-end tests, because they spin up a full browser
- End-to-end tests replace the need for unit tests once a project has enough of them
Show Answer
Answer: B — Unit tests verify a single function/module in isolation and run fastest; integration tests verify multiple units working together; end-to-end tests drive the real app like a user and give the highest confidence but are slowest and most brittle
Explanation: This is the classic testing pyramid tradeoff: isolation and speed decrease as confidence increases. Unit tests are cheap and fast because they touch no real collaborators; E2E tests are expensive and slow because they exercise the real browser, network, and database, but they catch integration bugs unit tests structurally cannot see. Option A reverses the definitions — unit tests never touch the UI. Option C gets the ordering backwards; E2E is typically the slowest tier because it pays for a real browser and real I/O, with integration tests in between. Option D is wrong because the pyramid recommends many unit tests, fewer integration tests, and the fewest E2E tests as complements, not replacements — E2E tests alone are too slow and flaky to run on every change.
Q2. You have processOrder(cart, paymentGateway), which totals a cart and charges a payment gateway. A test constructs an in-memory InMemoryCart and a FakePaymentGateway, calls processOrder, and asserts the resulting order's status is "paid". What kind of test is this?
test("processOrder marks the order as paid", async () => {
const cart = new InMemoryCart([{ sku: "A1", price: 20 }]);
const gateway = new FakePaymentGateway({ willSucceed: true });
const order = await processOrder(cart, gateway);
expect(order.status).toBe("paid");
});
- End-to-end test — it exercises the full application exactly the way a real user would, including the actual UI and a live payment gateway
- Unit test — because
processOrderis a single function, calling it in a test always makes the test a unit test regardless of what it depends on - Integration test — it exercises
processOrdertogether with its collaborators (the cart and the payment gateway, even though the gateway is a fake), verifying that these pieces work correctly together rather than testingprocessOrderin full isolation or driving the app through a real UI - Snapshot test — because the assertion checks the resulting
orderobject's shape
Show Answer
Answer: C — Integration test — it exercises processOrder together with its collaborators (the cart and the payment gateway, even though the gateway is a fake), verifying that these pieces work correctly together rather than testing processOrder in full isolation or driving the app through a real UI
Explanation: What makes this an integration test is that the assertion depends on the correct interaction between multiple collaborating pieces (cart totaling, order state transitions, gateway charging), even though the gateway is faked rather than real. Option A is wrong because there's no browser, UI, or live network call involved — using fakes instead of the real UI is exactly what keeps this out of E2E territory. Option B is the common misconception that "one function call" automatically means "unit test"; what matters is whether the collaborators are isolated out (unit) or exercised together (integration), not how many function calls appear in the test body. Option D confuses this with snapshot testing, which serializes and diffs output rather than asserting on a specific field.
Q3. Which statement accurately distinguishes a spy, a stub, and a mock?
- A spy wraps a real function, recording calls (arguments, call count) while still invoking the original implementation; a stub replaces a function with fixed, canned behavior; a mock is a stub that also has built-in expectations/assertions about how it should be called
- A stub wraps a real function and records how it was called while still invoking the original implementation
- A mock always requires a real network connection to verify calls
- Spies, stubs, and mocks are interchangeable terms with no meaningful difference between testing libraries
Show Answer
Answer: A — A spy wraps a real function, recording calls (arguments, call count) while still invoking the original implementation; a stub replaces a function with fixed, canned behavior; a mock is a stub that also has built-in expectations/assertions about how it should be called
Explanation: Idiom: these three terms describe increasing levels of replacement and assertion, and the distinction is universal test-double terminology, not specific to any one framework — Jest's jest.fn()/jest.spyOn() and Vitest's vi.fn()/vi.spyOn() are just two libraries' names for building spies, stubs, and mocks. A spy observes without necessarily replacing behavior; a stub replaces behavior with something canned so the test controls the return value; a mock goes further by encoding expectations about the calls themselves (e.g. "was called exactly once with these arguments") as part of the double. Option B swaps the spy/stub definitions. Option C and D are simply false — none of these concepts require real network access, and the distinctions materially affect how a test reads and what it actually proves.
Q4. jest.spyOn(console, "error") is called with no .mockImplementation(...) chained after it. What happens when the spied method is invoked during the test?
const spy = jest.spyOn(console, "error");
doSomethingThatLogsAnError();
expect(spy).toHaveBeenCalled();
-
console.erroris completely silenced; nothing is printed to the console -
jest.spyOnthrows becauseconsole.errorcannot be spied on - The test fails immediately, because spies cannot wrap built-in global methods
- The spy records the call for the assertion, but
console.errorstill executes its real implementation, so the error is still printed to the console
Show Answer
Answer: D — The spy records the call for the assertion, but console.error still executes its real implementation, so the error is still printed to the console
Explanation: Debug: a bare jest.spyOn(obj, "method") call, by default, calls through to the real implementation — it only adds tracking of calls, arguments, and return values on top. To also replace the behavior (e.g. to silence noisy console.error output in a test), you must explicitly chain .mockImplementation(() => {}). Option A is the mistake many people make: assuming spying automatically suppresses output, when in fact suppression requires an explicit mock implementation. Options B and C are false — jest.spyOn works on object methods including built-ins like console, as long as the property is configurable.
Q5. What is the problem with this test?
test("fetchUser resolves with the right name", () => {
fetchUser(1).then((user) => {
expect(user.name).toBe("Ada");
});
});
- This test always fails, because
fetchUserreturns a promise instead of a value - This test can report as passing even if
user.nameis not"Ada", because the test function returnsundefinedsynchronously and the test runner considers the test done before the.then()callback — and itsexpect— ever runs - This test throws a syntax error, because
.then()cannot be used inside atest()callback - This test correctly verifies the resolved value; the test runner automatically waits for any promise created inside a test
Show Answer
Answer: B — This test can report as passing even if user.name is not "Ada", because the test function returns undefined synchronously and the test runner considers the test done before the .then() callback — and its expect — ever runs
Explanation: Debug: this is one of the most common silent-false-positive bugs in async testing. The test callback here is a plain synchronous function that returns undefined immediately; Jest and Vitest both mark a test complete as soon as its callback returns (unless it returns a promise or uses a done parameter). The .then() callback is scheduled as a microtask that runs after the synchronous test function has already returned, so if the expect inside it fails, that failure either gets reported as an unhandled rejection after the test has already "passed," or is missed entirely depending on runner configuration. The fix is to return fetchUser(1).then(...) or, more idiomatically, use async () => { const user = await fetchUser(1); expect(user.name).toBe("Ada"); }.
Q6. What is wrong with this test?
test("all users are validated", async () => {
const ids = [1, 2, 3];
ids.forEach(async (id) => {
const user = await fetchUser(id);
expect(user.isValid).toBe(true);
});
});
-
Array.prototype.forEachdoes not await the async callbacks it's given, so the outer test function returns (and the test is marked done) before any of thefetchUsercalls resolve or theirexpectcalls actually run - This is correct —
asyncinsideforEachmakes the whole loop asynchronous, andawaiton the outertestwaits for every iteration to finish before the test completes - This throws a
TypeError, becauseforEachcannot accept an async function - This is equivalent to using
Promise.allwithmap, so all assertions are guaranteed to run before the test finishes
Show Answer
Answer: A — Array.prototype.forEach does not await the async callbacks it's given, so the outer test function returns (and the test is marked done) before any of the fetchUser calls resolve or their expect calls actually run
Explanation: Debug: forEach invokes its callback for each element and completely ignores any value — including a promise — that the callback returns; it does not chain or await them. Each async (id) => {...} call kicks off a promise and forEach immediately moves to the next element without waiting, so the outer test function's await-free body returns right after the loop, and the test is considered finished before any fetchUser resolves. This makes every expect inside the loop a fire-and-forget check that may never be seen by the test runner. The fix is for (const id of ids) { const user = await fetchUser(id); expect(user.isValid).toBe(true); }, or await Promise.all(ids.map(async (id) => { ... })). Option D specifically names the correct fix pattern but wrongly claims it describes the code as written.
Q7. Given this function, which is the most correct way to test that it rejects for an invalid amount?
async function chargeCard(amount) {
if (amount <= 0) throw new Error("Invalid amount");
return gateway.charge(amount);
}
-
expect(chargeCard(-5)).toThrow("Invalid amount"); -
const result = chargeCard(-5); expect(result).toBe(undefined); -
chargeCard(-5).catch((e) => console.log(e)); -
await expect(chargeCard(-5)).rejects.toThrow("Invalid amount");
Show Answer
Answer: D — await expect(chargeCard(-5)).rejects.toThrow("Invalid amount");
Explanation: Idiom: .rejects unwraps the promise's rejection so the matcher can assert on the thrown error, and awaiting the whole expect(...) call ensures the test runner actually waits for that assertion to run before the test is marked complete. (rejects/resolves are Jest and Vitest matcher-naming conventions specifically — other runners use different syntax, but the underlying need to both await and assert on the rejection is universal.) Option A is wrong because toThrow expects a synchronously-throwing function, not a promise — an async function's throw becomes a rejection, not a synchronous exception, so this assertion doesn't correctly observe it. Option B never awaits anything and doesn't test failure at all. Option C swallows the rejection with a console.log and asserts nothing — if chargeCard unexpectedly resolved instead of rejecting, this "test" would still report as passing.
Q8. What is the risk in this test?
test("chargeCard rejects for invalid amount", async () => {
try {
await chargeCard(-5);
} catch (err) {
expect(err.message).toBe("Invalid amount");
}
});
- This is the recommended pattern for testing rejections; it is equivalent in safety to
rejects.toThrow - This throws a syntax error, because
expectcannot be called inside acatchblock - This test can give a false positive: if
chargeCard(-5)unexpectedly resolves instead of rejecting, thecatchblock — and itsexpect— never runs, and the test passes having executed zero assertions - This test always fails, because
try/catchcannot be combined withasync/await
Show Answer
Answer: C — This test can give a false positive: if chargeCard(-5) unexpectedly resolves instead of rejecting, the catch block — and its expect — never runs, and the test passes having executed zero assertions
Explanation: Debug: this is the same silent-false-positive family as the missing-await/.then() bug, just wearing a try/catch disguise. If a future change accidentally makes chargeCard resolve for negative amounts instead of throwing, the catch block simply never executes, no expect ever runs, and the test still reports green — precisely when it should be failing loudest. The safer pattern is await expect(chargeCard(-5)).rejects.toThrow("Invalid amount"), where the absence of a rejection itself causes the assertion to fail; alternatively, guard the try/catch version with expect.assertions(1) at the top of the test so the runner fails the test if the expected number of assertions never ran.
Q9. What tradeoff does snapshot testing introduce?
test("renders the user card", () => {
const tree = renderer.create(<UserCard name="Ada" />).toJSON();
expect(tree).toMatchSnapshot();
});
- Snapshot tests replace the need for any other assertions, because they capture the entire output automatically
- Snapshot tests are useful for catching unintended changes to output, but they carry a real risk: when a snapshot fails, it's easy to reflexively run the "update snapshot" command without actually reviewing the diff, which can silently bake a real regression into the new snapshot as the new "expected" output
- Snapshots can only be used with React components, never with plain JSON or string output
- A snapshot test fails only if the component throws an error during render
Show Answer
Answer: B — Snapshot tests are useful for catching unintended changes to output, but they carry a real risk: when a snapshot fails, it's easy to reflexively run the "update snapshot" command without actually reviewing the diff, which can silently bake a real regression into the new snapshot as the new "expected" output
Explanation: Snapshot testing is good at flagging that output changed, but says nothing about whether the change is correct — that judgment call is left entirely to whoever reviews the failing diff. In practice, a failing snapshot in a busy CI run often gets "fixed" by blindly re-running the updater, which overwrites the stored snapshot with whatever the (possibly buggy) code now produces, turning a real regression into the new passing baseline. Option A overstates what snapshots verify — they detect change, not correctness. Option C is false; toMatchSnapshot works on any serializable value, including plain objects and strings. Option D is false; a snapshot test fails whenever the serialized output differs from the stored snapshot, render errors aside.
Q10. Following on from snapshot testing: what is the correct discipline for handling a failing snapshot?
- Snapshots should be reviewed like a code diff on every failure — treating an unreviewed "update snapshot" run as automatically correct defeats the purpose of the test, since it just captures whatever the code currently outputs as the new "truth"
- It is always safe to run the snapshot-update command in CI on every failing build, to keep the suite green
- Snapshots should be committed as binary files, so they cannot be diffed or reviewed at all
- A growing snapshot file is a sign the test suite has too much coverage, and snapshots should be deleted regularly
Show Answer
Answer: A — Snapshots should be reviewed like a code diff on every failure — treating an unreviewed "update snapshot" run as automatically correct defeats the purpose of the test, since it just captures whatever the code currently outputs as the new "truth"
Explanation: A snapshot is only as trustworthy as the review it received when it was captured or updated; the entire value of the test collapses if updates are applied reflexively rather than read as a diff. Option B describes the exact anti-pattern that turns a regression detector into rubber-stamping machinery — automatically updating snapshots in CI removes the human review step that gives the test any signal at all. Option C is counterproductive; snapshots are stored as plain, diffable text (e.g. .snap files) specifically so they can be reviewed in pull requests. Option D confuses volume with quality — a large snapshot file isn't inherently a problem, but unreviewed updates are.
Q11. What is the problem with this pair of tests?
let cache = [];
function addItem(item) {
cache.push(item);
return cache.length;
}
test("adds first item", () => {
expect(addItem("a")).toBe(1);
});
test("adds second item", () => {
expect(addItem("b")).toBe(1);
});
- Both tests pass reliably regardless of run order, because the test runner resets all module-level variables before each test
- This code cannot run at all, because top-level
letdeclarations are forbidden in test files - The second test always fails with a
TypeError, becausecacheis undefined insideaddItem - The second test is order-dependent and will fail (or pass) unpredictably depending on execution order, because
cacheis module-level shared state that isn't reset between tests — a classic source of flaky, order-dependent failures
Show Answer
Answer: D — The second test is order-dependent and will fail (or pass) unpredictably depending on execution order, because cache is module-level shared state that isn't reset between tests — a classic source of flaky, order-dependent failures
Explanation: Test isolation means each test should start from a known, independent state. Here, cache lives outside any test and outside any setup hook, so it accumulates across the whole file: after the first test runs, cache.length is already 1, so the second test's addItem("b") returns 2, not 1, and the assertion fails — but only if the tests run in that order. If a test runner or --shuffle flag reorders tests, or a test is run in isolation with .only, the outcome flips unpredictably. The fix is to reset cache = [] in a beforeEach, or avoid module-level mutable state entirely by scoping it inside each test. Option A describes behavior no mainstream test runner provides by default — module state persists across tests in the same file unless explicitly reset.
Q12. What do beforeEach and afterEach accomplish in this test file?
describe("UserRepository", () => {
let repo;
beforeEach(() => {
repo = new UserRepository();
});
afterEach(() => {
repo.close();
});
test("starts empty", () => {
expect(repo.count()).toBe(0);
});
});
-
beforeEach/afterEachrun once total for the wholedescribeblock, before/after all its tests combined -
afterEachruns beforebeforeEachon every test, sorepois closed before it's even created -
beforeEachcreates a freshrepobefore every test in the block andafterEachtears it down after every test, giving each test an isolated, known starting state instead of leaking state between tests - Because
repois declared withletoutside the hooks, every test shares the exact sameUserRepositoryinstance
Show Answer
Answer: C — beforeEach creates a fresh repo before every test in the block and afterEach tears it down after every test, giving each test an isolated, known starting state instead of leaking state between tests
Explanation: beforeEach/afterEach run around every individual test/it in their scope, not once for the whole describe block — that's what beforeAll/afterAll do instead, which is the mistake option A describes. Each test therefore gets a brand-new UserRepository assigned to the shared repo variable, and the previous instance is closed afterward, preventing the kind of cross-test state leakage seen in Q11. Option B reverses the actual execution order — setup always runs before the test, teardown after. Option D is wrong precisely because beforeEach reassigns repo to a new instance before each test, so the reference changes even though the variable binding itself is declared once.
Q13. save is a mock with a canned return value. The first test passes; the second fails because save has already been called once by the time it runs. Which fix resets the call count between tests without discarding the mockReturnValue(true) behavior?
const save = jest.fn().mockReturnValue(true);
test("first call", () => {
save("a");
expect(save).toHaveBeenCalledTimes(1);
});
test("second call", () => {
save("b");
expect(save).toHaveBeenCalledTimes(1);
});
- Add
jest.restoreAllMocks()in anafterEach— it only affects mocks created withjest.spyOn, restoring their original (non-mocked) implementation, so it wouldn't touch a plainjest.fn()likesave - Add
jest.clearAllMocks()in anafterEach— it resetsmock.calls/mock.instances(so the recorded call count starts fresh before each test) but leaves the configuredmockReturnValue(true)implementation intact - Add
jest.resetAllMocks()in anafterEach— it clears call history AND removes any configuredmockReturnValue/mockImplementation, resettingsaveto a plain mock that returnsundefined, sosave("b")would no longer returntrue - Do nothing — the test runner automatically clears mock call counts between tests by default in every configuration
Show Answer
Answer: B — Add jest.clearAllMocks() in an afterEach — it resets mock.calls/mock.instances (so the recorded call count starts fresh before each test) but leaves the configured mockReturnValue(true) implementation intact
Explanation: Idiom: mockClear() (and its bulk form jest.clearAllMocks()) only wipes recorded call history — arguments, call count, results — leaving any configured implementation untouched, which is exactly what's needed here. mockReset()/jest.resetAllMocks() goes further and also strips the mocked implementation, which is why option C's save would start returning undefined, breaking any test relying on true. mockRestore()/jest.restoreAllMocks() goes furthest, restoring the original un-mocked implementation, but that only applies to mocks created via jest.spyOn on a real method — a bare jest.fn() has no "original" to restore to, so option A wouldn't fix anything. (clearAllMocks/resetAllMocks/restoreAllMocks are Jest's specific method names; Vitest mirrors them as vi.clearAllMocks()/vi.resetAllMocks()/vi.restoreAllMocks() — the underlying three-tier distinction is the part worth remembering.) Option D is false; leftover mock state across tests is a common, real cause of flakiness, not something runners fix automatically.
Q14. Why does this test complete instantly instead of taking 5 real seconds?
function scheduleReminder(cb) {
setTimeout(cb, 5000);
}
test("calls the callback after 5 seconds", () => {
jest.useFakeTimers();
const cb = jest.fn();
scheduleReminder(cb);
jest.advanceTimersByTime(5000);
expect(cb).toHaveBeenCalledTimes(1);
});
-
jest.useFakeTimers()replaces the global timer functions with mock versions;jest.advanceTimersByTime(5000)then synchronously fast-forwards the mocked clock and fires any callbacks scheduled at or before that point — no real waiting occurs, so the test runs instantly - This test actually waits 5 real seconds;
advanceTimersByTimeonly affectsDate.now(), notsetTimeout -
jest.useFakeTimers()only works withsetInterval, notsetTimeout, socbis never called -
advanceTimersByTime(5000)schedules the callback to run 5000 real milliseconds from now, instead of running it immediately
Show Answer
Answer: A — jest.useFakeTimers() replaces the global timer functions with mock versions; jest.advanceTimersByTime(5000) then synchronously fast-forwards the mocked clock and fires any callbacks scheduled at or before that point — no real waiting occurs, so the test runs instantly
Explanation: Fake timers swap out setTimeout/setInterval/Date (depending on configuration) with mock implementations that track scheduled callbacks against a fake internal clock instead of the real system clock. advanceTimersByTime(ms) moves that fake clock forward by ms and synchronously invokes any callbacks whose delay has now elapsed, letting time-based code be tested in milliseconds of real wall-clock time instead of actually waiting. (jest.useFakeTimers() is Jest's API name; Vitest's equivalent is vi.useFakeTimers() — the fake-clock concept itself is universal.) Options B and C misdescribe what fake timers intercept — they replace the timer functions themselves, working for both setTimeout and setInterval. Option D inverts what "advance" means; it moves the clock forward through the scheduled point, not away from it.
Q15. This test combines fake timers with a real async/await chain. What subtlety can trip it up?
async function delayedFetch(id) {
await new Promise((resolve) => setTimeout(resolve, 1000));
return fetch(`/users/${id}`);
}
test("resolves after the delay", async () => {
jest.useFakeTimers();
const promise = delayedFetch(1);
jest.advanceTimersByTime(1000);
await expect(promise).resolves.toBeDefined();
});
- This is completely safe —
advanceTimersByTimealways resolves every pending promise in the same synchronous call, so no extra step is ever needed -
jest.useFakeTimers()disablesasync/awaitentirely, so this test throws aSyntaxError -
fetchcannot be called inside a function that usessetTimeout, so this test always throws aReferenceErrorregardless of timers - Advancing fake timers fires the timer callback synchronously, but resuming the
awaitinsidedelayedFetchstill requires a separate microtask tick — advancing timers doesn't automatically flush pending microtasks from real Promises, so mixing fake timers with async code can need an extraawait Promise.resolve()(or an async-aware timer-advance helper) before the promise actually settles
Show Answer
Answer: D — Advancing fake timers fires the timer callback synchronously, but resuming the await inside delayedFetch still requires a separate microtask tick — advancing timers doesn't automatically flush pending microtasks from real Promises, so mixing fake timers with async code can need an extra await Promise.resolve() (or an async-aware timer-advance helper) before the promise actually settles
Explanation: Debug: advanceTimersByTime operates on the macrotask/timer queue — it synchronously invokes the setTimeout callback (which calls resolve()), but resuming execution after an await happens on the microtask queue, on the next tick. If the mock-timer implementation doesn't also flush pending microtasks, the outer await expect(promise).resolves... can end up racing the still-pending microtask, leading to flaky or hanging assertions in some setups. Modern Jest/Vitest offer async-aware helpers (e.g. jest.advanceTimersByTimeAsync / vi.advanceTimersByTimeAsync, or awaiting a Promise.resolve()/flushPromises() tick) specifically to bridge this gap. Options B and C describe failures that don't occur — fake timers don't disable async/await or forbid calling other functions inside a timer-based one; option A overstates the guarantee and is the assumption that causes real intermittent failures in test suites mixing fake timers with real promise chains.
Q16. What is wrong with testing this function by asserting on a hardcoded expected output?
function generateOrderId() {
return `ORD-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
test("generateOrderId returns a predictable id", () => {
expect(generateOrderId()).toBe("ORD-1700000000000-42");
});
- This test is fine, because the test runner automatically freezes
Date.now()and seedsMath.random()for every test run -
Math.random()always returns the same value within a single test file, so onlyDate.now()needs mocking - This test is inherently flaky, because
Date.now()andMath.random()produce different real values on every run; to make it deterministic you need to mock them (e.g.jest.spyOn(Date, "now").mockReturnValue(...)and mockMath.random) or inject a clock/RNG dependency so the test controls their output - This will pass consistently as long as the test runs on the same machine, because
Date.now()is deterministic per machine
Show Answer
Answer: C — This test is inherently flaky, because Date.now() and Math.random() produce different real values on every run; to make it deterministic you need to mock them (e.g. jest.spyOn(Date, "now").mockReturnValue(...) and mock Math.random) or inject a clock/RNG dependency so the test controls their output
Explanation: Any function whose output depends on wall-clock time or randomness cannot be asserted against a fixed literal — the exact same code will produce a different string on every single run, so this test is guaranteed to fail almost immediately after being written (or on any machine other than the one that generated the literal). The fix is either to mock the nondeterministic sources directly (jest.spyOn(Date, "now").mockReturnValue(1700000000000), jest.spyOn(Math, "random").mockReturnValue(0.042)), or better, to inject a clock and RNG as parameters/dependencies so the test can supply deterministic fakes without needing to patch globals. Options A, B, and D each invent a guarantee that doesn't exist in JavaScript or any mainstream test runner.
Q17. A function under test has five collaborators, and the test mocks all five, then asserts only that each mock was called with the expected arguments in the expected order. What risk does this pattern introduce?
- Mocking more dependencies always makes a test suite more reliable, since fewer real code paths can fail
- Mocking so many of a function's collaborators that the test only verifies the mocks were called in the expected sequence — rather than exercising any real logic — can create false confidence: the test passes even if the real integration between those pieces is broken, because none of the real code paths actually ran
- Over-mocking is only a concern in end-to-end tests, never in unit tests
- A test with heavy mocking runs slower than one with no mocks, because mock setup always requires real I/O
Show Answer
Answer: B — Mocking so many of a function's collaborators that the test only verifies the mocks were called in the expected sequence — rather than exercising any real logic — can create false confidence: the test passes even if the real integration between those pieces is broken, because none of the real code paths actually ran
Explanation: When every collaborator is replaced with a mock, the "system under test" shrinks down to just the glue code wiring those mocks together — the test proves that glue code calls things in the right order, but proves nothing about whether the real implementations actually work together correctly. This is a genuine, common trap: a suite can be full of green tests like this while a refactor that changes real interaction contracts (argument shapes, error handling) sails through undetected, because nothing in the test exercised real behavior. Option A inverts the actual risk — more mocking generally means less real code is verified, not more reliability. Option C is false; over-mocking is very much a unit-test problem, arguably its most common failure mode. Option D confuses mock setup cost with I/O cost; mocks specifically avoid real I/O and are typically faster, not slower.
Q18. This test achieves 100% line coverage of divide. What is still missing?
function divide(a, b) {
if (b === 0) throw new Error("Cannot divide by zero");
return a / b;
}
test("divide runs without crashing", () => {
divide(10, 2);
});
- This test can contribute to 100% line coverage for
dividewhile asserting nothing about its behavior — it never checks that the returned value is actually5, so a bug that madedividealways return0(orNaN) would still leave this test green. High coverage measures which lines executed, not whether the output was verified - This test is equivalent to
expect(divide(10, 2)).toBe(5), because the test runner automatically asserts on the return value of the last expression in a test - This test fails automatically, because a
test()block must contain at least oneexpect()call - Line coverage tools report this line as uncovered because no
expect()was used, so the false sense of confidence described doesn't apply here
Show Answer
Answer: A — This test can contribute to 100% line coverage for divide while asserting nothing about its behavior — it never checks that the returned value is actually 5, so a bug that made divide always return 0 (or NaN) would still leave this test green. High coverage measures which lines executed, not whether the output was verified
Explanation: Line/branch coverage is purely a measure of which code ran during the test suite, not whether the test made any meaningful claim about correctness. Calling divide(10, 2) executes the return a / b line, satisfying coverage tooling, but with zero expect() calls the test can never fail no matter how wrong the computed value is — coverage percentage and correctness confidence are two different axes that can diverge sharply. Option B invents a behavior no mainstream test runner has; nothing is asserted automatically. Option C is false — most runners do not require an expect() per test by default (some can be configured with expect.assertions() to enforce it, but that's opt-in, not automatic). Option D is wrong for the reason stated in the correct answer: the line does count as covered by execution alone, regardless of whether it was asserted on.
Q19. A test suite occasionally fails because a test makes a real HTTP call to a third-party API, which is sometimes slow or briefly unavailable. What is the best-practice fix?
- Add a retry mechanism so the test simply reruns until it passes
- Keep the real network call, since network-dependent tests are considered best practice — they prove the code works against the real production API
- Increase the test timeout, since that addresses the root cause of network-related flakiness
- A test that calls a real external API introduces flakiness from network latency, rate limits, and outages that have nothing to do with whether the code under test is correct; the best practice is to isolate the unit by mocking the network layer (or using a recorded fixture/fake server) so the test's pass/fail depends only on the code being tested
Show Answer
Answer: D — A test that calls a real external API introduces flakiness from network latency, rate limits, and outages that have nothing to do with whether the code under test is correct; the best practice is to isolate the unit by mocking the network layer (or using a recorded fixture/fake server) so the test's pass/fail depends only on the code being tested
Explanation: A test's job is to give a reliable, repeatable signal about the code under test; anything that ties its outcome to an external system's availability, latency, or rate limits undermines that signal, because the test can now fail for reasons that have nothing to do with a real bug. The standard fix is to isolate the unit from the network entirely — mocking the HTTP client, stubbing the fetch call, or replaying a recorded fixture — so the test exercises the code's logic deterministically, with a separate (typically smaller, explicitly-labeled) suite of true integration/E2E tests reserved for verifying the real network integration occasionally. Option A papers over the symptom without fixing the underlying nondeterminism, and can hide a real regression behind eventual retries. Option B mistakes an E2E concern for a unit-test virtue — real-API calls belong in a deliberately separate, smaller test tier, not scattered through the main suite. Option C doesn't address rate limits or outright outages, and just makes a slow, flaky test slower.
Q20. Two tests check the same increment behavior on a Counter class that uses a real #count private field. Which approach is the better practice, and why?
class Counter {
#count = 0;
increment() {
this.#count += 1;
return this.#count;
}
}
test("increment increases the count (implementation-coupled)", () => {
const c = new Counter();
c.increment();
expect(c["#count"]).toBe(1);
});
test("increment increases the count (behavior-based)", () => {
const c = new Counter();
expect(c.increment()).toBe(1);
});
- Both tests are equally good, because they both call
expect()on the counter's state - The first test is preferred, because it verifies the internal field directly, which is more precise than checking the return value
- The second test is the better practice: it asserts on the public, observable output (the return value) that consumers actually depend on, so refactoring
Counter's internals won't break it. The first test reaches for a private field by bracket-string key, which doesn't actually work with real#-syntax private fields and couples the test to implementation details that can change independently of behavior - Neither test is valid, because private
#fields cannot be tested at all, even indirectly
Show Answer
Answer: C — The second test is the better practice: it asserts on the public, observable output (the return value) that consumers actually depend on, so refactoring Counter's internals won't break it. The first test reaches for a private field by bracket-string key, which doesn't actually work with real #-syntax private fields and couples the test to implementation details that can change independently of behavior
Explanation: Good tests verify a contract — given these inputs, what output or observable behavior should result — rather than reaching into how that output is produced internally. The second test does exactly that: it only relies on increment()'s public return value, so if Counter is later refactored to store its count differently, the test keeps passing as long as the behavior is unchanged. The first test tries to peek at #count via c["#count"], which is not how JavaScript's true private class fields (#-prefixed) work at all — they are not accessible via bracket/string property access from outside the class, so this line either reads undefined or throws, depending on engine and strict-mode context, making the test broken independent of the design critique. Even a syntactically-valid version of implementation-coupled testing is still the worse practice, because it makes tests brittle against safe refactors that don't change behavior.