Async Integration Tests

The Course
•
5 min read

Choose the schedule, then check the commits

A unit test can prove a decoder rejects malformed data. An integration test checks that the controller actually calls that decoder before rendering results. Likewise, a token helper may be correct while a forgotten guard in catch still displays an obsolete error.

Use deferred promises to choose fulfillment and rejection order. Start old, start new, finish new, then finish old. Assert the final state and the renderer’s observed states. Repeat with an old rejection. A sleep-based test that merely waits “long enough” does not prove these ordering cases reliably.

After cancel, fulfill the old promise anyway and assert no late result appears. This simulates a transport ignoring cancellation. After dispose, fulfill or reject pending work and assert no new render. Also test a rejected current request, empty success, and invalid response shape so the happy path does not dominate your evidence.

The downloaded project includes Node’s built-in test runner: node:test defines a test and node:assert/strict checks outcomes, including deepEqual for structured values. You do not need that runner inside the console editor; the checks below use ordinary throws. The framework organizes tests, but the important work is choosing the cases.

Predict and trace

Write the output before running. Follow the values through each call; if your prediction differs, identify the first step that surprised you.

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

    return { promise, reject };
  }

  const pending = deferred();
  const messages = [];

  async function attempt() {
    try {
      await pending.promise;
      messages.push("ready");
    } catch (error) {
      messages.push(error.message);
    }
  }

  const run = attempt();

  pending.reject(new Error("chosen failure"));
  await run;

  if (messages[0] !== "chosen failure") {
    throw new Error("wrong outcome");
  }

  console.log("Controlled rejection passed");
}

main();
Check your prediction
Controlled rejection passed

The rejection handler is attached before we reject. Nothing waits an arbitrary number of milliseconds.

Control the event that proves progress

A schedule test needs evidence that the relevant step happened, not a guessed delay. Retain a resolver or rejection function and release it after asserting the pending state. Await the actual operation before inspecting its final effects.

When testing a controller, use injected dependencies to observe loading, completion and cleanup. Test the integration between parts through their public effects. A reducer test alone cannot prove that the controller attached the right request id or respected disposal.

Control the event that proves progress: trace JavaScript
async function main() {
  let release;
  const pending = new Promise(resolve => {
    release = resolve;
  });
  const events = [];

  async function run() {
    events.push("loading");
    await pending;
    events.push("ready");
  }

  const work = run();

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

main();
Check your prediction
loading
loading,ready

The first observation happens while the operation is genuinely held. The second happens after its returned promise settles, so the test has a causal completion signal.

Control the event that proves progress: contrast JavaScript
async function main() {
  let starts = 0;

  async function request() {
    starts++;
    throw new Error("offline");
  }

  let message = "";

  try {
    await request();
  } catch (error) {
    message = error.message;
  }

  if (starts !== 1 || message !== "offline") {
    throw new Error("Wrong integration");
  }

  console.log("call count and failure checked");
}

main();
Check your prediction
call count and failure checked

The observed message and dependency call count protect different behavior. A controller that retries without permission could display the right error but still violate the one-call contract.

Try the rule on new inputs

Create controlled success, controlled failure, stale completion and completion-after-disposal cases. Remove one controller guard and confirm a particular schedule fails. Keep timers only as a final hung-test safety limit.

Build it yourself

Exercise

Async Integration Tests: practise

Write async expectReject(action), returning the caught error message and throwing Error(“Expected rejection”) if action fulfills. Verify a failing action.

Your working space JavaScript
// Do not catch the assertion you throw after a successful action.
Hint 1: the idea

Place the missing-rejection error outside the try/catch.

Hint 2: a first step

Await action inside try and return the message from catch.

Show one solution
async function main() {
  async function expectReject(action) {
    try {
      await action();
    } catch (error) {
      return error.message;
    }

    throw new Error("Expected rejection");
  }

  console.log(
    await expectReject(async () => {
      throw new Error("Offline");
    })
  );

  try {
    await expectReject(async () => 1);
  } catch (error) {
    console.log(error.message);
  }
}

main();
Offline
Expected rejection

Move the final throw inside try as a deliberate defect. A weak helper can catch its own assertion and falsely claim success. The fulfilling-action check exposes that design error.

Test a changed requirement

Move the final throw inside try as a deliberate defect. A weak helper can catch its own assertion and falsely claim success. The fulfilling-action check exposes that design error.

Explain before moving on

Which property of an async test is controlled by a deferred promise? Why must stale rejection be tested separately from stale success?

Was this article helpful?

Your response is saved on this device.