cswork / libraryOpen practice workspace

Articles

Mocha and Chai Interview Questions: Async Completion, Hooks, and Leaking Test State

Practice Mocha and Chai interview questions with async completion failures, nested hook order, Promise rejection assertions, and leaking test-state repairs.

A Mocha test can report success before its assertion runs. A Chai assertion can check the wrong kind of failure. A suite can pass alone and fail after another file changes process state. Each failure needs its own diagnosis, even when the test run appears successful.

For Mocha and Chai interview questions, explain who owns completion, what the assertion actually observes, and who restores every resource or state change. This guide uses small completion failures, a hook-order ledger, and a state-cleanup exercise. You should be able to predict a result before running the code and explain why the repair detects the original bug.

Start with Validate Unit-Test Coverage and Identify Missing Scenarios, then add completion and cleanup failures to its coverage discussion. Check whether the runner waits for the assertion before trusting a passing result.

What do Mocha and Chai each control?

Official facts: Mocha documents synchronous tests, callback completion through done, returned Promises, and async functions. Its asynchronous-code guide also explains that returning a Promise while using done overspecifies completion. Chai's assertion API defines checks such as equality, deep equality, and synchronous throwing.

Evidence boundary: The framework semantics here come from current official documentation. The examples, predicted failures, and debugging order are original preparation exercises. No independently verified employer-specific candidate report is used, and the exercises are not advertised as questions from a named company's interview.

Before changing a failing test, answer five questions:

  • Is the test synchronous, callback-based, or Promise-based?
  • Does the runner receive the asynchronous work's completion signal?
  • Does the assertion inspect a value, a thrown exception, or a rejection?
  • Which hooks create and dispose of the fixture?
  • Can another test or worker observe state left behind?

A useful division of responsibility is that the runner decides when the test ends; the assertion library decides whether an observed condition holds. Even a correct assertion can run after the test has already completed if its asynchronous work is detached.

Why does this test finish before the assertion?

Original failure exercise: Assume loadUser returns a Promise. Consider this test:

it('loads the expected user', function () {
  loadUser().then(user => {
    expect(user.id).to.equal('u-17');
  });
});

The test callback returns without returning the chain. It also does not accept a completion callback. The runner has no supplied Promise to await. The test does not return the Promise that contains the assertion, so that chain is outside its supplied completion contract.

Repair it by returning the chain or using an async test that awaits the work:

it('loads the expected user', async function () {
  const user = await loadUser();
  expect(user.id).to.equal('u-17');
});

The important follow-up is verification. Make the fake return the wrong ID and confirm that the repaired test fails. Then make the fake reject and confirm that the test fails rather than reporting an unrelated successful assertion. Passing with the correct fake is only one part of proving that the test observes the asynchronous result.

An async declaration alone is insufficient if the function starts work and does not await or return it. For example, adding async to the first broken test still leaves its inner chain detached. Explain the ownership of that specific chain rather than treating a keyword as the fix.

How should a callback test forward failures?

Original callback exercise: A legacy readRecord(callback) reports either an error or a record. The completion callback should be called once on each successful or failing path. An assertion thrown inside a later callback needs to reach that completion path explicitly:

it('reads the expected record', function (done) {
  readRecord((error, record) => {
    if (error) return done(error);
    try {
      expect(record.id).to.equal('r-9');
      done();
    } catch (assertionError) {
      done(assertionError);
    }
  });
});

Now introduce a second mistake: mark the test function async while retaining its done parameter and calling done. The async function produces a Promise, so the test mixes completion mechanisms. Choose the mechanism that matches the API boundary, or adapt the callback API into a Promise and use one Promise-based contract.

Do not silently ignore duplicate callbacks in a fixture meant to test callback behavior. If the application contract promises exactly one callback, the test should expose a second invocation. A defensive adapter that accepts only the first call may be appropriate in production, but it changes what your test can observe.

Completion patternWhat the runner waits forReview question
Synchronous callback with no parameterCallback return or throwIs every operation actually synchronous?
Callback accepting doneThe completion callbackCan a path omit or repeat completion?
Returned PromiseFulfillment or rejectionIs the complete assertion chain returned?
async testIts returned PromiseIs all relevant work awaited or returned?
done plus returned PromiseConflicting completion signalsWhich single mechanism should remain?

Does to.throw test an asynchronous rejection?

Official fact: Chai's throw assertion invokes a target function to check a synchronous thrown error. Passing an already-invoked function's result is a different operation. An async function's rejected Promise also needs a Promise-aware observation rather than a synchronous throw assertion.

Original contrast: If parseRecord throws synchronously, a suitable check is:

expect(() => parseRecord('broken')).to.throw(TypeError);

If loadRecord rejects asynchronously, await the result through a rejection assertion. The Chai as Promised documentation explains its plugin registration and Promise assertions. After registering that plugin, an example is:

it('rejects an unavailable record', async function () {
  await expect(loadRecord('missing'))
    .to.be.rejectedWith(Error, 'unavailable');
});

Returning or awaiting the assertion remains necessary. Installing a Promise assertion plugin does not make an abandoned assertion chain part of Mocha's completion contract.

Also explain equal versus deep.equal using the data being tested. Two separately allocated objects with the same fields need a structural comparison if structural equality is the intended contract. If identity is the requirement, a deep comparison could accept an incorrect replacement object. Choose the assertion from the behavior, not from which spelling makes the example pass.

Can you predict nested hook order?

Official fact: Mocha's hooks guide distinguishes suite-level before and after from per-test beforeEach and afterEach. Hooks can be asynchronous using the same completion styles as tests.

Original ledger exercise: An outer suite contains one inner suite with two tests, A and B. Both suites define all four hook types. For a successful serial run, predict the ledger:

StageExpected sequence
Suite entryOuter before, inner before
Test AOuter beforeEach, inner beforeEach, A, inner afterEach, outer afterEach
Test BOuter beforeEach, inner beforeEach, B, inner afterEach, outer afterEach
Suite exitInner after, outer after

Use a log array to compare the actual sequence with that prediction. This exercise assumes ordinary successful hooks; a setup failure changes which test and cleanup paths execute, so do not extrapolate this ledger to every failure case.

Then ask which fixture belongs at which level. A mutable list modified by each test should usually be recreated per test in this exercise. A costly immutable reference fixture may reasonably be prepared once. “Put everything in before” leaves test mutations shared unless the application resets them deliberately.

Give hooks descriptive names so a setup failure is distinguishable from a test-body failure. When acquiring several resources, account for partial setup: if the second acquisition fails, the first resource may still need disposal.

Why does the test pass alone and fail in the suite?

Original state-leak exercise: Test A changes process.env.FEATURE_MODE, subscribes to an emitter, and creates a timer. Test B assumes the original environment, counts emitted events, and expects no background callback. Running B alone works; running A before B produces a different observation.

Inspect state ownership before adding a retry. Preserve whether the environment key originally existed and its original value. Restore that value if present; otherwise delete the key. Assigning a textual replacement for absence is not equivalent to restoring absence.

Keep references to the exact listener and timer created by the test. Remove that listener and clear that timer during cleanup. Creating a new anonymous function in removeListener does not identify the original subscription.

Resource changed by a testEvidence of leakageTargeted cleanup
Environment keyB sees A's feature modeRestore original presence and value
Event listenerOne emission causes extra invocationsRemove the exact registered function
TimerCallback runs during a later testClear the owned timer
Shared mutable collectionB receives A's recordsRecreate or reset the fixture per test
Database rowsCount depends on prior testIsolate a namespace and remove owned rows

Mocha's global-leak explainer shows one way to trace a leaking global property. Do not mistake global-property detection for comprehensive fixture cleanup. A listener or database row can affect later tests without introducing a new global name.

The strongest repair demonstration runs B alone, A then B, and the alternate order under the intended fixture setup. Inspect the event count and environment state after cleanup. Repeating the suite can reveal order dependence; inspecting the environment key, listener count, and owned timer identifies the state to repair.

Does parallel mode guarantee isolated files?

Official fact: Mocha's parallel-mode guide says file order is not guaranteed and that worker processes can be reused across files. Process-level state, including module caches and globals, can therefore remain relevant. A root hook in one test file is not automatically a hook for every other file in parallel mode.

Original design exercise: Two test files write fixtures/result.json and bind the same port. Separate worker processes do not separate those external resources. Give each worker or test a distinct temporary directory, port allocation, or database namespace, then clean up what it owns.

If the interviewer suggests disabling parallel mode, explain what that would and would not resolve. It can reduce simultaneous resource contention. It does not fix a test that permanently mutates shared state, and it does not repair a Promise that the runner never awaits.

For timeouts, use the Mocha timeout documentation to identify test, hook, and suite scope. Increasing the limit can accommodate justified latency; it cannot supply missing completion. The arrow-function guide also explains why lexical this cannot access Mocha's context. A normal function is appropriate when your test calls this.timeout.

Explain the repair and prove it detects the bug

A strong answer names the original failure, chooses one completion mechanism, and demonstrates a negative control. For a leaking suite, it names the resource, its owner, and the post-cleanup observation. That explanation is more useful than a test that happens to pass after a timeout or retry increase.

Try Validate Unit-Test Coverage and Identify Missing Scenarios and add three review checks: wrong-result failure, rejected-work failure, and a second test that sees clean state. Those checks give a reviewer evidence that the test waits for work, detects failure, and restores owned state.

Sources and Further Reading