How to Test a Spatial API with pytest and httpx

Problem statement

A spatial API has failure modes a normal REST test suite does not look for:

  • coordinates in the wrong order, which produces a syntactically perfect response describing a point in the Indian Ocean
  • a next link that drops the bbox, so paging silently starts returning the whole dataset
  • an ETag that does not depend on the query, so a cache serves one query's answer for another
  • a response that is correct and 12.4 MB, which measured at 2.3 requests per second against 32.5 for a 857 kB page
  • a CORS configuration that works in curl and fails in a browser

None of these are caught by assert response.status_code == 200. All of them are caught by about thirty lines of tests that run in a second and never touch a network.

Quick answer

FastAPI's TestClient (httpx underneath) runs the whole application in-process:

import pytest
from fastapi.testclient import TestClient
from service import app


@pytest.fixture(scope="module")
def client():
    return TestClient(app)


def test_features_are_geojson(client):
    body = client.get("/features?limit=5").json()
    assert body["type"] == "FeatureCollection"
    assert len(body["features"]) == 5
    assert body["numberMatched"] >= body["numberReturned"]


def test_coordinates_are_lon_lat(client):
    feature = client.get("/features?limit=1").json()["features"][0]
    lon, lat = feature["geometry"]["coordinates"][:2]
    assert -180 <= lon <= 180 and -90 <= lat <= 90
    assert abs(lat) <= 90, "latitude out of range โ€” the pair is probably swapped"

No server to start, no port to choose, no flakiness. For async endpoints, httpx.AsyncClient with an ASGITransport does the same thing.

Checklist of seven spatial API test concerns beyond status codes.
assert response.status_code == 200 catches none of these.

Step-by-step solution

1. Build fixtures from a tiny, known dataset

A test suite that reads the production layer is slow, non-deterministic and untestable in CI. Build a fixture with the awkward cases in it:

import geopandas as gpd
from shapely.geometry import Point, Polygon


@pytest.fixture(scope="session")
def layer():
    return gpd.GeoDataFrame(
        {"id": [1, 2, 3, 4],
         "name": ["A", "B", "C", "ร˜"],           # non-ASCII on purpose
         "category": ["x", "x", "y", "y"]},
        geometry=[Point(-0.1276, 51.5072),        # London
                  Point(2.3522, 48.8566),         # Paris
                  Point(-74.0060, 40.7128),       # New York
                  Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])],
        crs="EPSG:4326")

Four features covering two hemispheres, a non-ASCII name and a mixed geometry type is enough to catch most encoding, CRS and serialisation bugs.

2. Test the shape of the response, not just the status

def test_response_shape(client):
    body = client.get("/features?limit=2").json()
    assert body["type"] == "FeatureCollection"
    assert body["numberReturned"] == len(body["features"]) == 2
    for feature in body["features"]:
        assert feature["type"] == "Feature"
        assert feature["geometry"]["type"] in {"Point", "Polygon", "MultiPolygon",
                                               "LineString", "MultiLineString"}
        assert isinstance(feature["properties"], dict)

numberReturned == len(features) is the assertion that catches a paging bug where the count and the content disagree.

3. Test coordinate order explicitly

Swapped coordinates are the most common spatial API bug and the least visible:

KNOWN = {"London": (-0.1276, 51.5072), "New York": (-74.0060, 40.7128)}


def test_known_coordinates(client):
    for name, (lon, lat) in KNOWN.items():
        body = client.get(f"/features?name={name}").json()
        got_lon, got_lat = body["features"][0]["geometry"]["coordinates"][:2]
        assert abs(got_lon - lon) < 0.01, f"{name}: longitude wrong โ€” swapped pair?"
        assert abs(got_lat - lat) < 0.01, f"{name}: latitude wrong"

One known coordinate per hemisphere is the cheapest regression test for an entire class of CRS and axis-order bugs.

4. Test the filters, including the empty case

def test_bbox_filters(client):
    everything = client.get("/features?limit=1000").json()["numberMatched"]
    europe = client.get("/features?bbox=-10,35,30,60&limit=1000").json()
    assert europe["numberMatched"] < everything
    assert all(-10 <= f["geometry"]["coordinates"][0] <= 30
               for f in europe["features"]
               if f["geometry"]["type"] == "Point")


def test_bbox_with_no_matches_is_an_empty_collection(client):
    body = client.get("/features?bbox=170,-80,175,-75").json()
    assert body["type"] == "FeatureCollection"
    assert body["features"] == []
    assert body["numberMatched"] == 0


@pytest.mark.parametrize("bad", ["1,2,3", "a,b,c,d", "10,50,0,60", "0,0,400,400"])
def test_invalid_bbox_is_a_400(client, bad):
    assert client.get(f"/features?bbox={bad}").status_code == 400

The empty case matters: a bounding box matching nothing should be an empty FeatureCollection, not a 404 and not a null.

def test_paging_does_not_overlap_or_lose_features(client):
    seen, url = [], "/features?limit=2"
    for _ in range(10):
        body = client.get(url).json()
        seen.extend(f["id"] for f in body["features"])
        next_link = next((l for l in body["links"] if l["rel"] == "next"), None)
        if not next_link:
            break
        url = next_link["href"]

    assert len(seen) == len(set(seen)), "pages overlap"
    assert len(seen) == client.get("/features?limit=1").json()["numberMatched"]


def test_next_link_keeps_the_filter(client):
    body = client.get("/features?bbox=-10,35,30,60&limit=1").json()
    next_link = next(l for l in body["links"] if l["rel"] == "next")
    assert "bbox=" in next_link["href"], (
        "the next link dropped the bbox โ€” paging will return the whole dataset")

The second test catches the single commonest paging bug in feature services.

6. Test the caching and CORS headers

def test_conditional_request_returns_304(client):
    first = client.get("/features?limit=5")
    second = client.get("/features?limit=5",
                        headers={"If-None-Match": first.headers["etag"]})
    assert second.status_code == 304 and second.content == b""


def test_etag_depends_on_the_query(client):
    assert (client.get("/features?limit=5").headers["etag"]
            != client.get("/features?limit=10").headers["etag"])


def test_cors_preflight(client):
    response = client.options("/features", headers={
        "Origin": "https://maps.example.org",
        "Access-Control-Request-Method": "GET"})
    assert response.status_code in (200, 204)
    assert response.headers["access-control-allow-origin"] in (
        "https://maps.example.org", "*")
Two map panels showing a transposed London coordinate and the correct one.
Sydney belongs in the test set too โ€” it catches sign errors London does not.

Code examples

Example 1 โ€” an async client for an async service

import pytest
import httpx
from service import app


@pytest.fixture
async def async_client():
    transport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport,
                                 base_url="http://test") as client:
        yield client


@pytest.mark.anyio
async def test_concurrent_requests_do_not_interfere(async_client):
    import asyncio
    responses = await asyncio.gather(*[
        async_client.get(f"/features?limit={n}") for n in (1, 5, 10, 20)])
    for response, n in zip(responses, (1, 5, 10, 20)):
        assert len(response.json()["features"]) == n

The concurrency test is worth having on any service with module-level state: a shared GeoDataFrame that a request mutates shows up here and nowhere else.

Example 2 โ€” a payload-size regression test

import gzip

BUDGETS_KB = {"/features?limit=50": 1200, "/features?bbox=-1,51,0,52": 200}


@pytest.mark.parametrize("path,budget", BUDGETS_KB.items())
def test_response_stays_within_budget(client, path, budget):
    body = client.get(path, headers={"Accept-Encoding": "identity"}).content
    kb = len(body) / 1024
    assert kb <= budget, (
        f"{path} returned {kb:,.0f} kB, budget is {budget:,} kB. "
        f"A 12.4 MB page measured at 2.3 requests per second against 32.5 "
        f"for a 857 kB one.")
    print(f"{path}: {kb:,.0f} kB raw, {len(gzip.compress(body, 6)) / 1024:,.0f} kB gzipped")

Payload size is a performance characteristic that silently regresses when somebody adds a column. A budget test turns that into a failing build.

Example 3 โ€” testing against a real server when you must

import subprocess
import sys
import time
import httpx
import pytest


@pytest.fixture(scope="session")
def live_server():
    """For anything TestClient cannot exercise: middleware ordering, gzip on the
    wire, real concurrency, worker behaviour."""
    process = subprocess.Popen(
        [sys.executable, "-m", "uvicorn", "service:app", "--port", "8765",
         "--log-level", "error"])
    url = "http://127.0.0.1:8765"
    for _ in range(50):
        try:
            httpx.get(f"{url}/health", timeout=1)
            break
        except httpx.HTTPError:
            time.sleep(0.2)
    else:
        process.terminate()
        pytest.fail("the server did not start")

    yield url
    process.terminate()
    process.wait(timeout=10)


def test_gzip_on_the_wire(live_server):
    response = httpx.get(f"{live_server}/features?limit=50",
                         headers={"Accept-Encoding": "gzip"})
    assert response.headers.get("content-encoding") == "gzip"
    wire = int(response.headers["content-length"])
    assert wire < len(response.content), (
        "the wire body should be smaller than the decoded one; "
        "measured elsewhere, 857,061 bytes became 299,123")

TestClient decodes transparently, so gzip on the wire is one of the few things that genuinely needs a real server.

Explanation

Why TestClient is enough for almost everything

It runs the ASGI application in-process, so there is no port, no start-up race and no network flakiness. Tests execute in milliseconds, which means they get run.

The exceptions are narrow: content encoding on the wire, middleware that depends on the server, worker-level behaviour, and anything involving real concurrency across processes. For those, start a real server in a fixture โ€” and keep that suite small, because it is slower and less reliable by construction.

Why coordinate-order tests belong in every spatial suite

A swapped coordinate pair produces a valid GeoJSON document with numbers in the right ranges. Nothing about the response is malformed; the point is simply in the wrong place, frequently in an ocean.

A single assertion against a known coordinate catches it, and it catches the whole family: a CRS transform without always_xy, a database function that takes latitude first, a client library with a different convention.

Why the payload budget is a test and not a monitoring concern

Response size regresses silently. Somebody adds a property to the serialiser, or removes a coordinate rounding step, and the responses grow by 40% โ€” measured, dropping the coordinate rounding took a payload from 29.56 MB back to 54.06 MB.

Monitoring catches that a week later, in production. A budget assertion catches it in the pull request that caused it, with a message naming the endpoint.

A test that constructs ?offset=50 tests the offset parameter. A test that follows the next link tests what a real client does โ€” and it is the only way to catch a next link that drops the filter, which is the commonest paging bug in feature services and completely invisible in a status-code assertion.

Two panels dividing test concerns between TestClient and a live server.
TestClient decodes gzip transparently, so compression needs the real thing.

Edge cases or notes

  • TestClient decodes gzip transparently โ€” test compression against a real server.
  • Module-level state is shared between tests. Reset it in a fixture or use a fresh app.
  • pytest.mark.anyio (or anyio_backend) is needed for async tests with httpx.
  • Test the empty case: a filter matching nothing is an empty collection, not a 404.
  • Parameterise the invalid inputs โ€” malformed, reversed and out-of-range bounding boxes.
  • Assert numberReturned == len(features) to catch count/content divergence.
  • Include a non-ASCII name in the fixture to catch encoding bugs.
  • Keep the live-server suite small; it is slower and flakier by nature.

FAQ

Do I need a running server to test a FastAPI spatial API?

No. TestClient runs the application in-process. Start a real server only for content encoding on the wire, middleware ordering and genuine concurrency.

What should a spatial API test suite check beyond status codes?

Response shape, coordinate order against known points, filter behaviour including the empty case, paging by following links, ETag correctness, CORS preflight and payload size.

How do I test that coordinates are the right way round?

Assert a known location: London is roughly (โˆ’0.1276, 51.5072) as lon, lat. A swapped pair puts it in the Indian Ocean, and one assertion catches the whole class of bug.

Why test payload size?

Because it regresses silently. Removing a coordinate-rounding step took a measured payload from 29.56 MB to 54.06 MB, and a page of 1,000 features ran at 2.3 requests per second against 32.5 for a small one.

What is the commonest paging bug?

A next link that drops the query filters, so the second page returns the whole dataset. Only a test that follows the link finds it.

How do I test gzip?

Against a real server. TestClient decodes content encoding transparently, so the compressed body never reaches the assertion.