How to Test a Spatial API with pytest and httpx
On this page
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 3.4 MB, which measured at 14.2 requests per second against 47.6 for an 859 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:
%%writefile test_service.py
import pytest
from fastapi.testclient import TestClient
import service
from service import app
@pytest.fixture(scope="module")
def client(layer):
service.LAYER = layer # the service serves whatever layer it is given
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"
The %%writefile lines build the suite one block at a time, as a notebook would; the last block on the page runs it. No server to start, no port to choose, no flakiness. For async endpoints, httpx.AsyncClient with an ASGITransport does the same thing.
What you get β from The service under test
Writing service.py
Tiny fixtures, response shape, coordinate order, filters
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:
%%writefile -a test_service.py
import geopandas as gpd
from shapely.geometry import Point, Polygon
@pytest.fixture(scope="session")
def layer():
return gpd.GeoDataFrame(
{"id": [1, 2, 3, 4, 5],
"name": ["London", "Paris", "New York", "Sydney", "Γ-square"], # non-ASCII on purpose
"category": ["x", "x", "y", "y", "y"]},
geometry=[Point(-0.1276, 51.5072),
Point(2.3522, 48.8566),
Point(-74.0060, 40.7128),
Point(151.2093, -33.8688), # southern and eastern hemisphere
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])],
crs="EPSG:4326")
Five features covering all four hemispheres, a non-ASCII name and a mixed geometry type is enough to catch most encoding, CRS and serialisation bugs. The first version of this fixture named its points "A", "B" and "C" while the coordinate test below looked them up by city name β the suite could never have passed.
2. Test the shape of the response, not just the status
%%writefile -a test_service.py
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:
%%writefile -a test_service.py
KNOWN = {"London": (-0.1276, 51.5072), "New York": (-74.0060, 40.7128),
"Sydney": (151.2093, -33.8688)}
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
%%writefile -a test_service.py
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
%%writefile -a test_service.py
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
%%writefile -a test_service.py
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):
five = client.get("/features?limit=5").headers["etag"]
ten = client.get("/features?limit=10").headers["etag"]
assert five != ten
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", "*")
Async tests, payload budgets and a real server
The service under test
The suite needs something to test. This is a deliberately small feature service with the behaviours the tests check: a bbox and name filter, paging with next links, counts, ETags, CORS for one origin and gzip. It serves a module-level LAYER, which the client fixture replaces with the test layer.
%%writefile service.py
import hashlib
import json
from urllib.parse import urlencode
import geopandas as gpd
from fastapi import FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from shapely.geometry import Point, Polygon, box
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(CORSMiddleware, allow_origins=["https://maps.example.org"], allow_methods=["GET"])
LAYER = gpd.GeoDataFrame(
{"id": [1, 2, 3, 4, 5], "name": ["London", "Paris", "New York", "Sydney", "Γ-square"],
"category": ["x", "x", "y", "y", "y"]},
geometry=[Point(-0.1276, 51.5072), Point(2.3522, 48.8566), Point(-74.0060, 40.7128),
Point(151.2093, -33.8688), Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])],
crs="EPSG:4326")
def parse_bbox(raw):
try:
x0, y0, x1, y1 = (float(v) for v in raw.split(","))
except ValueError:
raise HTTPException(400, "bbox must be minx,miny,maxx,maxy")
if x0 >= x1 or y0 >= y1:
raise HTTPException(400, "bbox min must be less than max")
if not (-180 <= x0 <= 180 and -180 <= x1 <= 180 and -90 <= y0 <= 90 and -90 <= y1 <= 90):
raise HTTPException(400, "bbox is outside the valid CRS84 range")
return x0, y0, x1, y1
@app.get("/health")
def health():
return {"ok": True}
@app.get("/features")
def features(request: Request, limit: int = Query(10, ge=1, le=1000),
offset: int = Query(0, ge=0), bbox: str | None = None, name: str | None = None):
subset = LAYER
if bbox:
subset = subset[subset.intersects(box(*parse_bbox(bbox)))]
if name:
subset = subset[subset["name"] == name]
page = subset.iloc[offset:offset + limit].set_index("id")
body = json.loads(page.to_json())
body["numberMatched"], body["numberReturned"] = len(subset), len(page)
query = dict(request.query_params)
body["links"] = [{"rel": "self", "href": "/features?" + urlencode(query)}]
if offset + limit < len(subset):
body["links"].append({"rel": "next", "href": "/features?" + urlencode(
{**query, "offset": offset + limit, "limit": limit})})
text = json.dumps(body, ensure_ascii=False)
etag = '"' + hashlib.sha256(text.encode()).hexdigest()[:16] + '"'
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers={"ETag": etag})
return Response(text, media_type="application/geo+json", headers={"ETag": etag})
Writing service.py
Example 1 β an async client for an async service
%%writefile -a test_service.py
import httpx
@pytest.fixture
def anyio_backend():
return "asyncio" # or anyio also tries trio, which may not be installed
@pytest.fixture
async def async_client(layer):
service.LAYER = layer
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, 2, 3, 4)])
for response, n in zip(responses, (1, 2, 3, 4)):
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
%%writefile -a test_service.py
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 3.4 MB page measured at 14.2 requests per second against 47.6 "
f"for an 859 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
%%writefile -a test_service.py
import socket
import subprocess
import sys
import time
@pytest.fixture(scope="session")
def live_server():
"""For anything TestClient cannot exercise: middleware ordering, gzip on the
wire, real concurrency, worker behaviour."""
with socket.socket() as probe: # a free port, not a fixed one
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
process = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "service:app", "--port", str(port),
"--log-level", "error"])
url = f"http://127.0.0.1:{port}"
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, an 859 kB page became 301 kB")
Running the whole suite the page has built:
import sys
!{sys.executable} -m pytest -q -p no:cacheprovider test_service.py 2>&1 | tail -4
................... [100%]
19 passed in 1.08s
TestClient decodes transparently, so gzip on the wire is one of the few things that genuinely needs a real server.
Why TestClient is enough almost always
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 two-thirds β measured on the filtering page, dropping six-decimal rounding took the full province layer from 32.38 MB back to 54.29 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.
Transparent gzip and shared module state
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 32.38 MB to 54.29 MB, and a 3.4 MB page ran at 14.2 requests per second against 47.6 for an 859 kB 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.