DevLab / testing / async-api-test-suite-with-httpx
TESTING PYTHON INTERMEDIATE

Async API Test Suite with httpx

Synchronous API test suites execute HTTP requests sequentially, creating severe bottlenecks when running integration tests against dozens or hundreds of RE

Python3.11+
OSLinux / macOS / Windows
Lines~104
Packages3 deps
Install Dependencies
$ pip install httpx pytest pytest-asyncio
Usage
$ python3 -m pytest test_async_api.py -v --asyncio-mode=auto
#testing#python#intermediate#httpx#pytest#pytest

Problem Statement

Synchronous API test suites execute HTTP requests sequentially, creating severe bottlenecks when running integration tests against dozens or hundreds of REST endpoints. In CI/CD pipelines, long test execution times delay deployment feedback loops and inflate runner compute costs. Standard synchronous HTTP client libraries (like traditional requests) block thread execution during network I/O, leading to idle CPU time and poor resource utilization.

An asynchronous testing architecture built with httpx and pytest-asyncio eliminates these performance bottlenecks. By executing non-blocking HTTP requests concurrently over Python's asyncio event loop, teams can reduce API test suite execution times significantly while simultaneously validating how target services handle parallel connection load.


How It Works

The test suite relies on httpx.AsyncClient managed inside a reusable pytest fixture (api_client). The fixture handles initialization of persistent HTTP connection pools, default request headers (such as Authorization tokens), connection timeouts, and proper resource teardown.

Individual test cases mark execution as asynchronous via pytest-asyncio. Tests validate API endpoint behavior including standard status code checks, payload schema assertions, latency benchmarking, and explicit error state handling (e.g., HTTP 404 response verification). For parallel endpoint testing, asyncio.gather() dispatches multiple asynchronous requests in parallel over the single underlying connection pool.


Full Script

#!/usr/bin/env python3
"""
Async API Test Suite with httpx

A production-ready asynchronous API integration test suite built with httpx and pytest.
Executes HTTP requests concurrently, evaluates endpoint response times, validates JSON payloads,
and manages persistent client connection pools.
"""

import asyncio
import os
import sys
import time
from typing import AsyncGenerator, Dict, Any, List
import httpx
import pytest

# Config section: load configuration from environment variables with defaults
BASE_URL: str = os.environ.get("TEST_API_BASE_URL", "https://jsonplaceholder.typicode.com").rstrip("/")
TIMEOUT_SECONDS: float = float(os.environ.get("TEST_TIMEOUT", "10.0"))
AUTH_TOKEN: str = os.environ.get("TEST_AUTH_TOKEN", "mock-bearer-token-12345")


@pytest.fixture
async def api_client() -> AsyncGenerator[httpx.AsyncClient, None]:
    """
    Pytest fixture providing an AsyncClient instance configured with base URL,
    default headers, connection pooling limits, and request timeouts.
    """
    headers = {
        "Authorization": f"Bearer {AUTH_TOKEN}",
        "Content-Type": "application/json",
        "User-Agent": "AsyncApiTestSuite/1.0",
    }
    limits = httpx.Limits(max_keepalive_connections=10, max_connections=20)

    # Yield client within context manager to guarantee proper connection closing on exit
    async with httpx.AsyncClient(
        base_url=BASE_URL,
        headers=headers,
        timeout=httpx.Timeout(TIMEOUT_SECONDS),
        limits=limits,
        follow_redirects=True,
    ) as client:
        yield client


@pytest.mark.asyncio
async def test_get_posts_list(api_client: httpx.AsyncClient) -> None:
    """Validate GET request returns HTTP 200, valid payload list, and acceptable response time."""
    start_time = time.monotonic()
    response = await api_client.get("/posts")
    elapsed = time.monotonic() - start_time

    assert response.status_code == 200, f"Expected status 200, got {response.status_code}"
    assert response.headers["content-type"].startswith("application/json")

    data: List[Dict[str, Any]] = response.json()
    assert isinstance(data, list), "Response payload should be a JSON array"
    assert len(data) > 0, "Response list should not be empty"
    assert "id" in data[0], "Resource item missing mandatory 'id' key"
    assert elapsed < 3.0, f"Request target latency exceeded limit: took {elapsed:.2f}s"


@pytest.mark.asyncio
async def test_create_post(api_client: httpx.AsyncClient) -> None:
    """Validate POST request correctly creates a new resource payload."""
    payload = {
        "title": "Async Testing with httpx",
        "body": "Production-ready asynchronous integration testing.",
        "userId": 1,
    }

    response = await api_client.post("/posts", json=payload)
    assert response.status_code == 201, f"Expected status 201 Created, got {response.status_code}"

    data: Dict[str, Any] = response.json()
    assert data.get("title") == payload["title"]
    assert data.get("body") == payload["body"]
    assert data.get("userId") == payload["userId"]
    assert "id" in data, "Created resource response missing assigned ID"


@pytest.mark.asyncio
async def test_concurrent_fetching(api_client: httpx.AsyncClient) -> None:
    """Execute multiple GET requests concurrently using asyncio.gather to test parallel performance."""
    post_ids = [1, 2, 3, 4, 5]

    # Schedule multiple requests across the event loop concurrently
    tasks = [api_client.get(f"/posts/{post_id}") for post_id in post_ids]
    start_time = time.monotonic()
    responses: List[httpx.Response] = await asyncio.gather(*tasks)
    total_time = time.monotonic() - start_time

    assert len(responses) == len(post_ids)
    for resp in responses:
        assert resp.status_code == 200
        assert "id" in resp.json()

    # Parallel requests should complete significantly faster than 5 sequential requests
    assert total_time < 4.0, f"Concurrent execution took unexpectedly long: {total_time:.2f}s"


@pytest.mark.asyncio
async def test_not_found_error_handling(api_client: httpx.AsyncClient) -> None:
    """Verify endpoint correctly returns HTTP 404 for non-existent resources."""
    response = await api_client.get("/posts/99999999")
    assert response.status_code == 404, f"Expected status 404, got {response.status_code}"


@pytest.mark.asyncio
async def test_update_post_put(api_client: httpx.AsyncClient) -> None:
    """Validate PUT endpoint correctly handles resource mutation."""
    updated_payload = {
        "id": 1,
        "title": "Updated Async Title",
        "body": "Updated asynchronous content body.",
        "userId": 1,
    }
    response = await api_client.put("/posts/1", json=updated_payload)
    assert response.status_code == 200, f"Expected status 200, got {response.status_code}"

    data = response.json()
    assert data["title"] == updated_payload["title"]
    assert data["body"] == updated_payload["body"]


if __name__ == "__main__":
    # Allows direct script execution via standard python command
    sys.exit(pytest.main([__file__, "-v", "--asyncio-mode=auto"]))

Configuration

The test suite behavior is controlled via environment variables. If not explicitly set, fallback defaults target the public JSONPlaceholder API service.

Environment Variable Description Default Value
TEST_API_BASE_URL Base endpoint URL for target REST API https://jsonplaceholder.typicode.com
TEST_TIMEOUT Network request timeout threshold in seconds 10.0
TEST_AUTH_TOKEN Authorization bearer token passed in headers mock-bearer-token-12345

Usage Examples

1. Execute Default Test Suite via pytest Command

Run tests using the standard pytest module runner with explicit asyncio auto mode:

python3 -m pytest test_async_api.py -v --asyncio-mode=auto

Expected Output:

============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
asyncio: mode=Mode.AUTO
collected 5 items

test_async_api.py::test_get_posts_list PASSED                            [ 20%]
test_async_api.py::test_create_post PASSED                               [ 40%]
test_async_api.py::test_concurrent_fetching PASSED                      [ 60%]
test_async_api.py::test_not_found_error_handling PASSED                 [ 80%]
test_async_api.py::test_update_post_put PASSED                          [100%]

============================== 5 passed in 0.85s ===============================

2. Run against a Custom Environment via Inline Environment Variables

Target a staging or local microservice API environment with a lower request timeout limit:

TEST_API_BASE_URL="http://localhost:8080/api/v1" TEST_TIMEOUT="3.0" TEST_AUTH_TOKEN="jwt-secret-xyz" python3 -m pytest test_async_api.py -v --asyncio-mode=auto

3. Run Directly as a Python Executable

Because the script contains a pytest.main() entrypoint, it can be executed directly as a Python module:

python3 test_async_api.py

Error Handling

  • Timeout Management: httpx.Timeout(TIMEOUT_SECONDS) is passed to the client instance. If an API request exceeds the specified limit, httpx.ReadTimeout or httpx.ConnectTimeout is raised, causing the test to fail cleanly rather than hanging indefinitely.
  • Connection Pooling Limits: Built-in limits (httpx.Limits(max_keepalive_connections=10, max_connections=20)) prevent local file descriptor exhaustion during parallel request execution via asyncio.gather.
  • Resource Cleanup: The api_client fixture uses an asynchronous context manager (async with). This guarantees HTTP connections and transport pools are cleanly disconnected even if assertions fail or unhandled exceptions occur during test execution.
  • HTTP Status Validation: Negative test cases (e.g., test_not_found_error_handling) explicitly assert error status codes like 404 Not Found to prevent false negatives when an API correctly returns client error responses.

Extending Ideas

  1. Schema Validation: Integrate pydantic models or jsonschema validation to verify API JSON response body contracts strictly against defined schemas.
  2. Automatic Retries with Backoff: Wrap client calls using tenacity or custom httpx.AsyncHTTPTransport hooks to automatically retry transient HTTP 502/503/504 status codes before marking a test failed.
  3. Dynamic Auth Token Retrieval: Modify the api_client fixture to perform an initial asynchronous OAuth2 token request (e.g., POST /oauth/token) and inject the returned JWT token dynamically into subsequent test headers.
  4. HTML / JUnit Test Reporting: Append --html=report.html --self-contained-html or --junitxml=results.xml flags to the command line invocation for CI pipeline integration output.
// RELATED_SCRIPTS
← Back to DevLab valtersit.com