Writing pytest Checks for WMS GetCapabilities Validity
TL;DR: Fetch the GetCapabilities document once in a scope="session" pytest fixture, parse it with lxml.etree, and select a namespace map from the root element’s version attribute. Then run parametrized tests that assert the root element and version, that expected layer names are present, that each layer advertises the required CRS codes, that bounding boxes exist, and that the whole document validates against a locally cached official XSD via lxml.etree.XMLSchema.
A WMS GetCapabilities response — the service discovery contract returned when a client sends SERVICE=WMS&REQUEST=GetCapabilities — is the single document every downstream client parses before it can request a map. If a deployment silently drops a layer, stops advertising EPSG:3857, or emits XML that no longer validates against the schema, QGIS and web clients break with cryptic errors. A focused pytest suite turns that class of regression into a red build. This guide builds that suite for the CI/CD and Compliance Testing for Spatial Services workflow.
The Core Challenge
The obstacle is not writing assertions — it is doing so once, cheaply, and across two incompatible namespace regimes. WMS 1.3.0 declares http://www.opengis.net/wms as its default XML namespace, so every element is namespaced and every XPath must carry the wms: prefix. WMS 1.1.1 carries no namespace at all, so the same XPath returns an empty node list. A naive test that hard-codes bare tag names passes against a 1.1.1 server and silently asserts nothing against a 1.3.0 server, because tree.findall("Layer") returns [] and an empty loop makes no assertions.
Fetching is the second problem. A GetCapabilities document from an enterprise server can exceed 500 KB and take a full second to render. Re-fetching it inside every test — one per layer, one per CRS — turns a twenty-assertion suite into twenty HTTP round-trips and a flaky, slow build. The fix is a session-scoped fixture that fetches and parses exactly once, plus parametrized tests that share that parsed tree.
Production-Ready Code
The suite lives in one conftest.py for shared fixtures and one test_wms_capabilities.py for the assertions. Dependencies are pip install pytest requests lxml. Point WMS_BASE_URL at the endpoint under test and vendor the official XSD tree under schemas/ (mirror http://schemas.opengis.net/wms/1.3.0/ with a recursive download so the imports resolve locally).
# conftest.py — shared, session-scoped WMS GetCapabilities fixtures
from __future__ import annotations
import os
from pathlib import Path
import pytest
import requests
from lxml import etree
WMS_BASE_URL = os.environ.get(
"WMS_BASE_URL", "https://demo.mapserver.org/cgi-bin/wms"
)
# Local mirror of http://schemas.opengis.net/wms/1.3.0/capabilities_1_3_0.xsd
XSD_130 = Path(__file__).parent / "schemas" / "wms" / "1.3.0" / "capabilities_1_3_0.xsd"
# WMS 1.3.0 puts every element in this default namespace; 1.1.1 has none.
NS_130 = {"wms": "http://www.opengis.net/wms"}
NS_111: dict[str, str] = {}
@pytest.fixture(scope="session")
def capabilities_tree() -> etree._ElementTree:
"""Fetch GetCapabilities exactly once per test run and return the parsed tree."""
params = {"SERVICE": "WMS", "REQUEST": "GetCapabilities"}
response = requests.get(WMS_BASE_URL, params=params, timeout=30)
response.raise_for_status()
# etree.fromstring rejects an XML declaration in a str; feed it bytes.
root = etree.fromstring(response.content)
return root.getroottree()
@pytest.fixture(scope="session")
def wms_version(capabilities_tree: etree._ElementTree) -> str:
"""Read the version attribute from the root element ('1.3.0' or '1.1.1')."""
return capabilities_tree.getroot().get("version", "1.3.0")
@pytest.fixture(scope="session")
def ns(wms_version: str) -> dict[str, str]:
"""Choose the namespace map that matches the advertised version."""
return NS_130 if wms_version.startswith("1.3") else NS_111
@pytest.fixture(scope="session")
def capabilities_schema() -> etree.XMLSchema:
"""Compile the cached official WMS 1.3.0 capabilities XSD once."""
if not XSD_130.exists():
pytest.skip(f"Cached XSD not found at {XSD_130}")
return etree.XMLSchema(etree.parse(str(XSD_130)))
# test_wms_capabilities.py — assertions over the shared capabilities tree
from __future__ import annotations
from lxml import etree
# Contract the deployment must honour. Adjust to your published layers.
EXPECTED_LAYERS = {"cities", "country_bounds", "rivers"}
REQUIRED_CRS = {"EPSG:4326", "EPSG:3857"}
def _q(ns: dict[str, str], path: str) -> str:
"""Strip the wms: prefix when running against un-namespaced 1.1.1 docs."""
if ns:
return path
return "/".join(part.split(":")[-1] for part in path.split("/"))
def _layer_elements(tree: etree._ElementTree, ns: dict[str, str]) -> list:
"""All named <Layer> elements anywhere under Capability."""
return tree.findall(f".//{_q(ns, 'wms:Layer')}", ns or None)
def _layer_name(layer, ns: dict[str, str]) -> str | None:
name = layer.find(_q(ns, "wms:Name"), ns or None)
return name.text.strip() if name is not None and name.text else None
def test_root_element_and_version(capabilities_tree, wms_version):
root = capabilities_tree.getroot()
local_name = etree.QName(root).localname
assert local_name in {"WMS_Capabilities", "WMT_MS_Capabilities"}, (
f"Unexpected root element {local_name!r}; not a WMS capabilities document"
)
assert wms_version in {"1.1.1", "1.3.0"}, (
f"Advertised version {wms_version!r} is not a supported WMS version"
)
def test_all_expected_layers_present(capabilities_tree, ns):
published = {
name
for layer in _layer_elements(capabilities_tree, ns)
if (name := _layer_name(layer, ns)) is not None
}
missing = EXPECTED_LAYERS - published
assert not missing, (
f"Layers missing from GetCapabilities: {sorted(missing)}. "
f"Server advertised: {sorted(published)}"
)
def test_each_named_layer_advertises_required_crs(capabilities_tree, ns):
"""Every named layer must offer each CRS in REQUIRED_CRS (CRS or SRS tag)."""
failures: list[str] = []
for layer in _layer_elements(capabilities_tree, ns):
name = _layer_name(layer, ns)
if name is None: # skip container/group layers with no Name
continue
codes = {
(el.text or "").strip()
for tag in ("wms:CRS", "wms:SRS")
for el in layer.findall(_q(ns, tag), ns or None)
}
missing = REQUIRED_CRS - codes
if missing:
failures.append(f"{name}: missing {sorted(missing)} (has {sorted(codes)})")
assert not failures, "Layers missing required CRS:\n" + "\n".join(failures)
def test_each_named_layer_has_a_bounding_box(capabilities_tree, ns):
"""Assert a geographic bounding box exists (1.3.0 vs 1.1.1 element names differ)."""
geo_130 = _q(ns, "wms:EX_GeographicBoundingBox")
geo_111 = _q(ns, "wms:LatLonBoundingBox")
failures: list[str] = []
for layer in _layer_elements(capabilities_tree, ns):
name = _layer_name(layer, ns)
if name is None:
continue
has_bbox = (
layer.find(geo_130, ns or None) is not None
or layer.find(geo_111, ns or None) is not None
)
if not has_bbox:
failures.append(name)
assert not failures, f"Layers with no geographic bounding box: {failures}"
def test_document_validates_against_official_xsd(
capabilities_tree, capabilities_schema, wms_version
):
if not wms_version.startswith("1.3"):
import pytest
pytest.skip("Cached XSD covers WMS 1.3.0 only")
valid = capabilities_schema.validate(capabilities_tree)
if not valid:
errors = "\n".join(
f" line {e.line}: {e.message}" for e in capabilities_schema.error_log
)
raise AssertionError(f"GetCapabilities failed XSD validation:\n{errors}")
Step-by-Step Walkthrough
The session-scoped capabilities_tree fixture. Declaring scope="session" means pytest instantiates the fixture once and hands the same parsed tree to every test that requests it, so the entire suite makes a single requests.get call. Note that etree.fromstring is fed response.content (bytes), not response.text — lxml raises ValueError if you pass a str that contains an XML encoding declaration. Calling .getroottree() returns an _ElementTree, which is what XMLSchema.validate expects later.
Version detection drives everything. The wms_version fixture reads the version attribute off the root element, and the ns fixture converts that into either NS_130 or the empty NS_111. The empty dict is the signal used by the _q helper: when ns is falsy, _q rewrites an XPath like wms:Layer/wms:Name into the bare Layer/Name that un-namespaced 1.1.1 documents require. This is why one suite covers both protocol generations without branching in every test.
Layer collection with _layer_elements. The .// prefix on the XPath finds Layer elements at any depth, which matters because WMS nests layers — a root container <Layer> holds the named child layers you actually publish. _layer_name returns None for container layers that carry no <Name>, and the tests skip those so a group layer without its own CRS list does not trigger a false failure.
The CRS assertion. WMS 1.3.0 uses <CRS> while 1.1.1 uses <SRS>, so the set comprehension gathers both tag names. It builds a set of advertised codes per layer and subtracts it from REQUIRED_CRS; any non-empty remainder is appended to failures with both the missing and the present codes. Collecting all failures before asserting — rather than asserting inside the loop — means one test run reports every non-compliant layer at once instead of stopping at the first.
XSD validation. capabilities_schema compiles the vendored capabilities_1_3_0.xsd once. schema.validate(tree) returns a bool rather than raising, so the test inspects capabilities_schema.error_log on a False result and raises AssertionError with each error’s line and message. That produces a failure that names the exact offending element instead of a bare “document is invalid”. For a deeper treatment of parsing the same document into structured metadata, see How to Parse OGC WMS GetCapabilities XML in Python.
Verification
Run the suite with verbose output so each check reports independently:
WMS_BASE_URL="https://demo.mapserver.org/cgi-bin/wms" pytest -v test_wms_capabilities.py
A healthy deployment prints one PASSED line per check off a single fetch:
test_wms_capabilities.py::test_root_element_and_version PASSED [ 20%]
test_wms_capabilities.py::test_all_expected_layers_present PASSED [ 40%]
test_wms_capabilities.py::test_each_named_layer_advertises_required_crs PASSED [ 60%]
test_wms_capabilities.py::test_each_named_layer_has_a_bounding_box PASSED [ 80%]
test_wms_capabilities.py::test_document_validates_against_official_xsd PASSED [100%]
============================== 5 passed in 0.94s ===============================
When a layer stops advertising a projection, the failure is specific and actionable rather than a generic diff:
E AssertionError: Layers missing required CRS:
E rivers: missing ['EPSG:3857'] (has ['EPSG:4326', 'CRS:84'])
Gotchas & Edge Cases
Why should the GetCapabilities fetch be a session-scoped fixture instead of running in each test?
A GetCapabilities document is expensive to fetch — often 500 KB and a full second of server-side rendering — yet immutable across a single test run. Fetching it with scope="session" and reusing the parsed lxml tree means a suite of dozens of layer and CRS assertions makes exactly one HTTP round-trip. Give the fixture function scope instead and you re-fetch per test, turning a fast suite into a slow, flaky one that also load-tests the very server you are trying to validate.
How do I make one pytest suite validate both WMS 1.1.1 and 1.3.0 documents?
Read the version attribute from the root element in a fixture and choose the namespace map dynamically. WMS 1.3.0 declares http://www.opengis.net/wms as its default namespace, so every XPath needs the wms: prefix; WMS 1.1.1 carries no namespace and needs bare tag names. The _q helper in the code strips the prefix when the namespace map is empty. Also remember the element names differ — <CRS> versus <SRS>, and <EX_GeographicBoundingBox> versus <LatLonBoundingBox> — so assertions must accept both spellings.
Why cache the OGC capabilities XSD locally instead of loading it from schemas.opengis.net?
The official capabilities_1_3_0.xsd <import>s several dependent schemas (xlink, gml) over HTTP. Loading them live makes CI depend on an external host, so a network hiccup or rate limit fails your build for reasons unrelated to your service. Mirror the whole wms/1.3.0/ schema tree into the repository (a recursive wget of http://schemas.opengis.net/wms/1.3.0/ works) and point lxml.etree.XMLSchema at the local copy so validation is deterministic and runs offline in a locked-down runner.
How do I get a clear pytest failure message when XSD validation fails?
lxml.etree.XMLSchema.validate returns a bool rather than raising, and it populates schema.error_log after a False result. Build your AssertionError message by iterating that log and formatting each entry’s .line and .message. pytest then prints the exact line number, element, and violated schema rule instead of a bare “document is invalid”, which turns a schema regression into a two-minute fix rather than an afternoon of bisecting the XML by hand.
Back to CI/CD and Compliance Testing for Spatial Services
Related
- CI/CD and Compliance Testing for Spatial Services — where these unit checks sit in the wider spatial-service validation pipeline
- Running OGC CITE Tests in GitHub Actions — the heavyweight, formal conformance suite that complements these fast pytest checks
- How to Parse OGC WMS GetCapabilities XML in Python — namespace handling and recursive layer extraction for the same document