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.

An OGC client makes two requests, and the first decides the second An OGC client interaction is drawn as two sequential exchanges. In the first, the client requests the capabilities document and the server returns a declaration of layer names, supported output formats, advertised coordinate reference systems, and protocol version. The client parses that declaration. In the second exchange, the client constructs a GetFeature request whose type name, output format, service reference system and version are all taken from the capabilities document rather than chosen independently. Two failure notes are attached: a stub that answers only the GetFeature request fails at the capabilities step, and a stub whose capabilities disagree with the data it serves produces errors that appear to be client bugs. client owslib / geopandas stub server speaks the protocol 1. request=GetCapabilities layers · formats · CRS list · version 2. request=GetFeature — every parameter taken from step 1 stub answers only step 2 → fails before your code runs capabilities disagree with data → looks like a client bug The capabilities document is the contract. Generate it from the same fixture the stub serves, so the two cannot drift apart. Anything the stub declares but cannot deliver becomes a test failure with a misleading message.

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.

Four stub responses and the code path each one covers Four responses a stub should be able to produce are listed with the code path each exercises. A valid capabilities document covers negotiation and layer discovery. A valid feature response covers parsing, geometry construction and coordinate reference system handling. A service exception returned with an HTTP 200 status covers the error path that any check based only on the HTTP status code will miss. A connection timeout covers retry and back-off logic. A closing note observes that typical stubs implement only the feature response, which is why defects concentrate in the other three paths. STUB RESPONSE CODE PATH COVERED USUALLY STUBBED? valid capabilities document negotiation, layer discovery sometimes valid feature response parsing, geometry, CRS handling yes service exception, HTTP 200 the error path status checks miss rarely connection timeout retry and back-off logic almost never

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.

A scheduled contract check regenerates the stub from reality Two loops are drawn side by side. The fast loop runs the offline test suite against the stub on every commit and never touches the network, so it is quick and cannot fail because of somebody else's outage. The slow loop runs on a schedule, fetches the real service's capabilities document, and compares only the structural elements the client code depends on — the feature type names it requests, the output formats it asks for, and the reference systems it assumes — regenerating the stub's source document whenever they differ. Layer titles and other cosmetic content are deliberately excluded from the comparison so that the check fires only when something changes that would actually break the client. fast loop — every commit runs against the stub only no network at all cannot fail on somebody else’s outage gates the merge seconds, deterministic slow loop — on a schedule fetches the real capabilities document compares type names, formats, CRS list ignores titles and cosmetic content regenerates the stub’s source reports; does not gate

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.