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.

Stored tile fixtures versus generated ones Two approaches to tile fixtures are compared. Stored fixtures recorded from a live server grow into thousands of opaque binary files, produce diffs a reviewer cannot meaningfully evaluate, become stale between releases, and encourage byte-equality assertions that fail whenever an encoder version changes for reasons unrelated to correctness. Generated fixtures are computed as a pure function of the three tile coordinates z, x and y, so they take no repository space, are perfectly reproducible without any storage, and let a test compute the content it expects rather than compare bytes against a blob whose meaning nobody can read. recorded tile fixtures thousands of opaque binary files diffs a reviewer cannot evaluate stale within a release cycle invites byte-equality assertions breaks when the encoder changes, not the data generated from (z, x, y) zero bytes in the repository reproducible without storage stateless — safe under parallel workers expected content is computable assert meaning, not bytes

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.

Tile response statuses and the correct client behaviour for each Four response statuses are listed with the behaviour a tile client should exhibit for each. HTTP 200 with a body means the tile should be rendered. HTTP 204 with no body means the address is valid but holds nothing, so the client should render empty space and must not retry. HTTP 404 means the address does not exist and is a configuration error that should be surfaced rather than silently treated as empty coverage. HTTP 500 or a connection timeout means the request should be retried with back-off. A closing note observes that a stub returning only status 200 leaves the remaining three behaviours entirely uncovered. STATUS MEANS CLIENT MUST 200 a tile with content decode and render it 204 valid address, no content render empty space, never retry 404 address does not exist surface it — not the same as empty 500 / timeout transient server fault retry with back-off, then give up

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.

Three zoom levels where tile bugs actually live Three zoom levels are described alongside the defect each one exposes. Zoom zero is the degenerate case in which one tile covers the entire world and both the x and y indices are zero, which exposes rounding errors in any code that computes a tile index by division. A middle zoom of roughly twelve to fourteen is where ordinary rendering happens and where the arithmetic that computes a viewport's covering set of tiles is exercised realistically. The configured maximum zoom is where over-zoom behaviour lives: a client asked for a level beyond the layer's maximum either clamps to the highest available tile and scales it up, or requests a tile that does not exist and receives a not-found response, and which of the two it does should be asserted rather than inherited from a library default. ZOOM WHAT IS SPECIAL ABOUT IT DEFECT IT EXPOSES z = 0 one tile, x = y = 0 a single tile covers the world rounding in index arithmetic z = 12–14 ordinary rendering realistic covering-set arithmetic over-fetching, duplicate requests z = max the configured ceiling clamp and scale, or ask and get 404 over-zoom policy left to a default

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.