Backend / Web frameworks / Streamlit / 07_testing_and_advanced.md

Streamlit Testing and Advanced Topics - Interview Questions

Updated 3 interview angles 6 min read source
On this page12
  1. 1. How do you test a Streamlit app?
  2. 2. How do you simulate widget interactions in tests?
  3. 3. What is st.fragment and when do you use it?
  4. 4. How do you handle background work / long-running tasks?
  5. 5. How do you build a custom component?
  6. 6. How do you use st.context and HTTP headers?
  7. 7. How do you handle errors and graceful failure?
  8. 8. How do you theme a Streamlit app?
  9. 9. How do you measure performance / find what’s slow?
  10. 10. Common advanced pitfalls
  11. 11. Cross-links
  12. Interview angle

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.

python
# 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?

python
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:

python
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.

python
import streamlit as st

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

@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.

python
import threading
import streamlit as st

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

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.

python
@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.
python
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.

python
user = st.context.headers.get("X-Forwarded-User")
# e.g., "en-US"
locale = st.context.locale
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?

python
try:
    df = load_data()
except FileNotFoundError:
    st.error("Data file not found — please re-upload.")
    # halts the rest of the script
    st.stop()
except Exception as e:
    # nice traceback display
    st.exception(e)
    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:

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):

python
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 3

  • “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.