Stubbing WMS and WFS Services in Integration Tests
A test that calls a live Web Map Service (WMS) or Web Feature Service (WFS) is not testing your code; it is testing your code, the network, somebody else’s server, and whatever that server’s operators deployed this morning. This guide sits beneath mocking geospatial data for tests and covers replacing those services with a stub that speaks enough of the protocol to exercise the code paths that matter.
The distinction that governs the whole design: a stub for an OGC service is not a stub for an HTTP call. Client libraries negotiate — they fetch a capabilities document, parse what the server declares, and then build their real request from it. A stub that only answers the second request fails on the first, and a stub that answers both but declares capabilities inconsistent with its data produces failures that look like bugs in your code.
Root cause: the client negotiates before it asks
owslib, geopandas.read_file with a WFS: prefix, and QGIS-derived tooling all begin the same way. They request ?service=WFS&request=GetCapabilities, parse the layer list, the supported output formats, the declared coordinate reference systems, and the version, and only then issue the request the test author was thinking about.
The consequence is that the capabilities document is the real interface. It is also the thing hand-written stubs get wrong, because it is verbose XML that nobody wants to maintain by hand and that drifts out of step with the fixture data the moment either changes.
What to stub and what not to
| Behaviour | Stub it? | Why |
|---|---|---|
GetCapabilities |
Yes | Every client fetches it first; nothing works without it |
GetFeature / GetMap |
Yes | The response your parsing code consumes |
| Service exception XML | Yes | The error path is where most client bugs live |
| HTTP timeouts and 503s | Yes | Cheap to simulate, and the retry logic needs coverage |
| Full OGC filter semantics | No | Reimplementing a query engine to test a client is the wrong trade |
| Coordinate transformation | No | Serve fixtures already in the CRS the test asks for |
| Authentication flows | Only if your code implements them | Otherwise it is testing the library |
The line falls where it usually falls with test doubles: stub the protocol, not the product. A stub that grows a filter evaluator has become a second implementation with its own bugs, and a test that passes against it proves nothing about the real server.
Step-by-step implementation
Step 1 — Generate the capabilities document from the fixture
Derive the declaration from the data so the two cannot disagree:
from pathlib import Path
import geopandas as gpd
WFS_CAPS = """<?xml version="1.0" encoding="UTF-8"?>
<wfs:WFS_Capabilities version="2.0.0"
xmlns:wfs="http://www.opengis.net/wfs/2.0"
xmlns:ows="http://www.opengis.net/ows/1.1">
<ows:ServiceIdentification>
<ows:ServiceType>WFS</ows:ServiceType>
<ows:ServiceTypeVersion>2.0.0</ows:ServiceTypeVersion>
</ows:ServiceIdentification>
<FeatureTypeList>{feature_types}</FeatureTypeList>
</wfs:WFS_Capabilities>
"""
FEATURE_TYPE = """
<FeatureType>
<Name>{name}</Name>
<Title>{name}</Title>
<DefaultCRS>urn:ogc:def:crs:EPSG::{epsg}</DefaultCRS>
<ows:WGS84BoundingBox>
<ows:LowerCorner>{minx} {miny}</ows:LowerCorner>
<ows:UpperCorner>{maxx} {maxy}</ows:UpperCorner>
</ows:WGS84BoundingBox>
</FeatureType>
"""
def build_capabilities(fixtures: dict[str, Path]) -> str:
"""One FeatureType per fixture file, with its real CRS and real extent."""
types = []
for name, path in fixtures.items():
gdf = gpd.read_file(path)
minx, miny, maxx, maxy = gdf.to_crs(4326).total_bounds
types.append(FEATURE_TYPE.format(
name=name,
epsg=gdf.crs.to_epsg(),
minx=minx, miny=miny, maxx=maxx, maxy=maxy,
))
return WFS_CAPS.format(feature_types="".join(types))
Every value a client might branch on — the type name, the declared CRS, the bounding box — now comes from the fixture itself. Swap a fixture and the declaration follows.
Step 2 — Serve it from a real socket
Client libraries build URLs, follow redirects, and set headers. A stub that intercepts at the function level skips all of that; a stub on a real port exercises it. http.server in a thread is enough:
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import pytest
class OGCHandler(BaseHTTPRequestHandler):
fixtures: dict = {}
capabilities: str = ""
def do_GET(self): # noqa: N802
params = {k.lower(): v[0] for k, v in
parse_qs(urlparse(self.path).query).items()}
request = params.get("request", "").lower()
if request == "getcapabilities":
return self._xml(200, self.capabilities)
if request == "getfeature":
name = params.get("typenames") or params.get("typename")
path = self.fixtures.get(name)
if path is None:
return self._xml(200, service_exception("InvalidParameterValue",
"typeNames"))
return self._xml(200, path.read_text(), "application/gml+xml")
return self._xml(200, service_exception("OperationNotSupported",
"request"))
def _xml(self, code, body, content_type="text/xml"):
payload = body.encode()
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args): # keep pytest output clean
pass
@pytest.fixture(scope="session")
def wfs_url(tmp_path_factory):
fixtures = {"test:parcels": Path("tests/fixtures/parcels.gml")}
OGCHandler.fixtures = fixtures
OGCHandler.capabilities = build_capabilities(
{"test:parcels": Path("tests/fixtures/parcels.geojson")}
)
server = ThreadingHTTPServer(("127.0.0.1", 0), OGCHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
host, port = server.server_address
yield f"http://{host}:{port}/wfs"
server.shutdown()
Binding to port 0 lets the operating system pick a free port, which is what makes the fixture safe under parallel execution with pytest-xdist — every worker gets its own server on its own port with no coordination.
Step 3 — Make the error path first-class
Service exceptions are the most under-tested part of any OGC client integration, because a live server rarely produces them on demand and a naive stub never produces them at all. They are also XML with a 200 status in WFS 1.x, which is exactly the shape that defeats code checking only response.raise_for_status().
EXCEPTION = """<?xml version="1.0" encoding="UTF-8"?>
<ows:ExceptionReport version="2.0.0"
xmlns:ows="http://www.opengis.net/ows/1.1">
<ows:Exception exceptionCode="{code}" locator="{locator}">
<ows:ExceptionText>{text}</ows:ExceptionText>
</ows:Exception>
</ows:ExceptionReport>
"""
def service_exception(code: str, locator: str, text: str = "") -> str:
return EXCEPTION.format(code=code, locator=locator,
text=text or f"{code} for {locator}")
A test that requests an unknown layer and asserts your code raises a domain error — rather than returning an empty GeoDataFrame — catches the single most common defect in service client code.
Step 4 — Assert against the request the client actually made
Recording requests turns the stub into an observation point. It is the only way to check that your bounding box, CRS parameter, and paging arguments left the process in the form you intended:
def test_bbox_is_sent_in_the_declared_crs(wfs_url, recorded_requests):
fetch_parcels(wfs_url, bounds=(-1.2, 51.7, -1.1, 51.8))
req = recorded_requests[-1]
assert req["request"] == "GetFeature"
assert req["srsname"] == "urn:ogc:def:crs:EPSG::4326"
# bbox must carry its CRS, or axis order is undefined
assert req["bbox"].endswith("urn:ogc:def:crs:EPSG::4326")
That last assertion covers the axis-order problem that a live-server test hides: a bbox without a CRS suffix is interpreted according to the server’s default, which for EPSG:4326 in WFS 2.0 is latitude-first. Code that assumes longitude-first works against servers that are lenient and silently returns the wrong extent against servers that are not. The same class of latent assumption is covered in detecting CRS drift across format conversions.
Failure modes and edge cases
Namespace prefixes are not stable. A real server may return gml:featureMember or wfs:member depending on version. If your parsing code matches on a prefix rather than a namespace URI, it will pass against your stub and fail against production. Serve fixtures captured from the real server rather than hand-written XML.
Paging changes the response shape. WFS 2.0 servers return numberMatched and numberReturned, and clients that page will issue several requests. A stub that always returns the whole fixture makes paging code look correct while never exercising it. Implement count and startIndex even if crudely — slicing the fixture is enough.
Content-encoding matters. Servers commonly gzip responses. If your code reads response.content and parses bytes directly rather than letting the HTTP client decode, a stub sending plain text hides the bug.
A stub cannot reproduce load-related behaviour. Slow first responses, connection pool exhaustion, and partial reads on large payloads all require the real thing or a fault-injecting proxy. Keep a small number of tests pointed at a real service, marked and excluded from the default run, and treat the stub suite as the fast gate rather than the whole story.
Capabilities responses are frequently cached by the client. owslib will reuse a parsed capabilities object across calls. If a test mutates the stub’s declaration mid-session expecting the client to notice, it will not — build a fresh client per test, or scope the stub per test rather than per session.
Keeping the stub honest against the real service
A stub is a claim about how the real server behaves, and claims decay. The cheap way to keep it honest is a contract check: a single test, excluded from the default run and executed on a schedule, that fetches the real capabilities document and compares its structure — not its content — against what the stub declares. If the real service moves from WFS 2.0.0 to 2.0.2, adds a required parameter, or drops an output format the stub still advertises, that one test reports it while every other test keeps running offline.
Capture the comparison at the level you actually depend on. The layer titles will change and should not fail anything; the presence of the type names your code requests, the output formats it asks for, and the CRS list it assumes are the things worth asserting. Recording a fresh capabilities document from the real service each time that check runs, and committing it as the stub’s source, closes the loop: the stub is regenerated from reality on a cadence you choose rather than drifting until someone notices in production.
Conclusion
Stub the two requests the client actually makes, generate the capabilities document from the same fixture you serve so the declaration cannot drift, bind to an ephemeral port so the stub survives parallel workers, and make service exceptions and timeouts as easy to trigger as success. The result is an integration suite that fails when your client is wrong and only then.
Related
- Mocking Geospatial Data for Tests — the parent strategy this fits into
- Faking Tile Server Responses in Python Tests — the raster equivalent of this pattern
- Best Practices for Mocking PostGIS Connections — the same question for a database dependency
- When to Use Unit vs Integration Tests in GIS — deciding which tier this belongs in
- Mocking Cloud-Optimized GeoTIFF Reads in Tests — range requests against a stub server