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 to test in a map app, and where. most of it needs no browserpure functionsfilters, summariesordinary unit tests, millisecondsAppTestwidgets, state, wiringin-process: 0.43 s a runbudgetspayload size, rerun timethey regress silentlyPlaywrightdoes the map mount?two or three tests, kept apartSpeed is what determines whether tests get run, which is why headless is not a compromise spatialworkflow.io
Extracting the analysis into plain functions is the highest-leverage change.

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.

Driving a Streamlit app headlessly with AppTest. the user loop, without a browserAppTest.from_fileruns in-processset a widgetby key, not indexapp.run()0.04–0.06 s cachedassert the outputand app.exceptionNo port, no browser, no waiting — a suite of twenty runs in seconds spatialworkflow.io
Address widgets by key so a layout change does not break the tests.

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.

Two app regressions that never raise an error. assert a number, or nobody noticespayload under 2 MB gzippeda new column doubles itrerun under 0.5 sa removed decorator quadruples itnon-ASCII survives the exportencoding bugs hide in downloadsthe empty case is explainednot a stack tracechecking only that it renderscatches none of theseMeasured: a missing cache decorator took reruns from 0.05 s to 0.24 s spatialworkflow.io
Both regress in the pull request that causes them and are noticed weeks later.

In-process tests, widget keys and exceptions

  • AppTest.from_file runs in-process — no port, no browser, no waiting.
  • Address widgets by key, not by index, or a layout change breaks the tests.
  • app.exception is where errors surface — assert on it after every run.
  • app.session_state is 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.

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.