How to Test a Map App Without a Browser
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, a full app run took 0.43 s and a rerun 0.04โ0.06 s, so a suite of them runs in seconds.
Quick answer
from streamlit.testing.v1 import AppTest
def test_filter_changes_the_result():
app = AppTest.from_file("app/main.py", default_timeout=60)
app.run()
assert not app.exception
before = app.metric[0].value
app.selectbox[0].select("North")
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/main.py")
app.run()
assert any("draw" in text.value.lower() for text in app.info)
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.
Step-by-step solution
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_region_filter_selects_only_that_region(districts):
selection = Selection(region="North")
subset = apply_selection(districts, selection)
assert set(subset["region"]) == {"North"}
def test_threshold_is_inclusive(districts):
subset = apply_selection(districts, Selection(min_rate=8.0))
assert subset["rate"].min() >= 8.0
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("North")
app.slider[0].set_value(8.0)
app.run()
assert app.metric[0].value == "42"
assert not app.exception
Widgets are addressable by type and index, or by key, which is more robust:
app.selectbox(key="region").select("North")
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 = AppTest.from_file("app/main.py")
app.run()
app.session_state["selected"] = {"E09000007"}
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:
import gzip
def test_geojson_payload_within_budget(districts):
body = to_geojson_bytes(districts.head(5000))
gz_mb = len(gzip.compress(body, 6)) / 1e6
assert gz_mb < 2.0, (
f"{gz_mb:.2f} MB gzipped exceeds the 2 MB budget. "
f"For reference, 50,000 folium markers measured at 23.88 MB of HTML.")
A budget test turns a silent performance regression into a failing build in the pull request that caused it.
5. Measure the rerun cost
import time
def test_rerun_is_fast_enough():
app = AppTest.from_file("app/main.py", default_timeout=120)
app.run()
times = []
for option in sorted(app.selectbox[0].options)[1:4]:
app.selectbox[0].select(option)
started = time.perf_counter()
app.run()
times.append(time.perf_counter() - started)
assert min(times) < 0.5, (
f"reruns take {min(times):.2f}s โ add @st.cache_data to the loaders. "
f"Measured elsewhere: 0.23โ0.25 s uncached against 0.04โ0.06 s cached.")
This is the test that catches a missing cache decorator, which is the single most common cause of a slow map app.
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.
Code examples
Example 1 โ fixtures and a fast suite
import geopandas as gpd
import pytest
from shapely.geometry import Point, Polygon
from streamlit.testing.v1 import AppTest
@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
"region": ["North", "North", "South", "South"],
"rate": [4.2, 9.8, 7.1, 12.0]},
geometry=[Point(-1.5, 54.0), Point(-1.6, 54.2),
Point(-0.1, 51.5),
Polygon([(0, 0), (0.1, 0), (0.1, 0.1), (0, 0.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/main.py", default_timeout=60)
instance.run()
assert not instance.exception, instance.exception
return instance
def test_app_starts(app):
assert not app.exception
assert app.title[0].value
def test_region_filter(app):
app.selectbox(key="region").select("North")
app.run()
assert app.metric(key="count").value == "2"
def test_threshold_filter(app):
app.slider(key="threshold").set_value(8.0)
app.run()
assert app.metric(key="above").value == "2"
def test_empty_selection_is_explained(app):
app.slider(key="threshold").set_value(20.0)
app.run()
assert any("no districts" in w.value.lower() for w in app.warning)
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
def test_download_produces_valid_csv(districts):
payload = to_csv(districts, key="test")
text = payload.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):
payload = to_geopackage(districts, key="test")
path = tmp_path / "out.gpkg"
path.write_bytes(payload)
import geopandas as gpd
back = gpd.read_file(path)
assert len(back) == len(districts)
assert back.crs == districts.crs
assert back.geometry.notna().all()
Round-tripping the export through the reader that consumers will use is the only check that matters, and it takes four lines.
Example 3 โ a small Playwright suite for the parts that need a browser
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, .folium-map", timeout=30_000)
assert page.locator("canvas, .folium-map").count() >= 1
page.screenshot(path="artifacts/app.png", full_page=True)
browser.close()
Mark it, exclude it from the default run, and 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.
Explanation
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, a full run of a small app took 0.43 s and a rerun 0.04โ0.06 s, so a suite of twenty is a few seconds. 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.5 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.
Edge cases or notes
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. A full run took 0.43 s and a rerun 0.04โ0.06 s in measurement.
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, a missing cache decorator took reruns from 0.05 s to 0.24 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.