Faking Tile Server Responses in Python Tests
Tiles are the one geospatial fixture you should almost never store. A single zoom level of a small city is thousands of files, the bytes are opaque, and a reviewer cannot tell from a diff whether a fixture changed meaningfully or was merely re-encoded. This guide sits beneath mocking geospatial data for tests and covers generating tile responses on demand, deterministically, from the tile coordinate itself.
The technique is simple and the reason it works is worth stating: a tile is addressed by three integers, so the tile’s contents can be a pure function of those integers. That makes the fake stateless, makes every response reproducible without storage, and makes assertions about content possible — you can compute what the tile should contain and compare, instead of comparing bytes to a blob nobody understands.
Root cause: tile fixtures are opaque and numerous
Recording tiles from a live server produces a directory that is large, unreviewable, and stale within a release. Worse, it encourages tests that assert on byte equality, which fail whenever the encoder version changes for reasons unrelated to correctness.
The fix is to treat the tile coordinate as the seed. z/x/y uniquely identifies the tile, so a deterministic function of those three integers gives a tile that is always the same for a given address and always different from its neighbours — which is precisely what a test needs to tell whether the client fetched the right one.
Step-by-step implementation
Step 1 — Generate a raster tile from its coordinate
Encode the tile address into the pixels so the test can read it back:
import io
import numpy as np
from PIL import Image
TILE_PX = 256
def raster_tile(z: int, x: int, y: int) -> bytes:
"""A 256x256 PNG whose pixel values encode its own address."""
# Channel values derived from the address: unique per tile, stable across runs.
r = (x * 37) % 256
g = (y * 61) % 256
b = (z * 17) % 256
arr = np.empty((TILE_PX, TILE_PX, 3), dtype=np.uint8)
arr[..., 0] = r
arr[..., 1] = g
arr[..., 2] = b
# A one-pixel border makes seams and off-by-one placement visible.
arr[0, :, :] = arr[-1, :, :] = arr[:, 0, :] = arr[:, -1, :] = 255
buf = io.BytesIO()
Image.fromarray(arr).save(buf, format="PNG", optimize=False)
return buf.getvalue()
def address_of(png: bytes) -> tuple[int, int, int]:
"""Recover (r, g, b) from a tile so a test can assert which tile it got."""
arr = np.asarray(Image.open(io.BytesIO(png)).convert("RGB"))
return tuple(int(v) for v in arr[TILE_PX // 2, TILE_PX // 2])
A test can now fetch a tile and assert it received tile (12, 2045, 1372) rather than asserting it received 4,096 bytes. That is the difference between a test that catches a coordinate bug and one that catches nothing.
Step 2 — Generate a vector tile the same way
Mapbox Vector Tiles are protobuf, and encoding one by hand is unpleasant; mapbox-vector-tile does it in a line. The geometry should be in tile-local coordinates — the 0–4096 extent space — which is itself a useful thing for a test to exercise, because converting from geographic coordinates to tile space is where client code goes wrong.
import mapbox_vector_tile
EXTENT = 4096
def vector_tile(z: int, x: int, y: int, n: int = 3) -> bytes:
"""A tile holding n points at predictable positions, each labelled with its index."""
step = EXTENT // (n + 1)
features = [
{
"geometry": f"POINT({step * (i + 1)} {step * (i + 1)})",
"properties": {"idx": i, "z": z, "x": x, "y": y},
}
for i in range(n)
]
return mapbox_vector_tile.encode({
"name": "test_layer",
"features": features,
"extent": EXTENT,
})
Carrying z, x, and y through as feature properties means a decoded feature identifies the tile it came from, so a test that stitches several tiles together can assert on the provenance of each feature rather than only on the total count.
Step 3 — Serve them, and record what was asked for
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
TILE_RE = re.compile(r"^/tiles/(\d+)/(\d+)/(\d+)\.(png|mvt|pbf)$")
class TileHandler(BaseHTTPRequestHandler):
requested: list = []
max_zoom: int = 14
def do_GET(self): # noqa: N802
match = TILE_RE.match(self.path)
if not match:
return self._send(404, b"", "text/plain")
z, x, y, ext = int(match[1]), int(match[2]), int(match[3]), match[4]
self.requested.append((z, x, y))
if z > self.max_zoom or x >= 2 ** z or y >= 2 ** z:
# Out of range is a real 404 — the address cannot exist.
return self._send(404, b"", "text/plain")
if (x + y) % 7 == 0:
# Empty tile: HTTP 204, not 404. See below.
return self._send(204, b"", "application/octet-stream")
if ext == "png":
return self._send(200, raster_tile(z, x, y), "image/png")
return self._send(200, vector_tile(z, x, y),
"application/vnd.mapbox-vector-tile")
def _send(self, code, body, content_type):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if body:
self.wfile.write(body)
def log_message(self, *args):
pass
@pytest.fixture
def tile_server():
TileHandler.requested = []
server = ThreadingHTTPServer(("127.0.0.1", 0), TileHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
host, port = server.server_address
yield f"http://{host}:{port}/tiles", TileHandler.requested
server.shutdown()
Port 0 gives each test its own server, which keeps the fixture safe under parallel workers and removes the ordering coupling a fixed port would introduce.
The distinction that breaks clients: 204 versus 404
An empty tile and a missing tile are different facts, and tile clients treat them differently. HTTP 204 means this address is valid and contains nothing — the ocean, a gap in coverage, a zoom level where the layer has no features. HTTP 404 means this address does not exist. A client that treats 204 as an error shows holes where there should be empty space; a client that treats 404 as empty hides a genuine misconfiguration.
The stub above produces all four, which makes each of them one parametrised test rather than an untested branch.
Step 4 — Assert on the request set, not just the result
The most valuable assertion a tile stub enables has nothing to do with tile content: it is which tiles did the client decide to ask for. Over-fetching is the characteristic tile client bug, and it is invisible against a live server.
def test_viewport_fetches_exactly_the_covering_tiles(tile_server):
base, requested = tile_server
render_viewport(base, bounds=(-0.13, 51.50, -0.11, 51.52), zoom=14)
expected = {(14, 8189, 5443), (14, 8189, 5444),
(14, 8190, 5443), (14, 8190, 5444)}
assert set(requested) == expected
assert len(requested) == len(expected), "a tile was fetched twice"
The second assertion catches duplicate fetches, which a set comparison alone would hide and which are the usual symptom of a missing cache key. The same request-recording trick applied to range requests is what makes mocking Cloud-Optimized GeoTIFF reads useful.
Failure modes and edge cases
Tile row order differs between schemes. XYZ counts y from the top, TMS counts it from the bottom, and the two agree only at zoom 0. A stub that accepts both silently lets an inverted client pass. Serve one scheme, reject the other with a 404, and the test tells you which one your code speaks.
PNG encoding is not byte-stable across Pillow versions. Never assert on the bytes; assert on the decoded pixel values, as address_of above does. The same applies to vector tiles — decode and compare features rather than protobuf bytes.
Deterministic content can accidentally collide. The multipliers in raster_tile are chosen so nearby tiles differ visibly, but (x * 37) % 256 repeats every 256 columns. For tests spanning wide extents at high zoom, hash the full address instead of taking each coordinate independently.
Clients cache aggressively. A test that expects a second request after a viewport change may see none because the client kept the tile. That is correct behaviour, and the test should assert on it deliberately rather than being surprised by it — clear the client’s cache between tests or construct a fresh client per test.
Content-Type drives client behaviour. Some clients dispatch on the response content type rather than the URL extension. If the stub returns application/octet-stream for everything, vector tile parsing may never be reached.
Choosing the zoom levels a test exercises
Tile bugs cluster at specific zoom levels, and testing every level is wasteful, so pick the three where behaviour actually changes. Zoom 0 is the degenerate case: one tile covers the world, x and y are both zero, and any code that computes a tile index by division will divide by something small enough to expose rounding. A mid zoom around 12 to 14 is where ordinary rendering happens and where the covering-set arithmetic is exercised realistically. The configured maximum zoom is where over-zoom behaviour lives — clients asked for a level beyond the layer’s maximum either clamp to the highest available tile and scale it, or request a tile that does not exist and get a 404.
Which of those two a client does is a decision, not a detail, and it should be asserted rather than inherited from whatever the library defaults to. Parametrising the covering-set test across those three zooms costs almost nothing once the stub generates tiles on demand, and it covers the arithmetic where nearly all real defects live.
Conclusion
Compute tiles from their coordinates instead of storing them: the fixture becomes free, every response is reproducible, and — because the address is encoded in the content — a test can assert which tile it received rather than that it received some bytes. Then use the stub’s request log for the assertion that matters most, that the client asked for exactly the tiles the viewport covers and no more.
Related
- Mocking Geospatial Data for Tests — the parent strategy this fits into
- Stubbing WMS and WFS Services in Integration Tests — the vector service equivalent
- Mocking Cloud-Optimized GeoTIFF Reads in Tests — range requests and header parsing
- Raster Mocking Techniques — generating raster fixtures more generally
- Generating Synthetic GeoJSON for Edge-Case Testing — the same generate-don’t-store principle for vectors