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
nextlink that drops thebbox, 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.
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.
5. Test paging by following the links
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", "*")
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.
Why paging tests must follow the links
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.
Edge cases or notes
TestClientdecodes 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(oranyio_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.
Internal links
- How to implement OGC API Features in Python โ the service being tested
- How to build a GeoJSON API with FastAPI โ the simpler service
- How to paginate a large feature API in Python โ what the paging tests cover
- Fixing coordinates in the wrong order in an API response โ the bug the known-coordinate test catches
- Fixing CORS errors on a spatial API โ the preflight test
- How to set cache headers on a spatial API and tile service โ the ETag tests
- How to test a GIS pipeline with pytest โ the wider testing approach
- GIS test fixtures explained โ building small, awkward fixtures
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.