Controlled Async Tests

The Course
•
4 min read

An async test should control the event it is testing. Sleeping for 20 milliseconds and hoping another operation completed can pass on one machine and fail under load. A deferred promise gives the test an explicit release point.

A test can first observe what has started, release one job, await an explicit notification that another started, and only then inspect the next state. That checks causality. Merely awaiting Promise.resolve once is not a general “flush all async work” operation; it only schedules another promise reaction.

Our pool tests use a separate third-started gate to prove a slot was refilled before releasing the first job. They also verify the outer promise has not settled while an active peer remains blocked after a failure. Test both the order and the lifecycle boundary, not just the final array.

Predict, run, and explain

Write the output and trace the important state changes before checking it. Then run the program. Each editor is independent. Async examples end with main() so this editor waits for their work.

Predict, then run JavaScript
async function main() {
  function deferred() {
    let resolve;
    const promise = new Promise(done => {
      resolve = done;
    });

    return { promise, resolve };
  }

  const gate = deferred();
  let finished = false;
  const work = gate.promise.then(() => {
    finished = true;
  });

  console.log(finished);
  gate.resolve();
  await work;
  console.log(finished);
}

main();
Check your prediction
false
true

Wait for a meaningful event rather than elapsed time

A controlled promise lets a test hold one operation at a chosen point. The test should observe the pending state, release the gate, then await the actual operation. Sleeping for an arbitrary interval hides whether the state changed for the reason you intended.

For a pool refill test, use a notification from the next mapper invocation. A microtask turn is not evidence that an arbitrary chain completed. Test causal events through the API rather than trying to flush every possible asynchronous operation.

Wait for a meaningful event rather than elapsed time: trace JavaScript
async function main() {
  let release;
  const gate = new Promise(resolve => {
    release = resolve;
  });
  const events = [];
  const work = gate.then(() => {
    events.push("done");
  });

  console.log(events.length);
  release();
  await work;
  console.log(events.join(","));
}

main();
Check your prediction
0
done

The first observation proves work is still held. The awaited promise in the second observation is the one that performs the effect, not an unrelated promise resolved nearby.

Wait for a meaningful event rather than elapsed time: contrast JavaScript
async function main() {
  let notify;
  const started = new Promise(resolve => {
    notify = resolve;
  });

  async function job() {
    notify();
    return 25;
  }

  const work = job();

  await started;
  console.log("start observed");
  console.log(await work);
}

main();
Check your prediction
start observed
25

A separate notification can prove a phase began. In a real test it can notify that the third pool job started while another is still held, distinguishing refill from batch behavior.

Try the rule on new inputs

Create separate started and released gates. Prove an outer promise stays pending while an active callback remains held after a peer fails. Add a timeout only as a safety limit for a broken test.

Your turn

Exercise

Controlled Async Tests: practise

Create a deferred gate and a job that prints started, waits on it and then prints finished. Start the job, print held from the caller, release the gate and await completion. Expect started, held, finished.

Your workspace JavaScript
// Keep a promise for the actual job and await it after releasing the gate.
Hint 1: the idea

The job runs until its first await before returning control to the caller.

Hint 2: a first step

Save the resolver for a promise and call it only after printing held.

Show one solution
async function main() {
  let release;
  const gate = new Promise(resolve => {
    release = resolve;
  });

  async function job() {
    console.log("started");
    await gate;
    console.log("finished");
  }

  const work = job();

  console.log("held");
  release();
  await work;
}

main();
started
held
finished

Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?

Change it and test it

Release two gates in reverse order and assert the observed completion order. Then explain which signal a test should await to know that a third pool job started. A timeout may be a final safety net for a broken test, but should not create its intended ordering.

Was this article helpful?

Your response is saved on this device.