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.
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 passedThe 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.
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.
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
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.
// 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 rejectionMove 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?
Completion is saved in this browser only. It does not measure mastery.