Testing async jobs
Every state a job can end in, cancelling, early results and tests that don't sleep.
Testing a job API means testing a small state machine over time. A job can end in more than one way, requests that are fine at one moment are wrong a second later, and the test has to wait without either hanging or guessing. This lesson lists the cases to cover, then turns them into tests that run against the jobs API in about seven seconds.
Every way a job can end
Testing only the happy path, a job that completes, leaves most of the behaviour untested. This API can produce each terminal state on demand:
| Ending | How to cause it here | What must be true |
|---|---|---|
completed | Any job of type report, export or import | progress 100, a resultUrl, the result returns 200 |
failed | A job of type fail, which dies at 60% | an error message, no result, the result endpoint refuses |
cancelled | DELETE /api/jobs/{id}sign in to see before it finishes | no result, and the job never changes again |
The failed job, as the status endpoint reports it:
{
"id": "job_1010",
"type": "fail",
"status": "failed",
"progress": 60,
"durationSeconds": 2,
"startedAt": "2026-10-11T12:33:57Z",
"finishedAt": "2026-10-11T12:33:58Z",
"resultUrl": null,
"error": "Simulated failure: worker crashed while processing batch 3 of 5."
}The status request itself succeeds with 200: asking about a failed job worked fine. The failure is in the body. A test that checks only the HTTP status of status requests will pass for a job that failed, so assert on status, and on errorwhen it's a failure.
Requests at the wrong moment
Several requests are right at one moment and wrong at another. Each should get a clear refusal:
| Request | When | This API answers |
|---|---|---|
| Get the result | While queued or running | 409, Job is still queued (0%). Poll … and retry., with Retry-After |
| Get the result | After it failed | 409, Job failed, so no result is available. |
| Get the result | After it was cancelled | 409, Job was cancelled, so no result is available. |
| Cancel | After it completed | 409, Job is already completed and cannot be cancelled. |
| Cancel | A second time | 409, Job is already cancelled and cannot be cancelled. |
| Anything | For a job that doesn't exist | 404 |
409 Conflict, used consistently for "this resource isn't in a state where that makes sense", and a message that says which state it is in. Check the messages as well as the codes: a client shows them to users, or logs them for whoever investigates.
Cancelling mid-run
Cancelling a job that has started stops it where it is. One cancelled about a second into a 10-second run ended with status: "cancelled" and progress: 10, and stayed exactly like that on every later request. That's the property to test: a terminal job never moves again. A cancelled job whose progress keeps climbing, or that later turns into completed, means the work wasn't really stopped.
Input validation
Bad input should be refused before a job is created, with 400:
- An unknown type gets
Unknown job type 'nope'. Supported: report, export, import, fail. - A duration of 0, -5 or 100000 gets
durationSeconds must be between 1 and 60.
One case behaves differently: a POST with no body at all is accepted, and starts a default 10-second report. Defaults like that are a design choice, but they turn a client bug (forgetting to send the body) into work that silently runs. Find out which way your API goes and write it down in a test, so a change is noticed.
Tests that wait without sleeping
The usual mistake in job tests is await sleep(5000)followed by an assertion. It's slow when the job is fast, and flaky when the job is slow. A polling helper with a deadline is faster and fails clearly:
// jobs.test.mjs: the states a job can end in, and the requests that must be refused
import { test } from "node:test";
import assert from "node:assert/strict";
const BASE = process.env.BASE_URL ?? "https://api.testingapis.com";
async function start(body) {
const res = await fetch(`${BASE}/api/jobs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
assert.equal(res.status, 202);
return (await res.json()).data;
}
// Poll until the job leaves queued/running, or fail the test after a deadline
async function settle(id, maxSeconds = 30) {
const deadline = Date.now() + maxSeconds * 1000;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/api/jobs/${id}`);
const job = await res.json();
if (!["queued", "running"].includes(job.status)) return job;
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
}
throw new Error(`job ${id} didn't finish within ${maxSeconds} s`);
}
test("a report job completes and its result becomes available", async () => {
const job = await start({ type: "report", durationSeconds: 1 });
const early = await fetch(`${BASE}/api/jobs/${job.id}/result`);
assert.equal(early.status, 409, "the result must not exist before the job finishes");
const done = await settle(job.id);
assert.equal(done.status, "completed");
assert.equal(done.progress, 100);
assert.ok(done.finishedAt >= done.startedAt);
const result = await fetch(`${BASE}${done.resultUrl}`);
assert.equal(result.status, 200);
assert.equal((await result.json()).jobId, job.id);
});
test("a failing job ends as failed, with an error and no result", async () => {
const job = await start({ type: "fail", durationSeconds: 1 });
const done = await settle(job.id);
assert.equal(done.status, "failed");
assert.match(done.error, /failure/i);
assert.equal(done.resultUrl, null);
assert.equal((await fetch(`${BASE}/api/jobs/${job.id}/result`)).status, 409);
});
test("a job can be cancelled once, while it is still queued", async () => {
const job = await start({ type: "report", durationSeconds: 10 });
const cancel = await fetch(`${BASE}/api/jobs/${job.id}`, { method: "DELETE" });
assert.equal(cancel.status, 200);
assert.equal((await cancel.json()).data.status, "cancelled");
const again = await fetch(`${BASE}/api/jobs/${job.id}`, { method: "DELETE" });
assert.equal(again.status, 409, "cancelling twice is a conflict");
});
test("bad input is refused before a job exists", async () => {
for (const body of [{ type: "nope", durationSeconds: 2 }, { type: "report", durationSeconds: 0 }, { type: "report", durationSeconds: 61 }]) {
const res = await fetch(`${BASE}/api/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
assert.equal(res.status, 400, JSON.stringify(body));
}
assert.equal((await fetch(`${BASE}/api/jobs/job_99999`)).status, 404);
});$ node --test jobs.test.mjs
✔ a report job completes and its result becomes available (3534.614ms)
✔ a failing job ends as failed, with an error and no result (3231.1622ms)
✔ a job can be cancelled once, while it is still queued (82.5166ms)
✔ bad input is refused before a job exists (127.767ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0Each test follows its job to the end and finishes as soon as the job does. The settle helper respects Retry-Afterand fails with a named job and a time limit, so a stuck job produces a readable error instead of a test runner timeout. The REST tutorial's lesson on automating tests with code covers the test runner itself.
Job test checklist
- Each terminal state: completed, failed and cancelled, with its fields checked.
- Results requested too early, after failure and after cancellation.
- Cancel twice, and cancel after completion.
- Progress only goes up, and a terminal job never changes again.
- Validation for every field, plus the request with no body.
- Every test waits with a deadline, never a fixed sleep.
Exercise
Add a test that cancels a job after it has started running: wait until its status is running, cancel it, then check the status three times over three seconds and assert that status and progress never change. Then add one for GET /api/jobs?status=failedsign in to see that checks every job in the list really has that status.