API TESTING

Testing Async APIs When There Is No Immediate Response

Most API testing tutorials assume a clean request-response cycle: you send a request, you get a body back, you assert on it. Async APIs break that assumption entirely. You submit a job, get back a 202 Accepted and a job ID, and then… nothing useful yet. The actual result shows up later — maybe in milliseconds, maybe in minutes — via polling, a webhook, or a status endpoint. I've seen teams struggle with this pattern more than almost any other, because the instinct is to treat async endpoints like sync ones and then wonder why tests are flaky or, worse, always green when they shouldn't be.

The core problem is timing. A test that asserts on a 202 and calls it a day isn't testing the thing that actually matters — it's testing that the API acknowledged your request, not that it did anything with it. Real coverage means following the async contract all the way through: submitting the job, polling or listening until the work is done, and then validating the final state. That requires deliberate design in your test code, not just a longer time.sleep().

In this article I'll walk through the three patterns that cover the vast majority of async API shapes in the wild: polling loops with backoff, webhook/callback validation, and event-driven status checks. Every example is in Python and structured so you can drop it into a pytest or Behave suite today. I'll also flag the common mistakes I see teams make at each stage — because with async testing, the failure modes are subtle in ways that sync testing just isn't.

Build an API Automation Framework With Node.js

Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.

Learn more

Polling a Job Status Endpoint Until the Work Is Actually Done

The most common async pattern is a two-step dance: POST /jobs returns 202 Accepted with a job ID, and GET /jobs/{id} returns the current status. Your test has to bridge those two calls with a polling loop. The naive version looks like this:

import time
import requests

def submit_and_wait(base_url, payload, timeout=30, interval=2):
    resp = requests.post(f"{base_url}/jobs", json=payload)
    assert resp.status_code == 202, f"Expected 202, got {resp.status_code}"
    job_id = resp.json()["job_id"]

    deadline = time.time() + timeout
    while time.time() < deadline:
        status_resp = requests.get(f"{base_url}/jobs/{job_id}")
        status_resp.raise_for_status()
        data = status_resp.json()
        if data["status"] in ("completed", "failed"):
            return data
        time.sleep(interval)

    raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")

This is a solid starting point, but there are two mistakes I see teams make here. The first is a flat sleep interval. A fixed 2-second wait is fine in development, but in CI it either wastes time on fast jobs or hammers a slow environment. Exponential backoff — doubling the interval each iteration up to a cap — is a much better habit. It also keeps you from accidentally tripping rate limits on status endpoints that enforce per-client request quotas.

def submit_and_wait_with_backoff(base_url, payload, timeout=60):
    resp = requests.post(f"{base_url}/jobs", json=payload)
    assert resp.status_code == 202
    job_id = resp.json()["job_id"]

    interval = 1
    deadline = time.time() + timeout
    while time.time() < deadline:
        data = requests.get(f"{base_url}/jobs/{job_id}").json()
        if data["status"] == "completed":
            return data
        if data["status"] == "failed":
            raise AssertionError(f"Job failed: {data.get('error')}")
        time.sleep(min(interval, 10))
        interval *= 2

    raise TimeoutError(f"Job {job_id} timed out")

The second mistake is not asserting on the terminal state itself. Once the job is "completed," your test still needs to validate the result payload — shape, required fields, value ranges. That's where automated schema validation pays off: rather than writing a wall of individual assertions, you define the expected shape once and validate the completed-job response against it. This catches regressions in the async result format that a simple status check will miss entirely.

One more thing worth building in: always assert on both terminal states. A test that only handles completed and loops forever on failed is going to cause you pain in CI. Make failed an explicit assertion failure with the error message from the response body — that message is usually the fastest path to diagnosing what broke.

Validating Webhooks and Callbacks That Your API Pushes to You

Some async APIs don't expect you to poll — they push. You register a callback URL at job submission time, and when the work is done the API sends a POST to that URL with the result. This is a cleaner contract for production systems, but it's harder to test because your test suite now needs to receive an HTTP request, not just send one.

The cleanest approach in a Python test suite is to spin up a lightweight local server in a pytest fixture and expose it via a tunneling tool during integration testing. For pure local or contract testing, http.server or a tiny Flask app in a thread works well:

import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import json

received_payloads = []

class CallbackHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers["Content-Length"])
        body = json.loads(self.rfile.read(length))
        received_payloads.append(body)
        self.send_response(200)
        self.end_headers()

    def log_message(self, *args):
        pass  # suppress noisy output in test runs

@pytest.fixture(scope="function")
def callback_server():
    server = HTTPServer(("localhost", 9876), CallbackHandler)
    thread = threading.Thread(target=server.serve_forever)
    thread.daemon = True
    thread.start()
    received_payloads.clear()
    yield "http://localhost:9876/callback"
    server.shutdown()

With that fixture in place, your test submits the job with the local callback URL, then waits for the payload to arrive:

def test_job_posts_callback(callback_server):
    requests.post(f"{BASE_URL}/jobs", json={
        "task": "export",
        "callback_url": callback_server
    })

    deadline = time.time() + 30
    while time.time() < deadline:
        if received_payloads:
            result = received_payloads[0]
            assert result["status"] == "completed"
            assert "output_url" in result
            return
        time.sleep(1)

    pytest.fail("Callback was never received within 30 seconds")

The mistake I see most often here is not validating the callback payload at all — teams confirm the callback fired and move on. But the shape and content of that pushed payload is part of the API contract. Validate it the same way you'd validate a polling response: required fields, correct types, no unexpected nulls. If the API also signs its webhooks (HMAC signatures are common), your test should verify that signature too — a callback with a bad signature should fail the test, not pass it silently.

For async APIs that also handle financial operations, the callback payload often carries idempotency keys or transaction IDs. Testing that those are present and consistent is worth the effort — it's the same discipline you'd apply when testing idempotency in payment or order APIs, where duplicate callbacks must not produce duplicate side effects.

Structuring Async Tests So They Don't Become Flaky Nightmares in CI

Even with solid polling logic and webhook receivers, async tests have a structural problem: they're inherently time-dependent. The same test that passes in two seconds locally might time out in CI because the environment is slower, the queue is backed up, or a cold-start adds latency. Here's how I structure async tests to stay reliable without making them artificially slow.

Separate the submission assertion from the completion assertion. Don't put both in one test function if they test different things. The 202 contract is its own assertion — it tells you the API accepted the request. The completion contract is a different assertion — it tells you the API processed it correctly. Keeping them in separate test cases makes failure diagnosis much faster.

# test_job_submission.py
def test_submit_returns_202_with_job_id():
    resp = requests.post(f"{BASE_URL}/jobs", json={"task": "report"})
    assert resp.status_code == 202
    body = resp.json()
    assert "job_id" in body
    assert isinstance(body["job_id"], str)

# test_job_completion.py
def test_completed_job_has_valid_output(submitted_job_id):
    result = poll_until_done(submitted_job_id, timeout=60)
    assert result["status"] == "completed"
    assert "output_url" in result
    assert result["output_url"].startswith("https://")

Use a shared fixture for job submission. If multiple tests need a completed job, submit once in a session-scoped or module-scoped fixture and share the result. Submitting a fresh job per test multiplies your CI runtime and your queue load. One job, many assertions.

Set explicit, documented timeouts — and make them configurable. Hard-coding timeout=30 everywhere makes it impossible to tune for different environments. Pull timeouts from environment variables or a config file:

import os

ASYNC_TIMEOUT = int(os.getenv("ASYNC_TEST_TIMEOUT", "60"))
POLL_INTERVAL = int(os.getenv("ASYNC_POLL_INTERVAL", "2"))

This lets your CI pipeline set a tighter timeout for fast environments and a looser one for staging without touching test code.

Always assert on the error path. Submit a job with invalid input and confirm it reaches a failed terminal state with a meaningful error message. Async APIs that silently drop bad requests are a common source of production bugs, and they're trivially easy to catch in a test if you think to write it.

One last pattern worth mentioning: if your async API requires authenticated requests, manage those credentials the same disciplined way you would for any other protected endpoint — stored in environment variables, never in source control. The same practices that keep you safe when testing OAuth-protected APIs without hardcoding tokens apply just as much when those tokens are being used across a multi-step async flow.

Async API testing isn't fundamentally harder than synchronous testing — it just requires you to be explicit about time, terminal states, and the full shape of deferred responses. Build the polling helpers and callback receivers once, make them configurable, and your async tests will be as reliable as any other part of your suite.