Streamlit 很適合快速把資料流程包成小工具,但「瀏覽器點起來沒問題」不是測試。
Widget 一多、Session State 一複雜,改一行 callback 就可能讓另一條操作路徑悄悄壞掉。
好消息是,Streamlit 內建的 AppTest 不需要真的啟動瀏覽器。
它能在 Python 測試裡執行 App、操作 Widget、觸發 rerun,再讓 pytest 檢查畫面與狀態。
一. AppTest 解決了什麼問題? #
一般單元測試很擅長測純函式,卻不容易回答這些問題:
- 使用者輸入文字再按下按鈕後,畫面會出現什麼?
- Widget 的值是否真的寫進
st.session_state? - 缺少 Secret 時,App 會不會直接噴出 Exception?
- 某次重構是否破壞了 Streamlit 的 rerun 流程?
- Pull Request 能不能在合併前自動跑完互動測試?
streamlit.testing.v1.AppTest 會模擬一個執行中的 Streamlit App。
它不像 Playwright 或 Selenium 那樣控制真正的瀏覽器,而是直接提供 API 讀取、操作與檢查元素。
因此它特別適合:
- 測 Streamlit 頁面的主要互動流程。
- 測 Widget、Session State 與顯示結果之間的關係。
- 在 CI 裡快速做 smoke test 與 regression test。
如果你還不熟 Streamlit 的基本元件,可以先看Streamlit 入門; 如果卡在 rerun、key 與 Session State,先補Streamlit 進階會更順。
二. 安裝與專案結構 #
先建立一個最小專案:
task-board/
├── .streamlit/
│ └── secrets.toml
├── app.py
├── pyproject.toml
└── tests/
└── test_app.py
用 uv 建立環境並安裝 Streamlit、pytest:
uv init
uv add streamlit
uv add --dev pytest
或使用 pip:
python -m pip install streamlit pytest
pyproject.toml 可以順手放入 pytest 設定:
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
接著從專案根目錄執行:
uv run pytest
路徑很重要。
AppTest.from_file("app.py") 是以你執行 pytest 的工作目錄為準,不是以測試檔所在位置為準。
三. 準備一個可測試的任務看板 #
我們做一個迷你任務看板,支援:
- 輸入任務名稱;
- 選擇優先級;
- 按下按鈕後加入 Session State;
- 空白輸入顯示警告;
- 達到上限時停用新增按鈕。
先建立 .streamlit/secrets.toml:
task_limit = 3
再建立 app.py:
import streamlit as st
TASK_LIMIT = int(st.secrets["task_limit"])
st.set_page_config(page_title="拍拍君任務板", page_icon="📝")
st.title("拍拍君任務板")
if "tasks" not in st.session_state:
st.session_state.tasks = []
task_name = st.text_input("任務名稱", key="task_name")
priority = st.select_slider(
"優先級",
options=["低", "中", "高"],
value="中",
key="priority",
)
is_full = len(st.session_state.tasks) >= TASK_LIMIT
if st.button("加入任務", key="add_task", disabled=is_full):
cleaned_name = task_name.strip()
if not cleaned_name:
st.warning("請輸入任務名稱")
else:
st.session_state.tasks.append(
{"name": cleaned_name, "priority": priority}
)
st.success(f"已加入:{cleaned_name}")
st.metric("任務數", len(st.session_state.tasks))
for index, task in enumerate(st.session_state.tasks, start=1):
st.write(f"{index}. [{task['priority']}] {task['name']}")
if is_full:
st.info("任務已達上限")
這個 App 不複雜,但已經包含測試最常遇到的四種邊界:輸入、互動、狀態與條件顯示。
四. 第一個 AppTest:先確認 App 跑得起來 #
建立 tests/test_app.py:
from streamlit.testing.v1 import AppTest
def test_app_starts_without_exception():
at = AppTest.from_file("app.py")
at.secrets["task_limit"] = 3
at.run()
assert len(at.exception) == 0
assert at.title[0].value == "拍拍君任務板"
assert at.metric[0].value == "0"
這裡故意把 from_file() 與 .run() 分成兩行。
因為 App 第一次執行前,必須先把測試用 Secret 放進 at.secrets。
at.exception 是一個元素序列。
只要 App 執行時有未捕捉的例外,它就不會是空的,因此很適合當最基本的 smoke test。
也可以寫成更短的形式:
at = AppTest.from_file("app.py").run()
但這只適合不需要在第一次執行前準備 secrets、query params 或 Session State 的頁面。
五. 用 Key 找 Widget,比 Index 更耐重構 #
AppTest 可以用畫面順序取得元素:
assert at.text_input[0].label == "任務名稱"
assert at.button[0].label == "加入任務"
也可以用 Widget Key:
assert at.text_input("task_name").label == "任務名稱"
assert at.button("add_task").label == "加入任務"
Index 很方便,但在頁面前面多插一個按鈕,at.button[0] 指到的東西就可能改變。
測主要互動流程時,拍拍君比較推薦為重要 Widget 設定穩定的 key。
容器也可以縮小搜尋範圍:
assert len(at.sidebar.button) == 0
assert len(at.main.text_input) == 1
如果頁面用了 columns 或 tabs,也能先選容器,再找裡面的元素:
assert at.columns[0].button[0].label == "儲存"
assert at.tabs[1].markdown[0].value == "歷史紀錄"
請記住:元素序列依照實際顯示順序排列,不一定等於程式碼出現的順序。
六. 模擬輸入、點擊與 Rerun #
現在來測真正的使用者流程:
from streamlit.testing.v1 import AppTest
def make_app() -> AppTest:
at = AppTest.from_file("app.py")
at.secrets["task_limit"] = 3
return at.run()
def test_user_can_add_a_task():
at = make_app()
at.text_input("task_name").set_value("整理測試")
at.select_slider("priority").set_value("高")
at.button("add_task").click().run()
assert at.success[0].value == "已加入:整理測試"
assert at.metric[0].value == "1"
assert at.markdown[0].value == "1. [高] 整理測試"
最容易忘記的是最後的 .run()。
Widget 操作先改變模擬輸入;只有呼叫 .run(),App 才真的執行下一輪 rerun。
這正好反映 Streamlit 的執行模型:互動不是只改某個 DOM 節點,而是讓整支 Python script 重跑。
多個輸入可以先設定,最後一起觸發一次 rerun:
at.text_input("task_name").set_value("寫文件")
at.select_slider("priority").set_value("低")
at.button("add_task").click().run()
如果要模擬使用者分兩次操作,就分兩次 .run(),不要為了讓測試短而改變真實互動順序。
七. 直接檢查 Session State #
畫面結果重要,內部狀態也值得檢查:
def test_task_is_saved_to_session_state():
at = make_app()
at.text_input("task_name").set_value(" 修正 callback ")
at.select_slider("priority").set_value("中")
at.button("add_task").click().run()
assert at.session_state["tasks"] == [
{"name": "修正 callback", "priority": "中"}
]
at.session_state 同時支援 key 與 attribute 形式:
assert at.session_state["tasks"] == at.session_state.tasks
也可以在第一次 run 前直接塞入狀態,快速抵達特定情境:
def test_full_board_disables_button():
at = AppTest.from_file("app.py")
at.secrets["task_limit"] = 2
at.session_state["tasks"] = [
{"name": "A", "priority": "高"},
{"name": "B", "priority": "低"},
]
at.run()
assert at.button("add_task").disabled is True
assert at.info[0].value == "任務已達上限"
這比真的點兩次按鈕更聚焦。 前者測「滿額狀態的 UI」,後者測「連續新增的流程」,兩者是不同測試目的。
八. 測 Validation 與錯誤路徑 #
只測 happy path,常常會得到一種很有自信的脆弱感。 空白輸入也應該被測到:
def test_blank_task_is_rejected():
at = make_app()
at.text_input("task_name").set_value(" ")
at.button("add_task").click().run()
assert at.warning[0].value == "請輸入任務名稱"
assert at.session_state["tasks"] == []
assert at.metric[0].value == "0"
也可以對所有測試加一道 Exception 防線:
assert len(at.exception) == 0
當斷言失敗時,先把 AppTest 目前看見的元素印出來:
print(at)
print(at.warning)
print(at.exception)
測試不要只斷言「某個元素存在」。 最好同時檢查內容與狀態,才能避免錯誤訊息出現了、資料卻仍被寫入的假通過。
九. Secrets 要注入假值,不要提交真密碼 #
測試執行位置若看得到 .streamlit/secrets.toml,模擬 App 也可能讀得到它。
但 CI 不該依賴開發者電腦上的檔案,更不能把正式密碼提交進 Git。
最安全的預設是注入 dummy secret:
at = AppTest.from_file("app.py")
at.secrets["task_limit"] = 3
at.secrets["api_token"] = "fake-token-for-tests"
at.run()
巢狀設定要使用 key notation:
at.secrets["database.url"] = "sqlite:///:memory:"
at.secrets 不支援 at.secrets.database 這種 attribute notation;
Session State 才同時支援兩種寫法。
如果 App 會呼叫外部 API,應把 API client 包在函式或模組裡,再用 pytest 的 monkeypatch 換成假實作。
AppTest 負責頁面流程,mock 負責隔離網路,分工會比真的打 production API 清楚得多。
需要複習一般 pytest fixture 與 monkeypatch,可以接著看pytest fixtures 進階。
十. 用 Fixture 減少重複初始化 #
測試變多後,把初始化整理成 fixture:
import pytest
from streamlit.testing.v1 import AppTest
@pytest.fixture
def app() -> AppTest:
at = AppTest.from_file("app.py")
at.secrets["task_limit"] = 3
return at.run()
def test_initial_state(app: AppTest):
assert app.metric[0].value == "0"
assert app.session_state["tasks"] == []
def test_add_button_is_enabled(app: AppTest):
assert app.button("add_task").disabled is False
Function-scoped fixture 預設會為每個測試建立新的 AppTest。 這正是我們想要的隔離:某個測試加過的任務,不會漏到下一個測試。 不要為了快就把 AppTest 改成 session scope。 共享可變的 Session State 會讓測試順序開始影響結果,最後變成只有整包一起跑才會壞的幽靈 bug。
十一. 在 GitHub Actions 自動執行 #
建立 .github/workflows/test.yml:
name: test
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Install dependencies
run: uv sync --locked --dev
- name: Run AppTest suite
run: uv run pytest -q
如果專案還沒使用 uv,也可以換成 actions/setup-python 加 pip 安裝。
重點不是工具,而是每個 Pull Request 都用乾淨環境重新安裝並執行測試。
Streamlit 官方也提供 Streamlit App Action,可自動對主頁與 pages/ 做基本 smoke test。
不過有明確商業邏輯時,仍應保留自己的 pytest 斷言,因為「頁面沒 Exception」不代表行為正確。
十二. 常見陷阱 #
1. 操作 Widget 後忘記 .run()
#
at.button("add_task").click()
這只設定了互動,還沒讓 App rerun。
2. 所有元素都用 Index #
小型 smoke test 可以用 [0],關鍵 Widget 則優先用穩定 key,重排版面時比較不會誤中別的元素。
3. 在測試裡放正式 Secret #
測試應使用 dummy value、環境注入或 mock。 真正打外部服務的 integration test 要分開標記,也不要預設每次 CI 都執行。
4. 一個測試模擬十幾步 #
失敗時會很難知道是哪個行為壞掉。 把測試拆成初始狀態、成功新增、validation、滿額狀態等單一目的。
5. 把 AppTest 當完整瀏覽器測試 #
AppTest 很快,但它不是真正的 browser automation。 CSS、JavaScript、自訂元件、下載流程或瀏覽器相容性仍可能需要 Playwright 類工具補上。
6. 忽略版本差異 #
AppTest 支援的元素會隨 Streamlit 版本演進。 遇到某個元件沒有專用 testing element 時,先查目前版本的 AppTest API 與 cheat sheet,不要憑印象硬猜方法名稱。
十三. 一套實用的測試分層 #
拍拍君建議把 Streamlit 專案的測試分三層:
| 層級 | 測什麼 | 工具 |
|---|---|---|
| 純邏輯 | 清理、計算、validation | pytest |
| App 互動 | Widget、rerun、Session State、畫面輸出 | AppTest + pytest |
| 真實瀏覽器 | CSS、自訂元件、下載、跨頁端到端流程 | Playwright |
不要把所有商業邏輯都塞在 app.py,再期待 AppTest 幫你測完一切。
可重用的計算與資料存取仍應拆成普通 Python 函式,這樣單元測試更快,AppTest 只需驗證 UI 接線。
最小但有價值的 AppTest suite 通常包含:
- 首次載入沒有 Exception。
- 一條主要成功流程。
- 一條 validation 或失敗流程。
- 一個重要 Session State 邊界。
- CI 在每次 Pull Request 執行。
結語:把「我點過了」變成可重跑的證據 #
Streamlit 的魅力是快,但開發快不代表測試只能靠手點。
AppTest 把 Widget 操作、rerun、Session State 與輸出檢查都留在 Python 世界裡,正好能和 pytest、fixture、mock、CI 接起來。
先替最重要的按鈕與輸入框加上 key,再寫一條成功流程、一條錯誤流程。
當下次重構後 CI 仍然是綠色,你得到的不只是安心,而是一套可重複驗證的互動規格。