backend / web frameworks / streamlit / 07_testing_and_advanced.md

Streamlit Testing and Advanced Topics - Interview Questions

3 interview angles 6 min read source

Streamlit Testing and Advanced Topics - Interview Questions

1. How do you test a Streamlit app?

Streamlit ships AppTest (in streamlit.testing.v1) — a headless test harness that runs your script and lets you assert on widgets and outputs.

# tests/test_app.py
from streamlit.testing.v1 import AppTest

def test_counter_increments():
    at = AppTest.from_file("app.py").run()
    assert at.session_state["count"] == 0

    at.button[0].click().run()
    assert at.session_state["count"] == 1

    assert at.markdown[0].value == "Count: 1"

It runs in the same process as pytest — no browser, no WebSocket, fast. Asserts:

  • at.button[i] / at.text_input[i] / at.selectbox[i] — widget access by type and index, or by key=.
  • at.markdown / at.dataframe / at.title — output elements.
  • at.session_state — same dict you read in the app.
  • at.exception — list of exceptions raised during the run.

Pattern: load the script, simulate interactions with .click() / .set_value(...), call .run() again, assert.


2. How do you simulate widget interactions in tests?

at = AppTest.from_file("app.py").run()

at.text_input(key="name").set_value("Alice").run()
at.selectbox(key="role").select("admin").run()
at.checkbox(key="terms").check().run()
at.slider(key="age").set_value(30).run()
at.button(key="submit").click().run()

assert at.markdown[-1].value == "Welcome, Alice (admin)"

.set_value() / .click() mutates the widget; .run() re-executes the script with the new state. Chain them for multi-step flows.

For session state preconditions, set them before the first run:

at = AppTest.from_file("app.py")
at.session_state["user"] = {"id": 1, "name": "Alice"}
at.run()

3. What is st.fragment and when do you use it?

@st.fragment (1.33+) marks a function whose interactions trigger a partial rerun — only the fragment re-executes, not the whole script.

import streamlit as st

st.title("Big dashboard")
expensive_data = load_million_rows()  # not re-run on fragment interactions

@st.fragment
def filter_panel():
    region = st.selectbox("Region", ["US", "EU", "APAC"])
    metric = st.selectbox("Metric", ["revenue", "users"])
    st.bar_chart(expensive_data.query("region == @region")[metric])

filter_panel()

Clicking inside filter_panel() re-runs only the fragment. The expensive load_million_rows() outside doesn’t fire.

Use for: filter panels, embedded charts, any sub-component whose updates shouldn’t ripple to the whole page.

@st.fragment(run_every="5s") makes the fragment auto-rerun on a timer — cheap polling for live dashboards.


4. How do you handle background work / long-running tasks?

Streamlit’s script is foreground; long jobs block the UI. Three patterns:

1. Threads with shared state.

import threading
import streamlit as st

def run_job(job_id):
    result = expensive_computation()
    st.session_state[f"job_{job_id}"] = result   # WARNING: see below

if st.button("Start"):
    job_id = uuid.uuid4().hex
    threading.Thread(target=run_job, args=(job_id,), daemon=True).start()
    st.session_state["last_job"] = job_id

# Need to add the script context for session_state to work cross-thread:
from streamlit.runtime.scriptrunner import add_script_run_ctx
ctx = get_script_run_ctx()
t = threading.Thread(target=run_job, args=(...,))
add_script_run_ctx(t, ctx)
t.start()

2. Offload to a real task queue (Celery / RQ / Cloud Tasks). App enqueues; the result lands in DB; Streamlit polls.

3. st.fragment(run_every="2s") to poll status.

@st.fragment(run_every="2s")
def status_panel():
    job = db.get_job(st.session_state["job_id"])
    st.write(job.status)
    if job.status == "done":
        st.success("Done!")
        st.write(job.result)

For anything past 30s, use option 2. Don’t tie up Streamlit threads for batch work.


5. How do you build a custom component?

When the built-in widgets don’t cover what you need, write a Streamlit Component — a React (or any-framework) widget that talks to the Python script via JSON messages.

Two flavors:

  • Static component — pure HTML/JS injected via components.html(...). No two-way data flow.
  • Bidirectional component — built with streamlit-component-lib (npm) and streamlit.components.v1.declare_component (Python). React frontend renders, calls Streamlit.setComponentValue(...), Python reads the return value.
import streamlit.components.v1 as components

# Static
components.html("<div>Hello from raw HTML</div>", height=100)
components.iframe("https://example.com", height=400)

# Declared (custom)
my_widget = components.declare_component("my_widget", path="./frontend/build")
result = my_widget(label="Pick", default=None)

In practice, before writing one, check the community list (streamlit-aggrid, streamlit-folium, streamlit-elements, streamlit-extras) — someone has probably already built it.


6. How do you use st.context and HTTP headers?

st.context (1.35+) exposes request metadata: headers, cookies, IP, locale.

user = st.context.headers.get("X-Forwarded-User")
locale = st.context.locale                    # e.g., "en-US"
session_cookie = st.context.cookies.get("session")

Used for:

  • Auth — read user from a proxy-set header.
  • i18n — pick translation by locale.
  • Audit logging — log which user did what.

Headers from a reverse proxy must be trusted; spoofable if the app is reachable directly. Always set enableXsrfProtection and bind only to internal interfaces when behind a proxy.


7. How do you handle errors and graceful failure?

try:
    df = load_data()
except FileNotFoundError:
    st.error("Data file not found — please re-upload.")
    st.stop()                            # halts the rest of the script
except Exception as e:
    st.exception(e)                      # nice traceback display
    st.stop()
  • st.error / st.warning / st.success / st.info — colored banner messages.
  • st.exception(e) — shows the exception object with traceback.
  • st.stop() — cleanly halts script execution. Nothing below runs.

For production, set client.showErrorDetails = false in config and route exceptions to Sentry — users shouldn’t see Python tracebacks.


8. How do you theme a Streamlit app?

.streamlit/config.toml:

[theme]
base = "light"                  # or "dark"
primaryColor = "#1f77b4"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#f0f2f6"
textColor = "#262730"
font = "sans serif"

Or build a custom theme through Settings → Theme in the UI, copy the resulting TOML into config.

For per-component CSS overrides, inject CSS via st.markdown(unsafe_allow_html=True):

st.markdown(
    "<style>.stButton button { border-radius: 999px; }</style>",
    unsafe_allow_html=True,
)

This is brittle — Streamlit’s internal class names can change between versions. Use sparingly.


9. How do you measure performance / find what’s slow?

  • Profile a function — wrap with cProfile, log results.
  • Time blockstime.perf_counter() around expensive calls; log the delta.
  • Check the rerun trigger — open browser devtools → Network → WS, watch the rerunScript messages. If reruns are firing on every keystroke, something is bound to a text_input outside a st.form.
  • Cache audit — count @st.cache_data / @st.cache_resource hits/misses. A cached function that’s still slow is a cold-cache problem; a function that should be cached but isn’t is missing the decorator.
  • Memory@st.cache_resource keeps objects alive in process memory. Large models per pod = OOM. Track resident memory (psutil.Process().memory_info().rss).

Common culprits in slow Streamlit apps: un-cached data loads on every rerun, large DataFrames passed through st.dataframe without filtering, inline LLM calls outside an if prompt: guard, blocking I/O in the main script.


10. Common advanced pitfalls

Pitfall Fix
Threads can’t access st.session_state Attach script context with add_script_run_ctx(thread, ctx)
Custom CSS breaks after a Streamlit upgrade Avoid CSS overrides; use theme + components
st.fragment doesn’t share state It does — fragments read/write st.session_state like the main script, but their reruns are isolated to the fragment
Tests pass locally, fail in CI Headless AppTest is reproducible; usually a missing test dependency or a path difference (AppTest.from_file is relative to cwd)
st.write_stream returns a generator, not a string Capture the return value; it’s the joined result
Memory leak across reruns Resources cached with @st.cache_resource persist; ensure you’re not calling .clear() and reloading on every rerun
App freezes during a long synchronous LLM call Always stream. If you can’t stream, run in a thread + poll, or offload to a queue

Interview angle

  • “How do you test a Streamlit app?” - AppTest runs the script without a browser and lets you assert on widget values and rendered output. The stronger approach is keeping business logic in plain modules with their own tests and leaving Streamlit as a thin view layer.
  • “How do you keep it maintainable?” - separate data access and computation from presentation. A Streamlit script that also holds business logic is untestable and hard to reuse.
  • “What are the common performance problems?” - uncached expensive calls re-running on every interaction, loading large datasets per rerun, and unbatched inputs. Caching plus st.form fixes most of them.