How to Test a Map App Without a Browser
On this page
Problem statement
Map apps are usually untested, for a reason that sounds convincing: testing them seems to need a browser, a driver and a screenshot comparison, and that is slow and flaky enough that nobody does it.
Most of it does not. The parts that break are:
- the filter logic, which is plain Python
- the state transitions — what a click does to a selection
- the payload size, which regresses silently when somebody adds a column
- the rerun cost, which decides whether the app feels usable
- the wiring: does the widget actually drive the map?
All five can be tested headlessly. Streamlit ships AppTest, which runs the script and exposes the widgets and the output without a browser at all. Measured on this page's app over Europe's 1,333 NUTS 3 regions, a first run took 1.01 s and a cached rerun 0.03 s; the thirteen fast tests ran in about four seconds, and on their first run against real data one of them failed for a real reason (Example 4).
Quick answer
%%writefile tests/test_quick.py
from streamlit.testing.v1 import AppTest
APP = "../app/main.py" # relative to this test file, not to where pytest runs
def test_filter_changes_the_result():
app = AppTest.from_file(APP, default_timeout=60)
app.run()
assert not app.exception
before = app.metric[0].value
app.selectbox(key="country").select("PT")
app.run()
assert not app.exception
assert app.metric[0].value != before, "the filter did not change anything"
def test_no_selection_shows_a_hint():
app = AppTest.from_file(APP, default_timeout=60)
app.run()
assert any("click" in text.value.lower() for text in app.info)
The path is the one trap: AppTest.from_file resolves a relative path against the file that calls it, not against the directory pytest runs in. Written as "app/main.py" from inside tests/, both tests failed with FileNotFoundError.
AppTest drives the app the way a user does — set a widget, re-run, inspect the output — and it is fast enough to run on every commit.
What you get — from The data used on this page
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names
Test the analysis, then drive the app with AppTest
1. Test the analysis without the framework
The largest and most valuable set of tests needs no app at all. If the filtering and summarising live in plain functions, they are ordinary unit tests:
def test_country_filter_selects_only_that_country(districts):
subset = apply_selection(districts, Selection(country="NO"))
assert set(subset["country"]) == {"NO"}
def test_threshold_is_inclusive(districts):
subset = apply_selection(districts, Selection(min_rate=19.8))
assert subset["rate"].min() == 19.8
This is the argument for keeping the analysis out of the script body: it becomes testable, reusable and portable in one move.
2. Drive the app with AppTest
app = AppTest.from_file("app/main.py", default_timeout=60)
app.run()
app.selectbox[0].select("PT")
app.slider[0].set_value(30.0)
app.run()
assert app.metric[1].value == "4"
assert not app.exception
Widgets are addressable by type and index, or by key, which is more robust:
app.selectbox(key="country").select("PT")
st.metric is the exception: it takes no key, so a test finds it by its label — the metric(app, "Regions") helper in Example 1.
Naming widgets with keys makes tests survive a layout change, which is the usual reason an app test breaks for no real reason.
3. Test the state transitions
def test_clicking_toggles_selection(app):
app.session_state["selected"] = {"D4"}
app.run()
assert "1 selected" in app.caption[0].value
app.session_state["selected"] = set()
app.run()
assert any("click" in info.value.lower() for info in app.info)
AppTest exposes session_state, so the state machine can be driven directly. Simulating the component's return value is not possible, but setting the state it would have produced is — and that is where the logic lives.
4. Assert the payload size
Response size regresses when somebody adds a column or removes a rounding step, and nothing else catches it:
def test_geojson_payload_within_budget():
districts = gpd.read_file(REAL_DATA)
districts["geometry"] = districts.geometry.simplify(0.01, preserve_topology=True)
gz_mb = len(gzip.compress(to_geojson_bytes(districts), 6)) / 1e6
assert gz_mb < 2.0, f"{gz_mb:.2f} MB gzipped exceeds the 2 MB budget"
Run on the real 1,333-region layer, this test failed the first time — 2.02 MB gzipped — and passed at 1.14 MB once the export rounded its coordinates (Example 4).
A budget test turns a silent performance regression into a failing build in the pull request that caused it.
5. Measure the rerun cost
def test_rerun_is_fast_enough():
app = AppTest.from_file(APP, default_timeout=120)
app.run()
times = []
for option in sorted(app.selectbox(key="country").options)[1:4]:
app.selectbox(key="country").select(option)
started = time.perf_counter()
app.run()
times.append(time.perf_counter() - started)
assert min(times) < 0.2, f"reruns take {min(times):.2f}s — add @st.cache_data to the loaders"
This is the test that catches a missing cache decorator: without it, a rerun of this app measured 0.52 s against 0.03 s with it, so the threshold sits well between the two rather than at a round number.
6. Keep a very small browser suite, if any
Playwright is worth it for exactly two things: that the map renders at all, and that a click on the map reaches the app. Everything else is faster and more reliable headlessly.
Keep that suite to a handful of tests, run it separately from the fast suite, and accept that it will occasionally be flaky.
A fast suite plus a small browser suite
The data used on this page
The "districts" are Europe's NUTS 3 regions — 1,333 of them once joined to data — from Eurostat GISCO at 1:1 million, with GISCO's urban–rural typology as the category, and the share of the population aged 65 and over for 2019 to 2023 from the Eurostat table demo_r_pjanind3 as rate_2019 … rate_2023. Eurostat refuses the default python-requests user agent, hence the header.
import math
import os
import geopandas as gpd
import pandas as pd
import requests
YEARS = [2019, 2020, 2021, 2022, 2023]
def eurostat(table, **filters):
"""A Eurostat table as a Series indexed by every dimension."""
stat = requests.get(f"https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/{table}",
params={"format": "JSON", **filters},
headers={"User-Agent": "spatialworkflow.io guide"}, timeout=120).json()
codes = [sorted(stat["dimension"][d]["category"]["index"], key=stat["dimension"][d]["category"]["index"].get)
for d in stat["id"]]
values = [stat["value"].get(str(i)) for i in range(math.prod(stat["size"]))]
return pd.Series(values, index=pd.MultiIndex.from_product(codes, names=stat["id"]), dtype=float)
if not os.path.exists("districts.gpkg"):
nuts = gpd.read_file("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
"NUTS_RG_01M_2021_4326_LEVL_3.geojson")
older = (eurostat("demo_r_pjanind3", indic_de="PC_Y65_MAX", unit="PC",
sinceTimePeriod=YEARS[0], untilTimePeriod=YEARS[-1])
.droplevel(["freq", "indic_de", "unit"]).unstack("time"))
older.columns = [f"rate_{year}" for year in older.columns]
kind = {1: "urban", 2: "intermediate", 3: "rural"}
(nuts.assign(category=nuts["URBN_TYPE"].map(kind))
.rename(columns={"NUTS_ID": "code", "CNTR_CODE": "country", "NAME_LATN": "name"})
[["code", "country", "name", "category", "geometry"]]
.merge(older, left_on="code", right_index=True)
.dropna(subset=[f"rate_{YEARS[-1]}"])
.to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(f"{len(districts):,} NUTS 3 regions in {districts.country.nunique()} countries; "
f"share aged 65+ in {YEARS[-1]}: {districts[f'rate_{YEARS[-1]}'].min():.1f}–"
f"{districts[f'rate_{YEARS[-1]}'].max():.1f}%")
print(districts.category.value_counts().to_dict(),
f"{districts.name.nunique():,} distinct names")
# the app reads data/districts.gpkg, with the latest year as `rate`
for folder in ("app", "tests", "data"):
os.makedirs(folder, exist_ok=True)
districts.assign(rate=districts[f"rate_{YEARS[-1]}"]).to_file("data/districts.gpkg")
1,333 NUTS 3 regions in 36 countries; share aged 65+ in 2023: 2.7–36.0%
{'intermediate': 619, 'rural': 438, 'urban': 276} 1,332 distinct names
The app under test
A small version of the Streamlit map app: the filtering and the exports live in app/logic.py, and app/main.py is only wiring. DATA_DIR lets a test point it at a fixture.
%%writefile app/logic.py
import os
import tempfile
from dataclasses import dataclass
@dataclass(frozen=True)
class Selection:
country: str | None = None
min_rate: float = 0.0
def apply_selection(districts, selection: Selection):
"""Pure function: the part of the app worth most of the tests."""
subset = districts
if selection.country:
subset = subset[subset["country"] == selection.country]
return subset[subset["rate"] >= selection.min_rate]
def to_csv(gdf, key: str) -> bytes:
return gdf.drop(columns=gdf.geometry.name).to_csv(index=False).encode("utf-8")
def to_geojson_bytes(gdf) -> bytes:
return gdf.to_json().encode("utf-8")
def to_geopackage(gdf, key: str) -> bytes:
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "selection.gpkg")
gdf.to_file(path, layer="selection", driver="GPKG")
with open(path, "rb") as handle:
return handle.read()
%%writefile app/main.py
import os
import geopandas as gpd
import pydeck as pdk
import streamlit as st
from logic import Selection, apply_selection, to_csv
DATA_DIR = os.environ.get("DATA_DIR", "data")
@st.cache_data
def load(path):
gdf = gpd.read_file(path).to_crs(4326)
gdf["geometry"] = gdf.geometry.simplify(0.01, preserve_topology=True)
return gdf
st.title("Share of the population aged 65 and over")
districts = load(os.path.join(DATA_DIR, "districts.gpkg"))
country = st.selectbox("Country", ["All", *sorted(districts["country"].unique())], key="country")
threshold = st.slider("Highlight above (%)", 0.0, 40.0, 25.0, 0.5, key="threshold")
subset = apply_selection(districts, Selection(None if country == "All" else country))
above = subset[subset["rate"] > threshold]
st.metric("Regions", f"{len(subset):,}")
st.metric("Above threshold", f"{len(above):,}")
if above.empty:
st.warning("No districts above this threshold — lower it to see some.")
selected = st.session_state.setdefault("selected", set())
if selected:
st.caption(f"{len(selected)} selected")
else:
st.info("Click a region on the map to select it.")
layer = pdk.Layer("GeoJsonLayer", subset.assign(above=subset["rate"] > threshold).__geo_interface__,
get_fill_color="properties.above ? [239, 68, 68, 160] : [14, 165, 233, 120]",
pickable=True)
st.pydeck_chart(pdk.Deck(layers=[layer], initial_view_state=pdk.ViewState(
latitude=52.0, longitude=10.0, zoom=3)), width="stretch")
st.download_button("Download CSV", to_csv(subset, f"{country}|{threshold}"),
file_name="selection.csv", mime="text/csv")
Example 1 — fixtures and a fast suite
%%writefile pytest.ini
[pytest]
pythonpath = app
markers =
browser: needs a real browser and a running app
addopts = -m "not browser"
%%writefile tests/conftest.py
import os
import socket
import subprocess
import sys
import time
import urllib.request
import geopandas as gpd
import pytest
from shapely.geometry import Point, Polygon
from streamlit.testing.v1 import AppTest
APP = os.path.join(os.path.dirname(__file__), "..", "app", "main.py")
@pytest.fixture(scope="session")
def districts():
"""Small, deterministic, and deliberately awkward."""
return gpd.GeoDataFrame(
{"code": ["A1", "B2", "C3", "D4"],
"name": ["Alpha", "Beta", "Gamma", "Ødegård"], # non-ASCII on purpose
"country": ["NO", "NO", "PT", "PT"],
"rate": [14.2, 19.8, 27.1, 32.0]},
geometry=[Point(10.7, 59.9), Point(5.3, 60.4),
Point(-9.1, 38.7),
Polygon([(-8, 41), (-7.9, 41), (-7.9, 41.1), (-8, 41.1)])],
crs="EPSG:4326")
@pytest.fixture
def app(tmp_path, districts, monkeypatch):
districts.to_file(tmp_path / "districts.gpkg", driver="GPKG")
monkeypatch.setenv("DATA_DIR", str(tmp_path))
instance = AppTest.from_file(APP, default_timeout=60)
instance.run()
assert not instance.exception, instance.exception
return instance
def metric(app, label):
"""st.metric takes no key, so find it by its label."""
return next(m.value for m in app.metric if m.label == label)
@pytest.fixture(scope="session")
def live_app_url():
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
server = subprocess.Popen(
[sys.executable, "-m", "streamlit", "run", APP, "--server.headless", "true",
"--server.port", str(port), "--browser.gatherUsageStats", "false"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
url = f"http://127.0.0.1:{port}"
try:
for _ in range(120):
try:
urllib.request.urlopen(url + "/_stcore/health", timeout=1)
break
except OSError:
time.sleep(0.5)
yield url
finally:
server.terminate()
server.wait(timeout=30)
%%writefile tests/test_logic.py
from logic import Selection, apply_selection
def test_country_filter_selects_only_that_country(districts):
subset = apply_selection(districts, Selection(country="NO"))
assert set(subset["country"]) == {"NO"}
def test_threshold_is_inclusive(districts):
subset = apply_selection(districts, Selection(min_rate=19.8))
assert subset["rate"].min() == 19.8
%%writefile tests/test_app.py
import gzip
import os
import time
import geopandas as gpd
from conftest import APP, metric
from logic import to_geojson_bytes
from streamlit.testing.v1 import AppTest
REAL_DATA = os.path.join(os.path.dirname(APP), "..", "data", "districts.gpkg")
def test_app_starts(app):
assert not app.exception
assert app.title[0].value
def test_country_filter(app):
app.selectbox(key="country").select("NO")
app.run()
assert metric(app, "Regions") == "2"
def test_threshold_filter(app):
app.slider(key="threshold").set_value(19.0)
app.run()
assert metric(app, "Above threshold") == "3"
def test_empty_selection_is_explained(app):
app.slider(key="threshold").set_value(35.0)
app.run()
assert any("no districts" in w.value.lower() for w in app.warning)
def test_clicking_toggles_selection(app):
app.session_state["selected"] = {"D4"}
app.run()
assert "1 selected" in app.caption[0].value
app.session_state["selected"] = set()
app.run()
assert any("click" in info.value.lower() for info in app.info)
def test_geojson_payload_within_budget():
districts = gpd.read_file(REAL_DATA)
districts["geometry"] = districts.geometry.simplify(0.01, preserve_topology=True)
gz_mb = len(gzip.compress(to_geojson_bytes(districts), 6)) / 1e6
print(f"payload {gz_mb:.2f} MB gzipped")
assert gz_mb < 2.0, f"{gz_mb:.2f} MB gzipped exceeds the 2 MB budget"
def test_rerun_is_fast_enough():
app = AppTest.from_file(APP, default_timeout=120)
app.run()
times = []
for option in sorted(app.selectbox(key="country").options)[1:4]:
app.selectbox(key="country").select(option)
started = time.perf_counter()
app.run()
times.append(time.perf_counter() - started)
print(f"reruns {', '.join(f'{t:.2f}' for t in times)} s")
assert min(times) < 0.2, f"reruns take {min(times):.2f}s — add @st.cache_data to the loaders"
The Ødegård row is not decoration: it catches encoding bugs in the download path, in the tooltip and in the CSV export.
Example 2 — testing the download path
%%writefile tests/test_download.py
import geopandas as gpd
from logic import to_csv, to_geopackage
def test_download_produces_valid_csv(districts):
text = to_csv(districts, key="test").decode("utf-8") # raises on an encoding bug
lines = text.strip().splitlines()
assert len(lines) == len(districts) + 1
assert "geometry" not in lines[0], "CSV should not carry the geometry column"
assert "Ødegård" in text, "non-ASCII names must survive the export"
def test_geopackage_roundtrips(districts, tmp_path):
path = tmp_path / "out.gpkg"
path.write_bytes(to_geopackage(districts, key="test"))
back = gpd.read_file(path)
assert len(back) == len(districts)
assert back.crs == districts.crs
assert back.geometry.notna().all()
assert "Ødegård" in set(back["name"])
Round-tripping the export through the reader that consumers will use is the only check that matters, and it takes a few lines.
Example 3 — a small Playwright suite for the parts that need a browser
%%writefile tests/test_browser.py
import os
import pytest
from playwright.sync_api import sync_playwright
@pytest.mark.browser
def test_map_actually_renders(live_app_url):
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page()
page.goto(live_app_url)
page.wait_for_selector("canvas", timeout=60_000)
assert page.locator("canvas").count() >= 1
os.makedirs("artifacts", exist_ok=True)
page.screenshot(path="artifacts/app.png", full_page=True)
browser.close()
pytest.ini deselects it by default; -m browser runs it against a real streamlit run that the live_app_url fixture starts on a free port and stops afterwards. Keep it to the two or three assertions that genuinely require a browser: that the map element exists, and that an interaction reaches the server.
Example 4 — running the suites
import sys
!{sys.executable} -m pytest -q -p no:cacheprovider 2>&1 | tail -6
tests/test_app.py:51: AssertionError
----------------------------- Captured stdout call -----------------------------
payload 2.02 MB gzipped
=========================== short test summary info ============================
FAILED tests/test_app.py::test_geojson_payload_within_budget - AssertionError...
1 failed, 12 passed, 1 deselected in 4.08s
The payload budget fails on the real layer. Rounding the coordinates to five decimal places — about a metre, far finer than the 0.01° simplification — is the fix:
%%writefile -a app/logic.py
def to_geojson_bytes(gdf, precision=1e-5) -> bytes:
"""Round the coordinates: the budget test caught the unrounded layer at 2.02 MB."""
import shapely
rounded = gdf.copy()
rounded["geometry"] = shapely.set_precision(rounded.geometry.values, precision)
return rounded.to_json().encode("utf-8")
!{sys.executable} -m pytest -q -s -p no:cacheprovider 2>&1 | grep -E "payload|reruns|passed|failed"
!{sys.executable} -m pytest -q -m browser -p no:cacheprovider 2>&1 | tail -1
.payload 1.14 MB gzipped
reruns 0.02, 0.33, 0.01 s
13 passed, 1 deselected in 4.39s
1 passed, 13 deselected in 8.24s
And the cost the rerun test guards, measured directly — the app as written, then a copy with its @st.cache_data removed:
import statistics
import sys
import time
from streamlit.testing.v1 import AppTest
source = open("app/main.py").read()
with open("app/main_uncached.py", "w") as out:
out.write(source.replace("@st.cache_data\n", ""))
for name in ("app/main.py", "app/main_uncached.py"):
started = time.perf_counter()
at = AppTest.from_file(name, default_timeout=120).run()
first = time.perf_counter() - started
times = []
for option in ("PT", "ES", "FR", "IT"):
at.selectbox(key="country").select(option)
started = time.perf_counter()
at.run()
times.append(time.perf_counter() - started)
print(f"{name:22} first run {first:.2f} s, rerun median {statistics.median(times):.2f} s")
print(f"load average {os.getloadavg()[0]:.1f}")
app/main.py first run 1.01 s, rerun median 0.03 s
app/main_uncached.py first run 0.93 s, rerun median 0.52 s
load average 1.2
Extract the analysis, test payload and reruns
Why AppTest covers most of what matters
It executes the script in-process, exposes the widgets and the rendered elements, and lets the test drive reruns. That is exactly the loop a user performs, without a browser, a port or a wait.
Measured here, a first run took 1.01 s and a rerun 0.03 s, and the thirteen fast tests about four seconds in all. Speed is what determines whether tests get run, which is why headless testing is not a compromise here.
Why extracting the analysis is the highest-leverage change
An app whose filtering lives inside the script body can only be tested through the app. The same logic in plain functions is tested directly, reused in a batch job, and survives a change of framework.
It also makes the app tests smaller and more stable: they check the wiring — does this widget drive that number — rather than the arithmetic, which is checked once, elsewhere.
Why payload and rerun tests belong in the suite
Both regress silently. Somebody adds a column to the export and the payload doubles; somebody removes a cache decorator during a refactor and every interaction becomes four times slower.
Neither produces an error, so neither is noticed until a user complains. An assertion with a number in it — under 2 MB gzipped, under 0.2 s per rerun — converts a slow discovery into a failing build.
Why the browser suite should stay tiny
Browser tests are slow, order-dependent and prone to timing flakiness. Every assertion that can be made headlessly should be.
What genuinely needs a browser is narrow: that the map component mounts and draws, and that an interaction reaches the server. Two or three tests, run separately, gives the confidence without the maintenance cost of a large end-to-end suite.
In-process tests, widget keys and exceptions
AppTest.from_fileruns in-process — no port, no browser, no waiting.- Address widgets by
key, not by index, or a layout change breaks the tests. app.exceptionis where errors surface — assert on it after every run.app.session_stateis readable and writable, which is how state transitions are tested.- Use a tiny fixture with a non-ASCII name to catch encoding bugs in exports.
- Assert the payload budget — it regresses silently.
- Assert the rerun time — it catches a missing cache decorator.
- Mark browser tests and exclude them from the default run.
Internal links
- How to build a Streamlit app with an interactive map — the app under test
- Reruns and state explained: why your map app redraws everything — what
AppTestdrives - How to cache spatial data in a map app — what the rerun test protects
- What a map app can afford to send to the browser — the payload budget
- How to add a download button for filtered spatial data — the export tests
- How to test a GIS pipeline with pytest — the wider approach
- GIS test fixtures explained — building small awkward fixtures
- How to deploy a Python map app with Docker — what to run before deploying
FAQ
Can I test a Streamlit app without a browser?
Yes. streamlit.testing.v1.AppTest runs the script in-process and exposes widgets, output and session state. Measured, a first run took 1.01 s and a rerun 0.03 s.
What should I test in a map app?
The filter logic as plain functions, the state transitions, the wiring between widgets and outputs, the payload size and the rerun time.
How do I test a click on the map?
Set the state the component would have produced — app.session_state["selected"] = {...} — and assert what the app does with it. The component's own behaviour is the library's responsibility.
Do I need Playwright?
For two or three tests: that the map element mounts, and that an interaction reaches the server. Mark them and keep them out of the fast suite.
How do I catch a performance regression?
Assert a payload budget and a maximum rerun time. Both regress silently and neither raises an error; measured, removing the cache decorator took reruns from 0.03 s to 0.52 s.
Why address widgets by key?
Because indices change when the layout does. app.selectbox(key="region") survives a rearrangement that app.selectbox[0] does not.