快轉到主要內容
  1. 教學文章/

Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI

·9 分鐘· loading · loading · ·
Python Streamlit AppTest Pytest Testing Session-State Ci
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 105: 本文

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 讀取、操作與檢查元素。 因此它特別適合:

  1. 測 Streamlit 頁面的主要互動流程。
  2. 測 Widget、Session State 與顯示結果之間的關係。
  3. 在 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.secretsat.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 通常包含:

  1. 首次載入沒有 Exception。
  2. 一條主要成功流程。
  3. 一條 validation 或失敗流程。
  4. 一個重要 Session State 邊界。
  5. CI 在每次 Pull Request 執行。

結語:把「我點過了」變成可重跑的證據
#

Streamlit 的魅力是快,但開發快不代表測試只能靠手點。 AppTest 把 Widget 操作、rerun、Session State 與輸出檢查都留在 Python 世界裡,正好能和 pytest、fixture、mock、CI 接起來。 先替最重要的按鈕與輸入框加上 key,再寫一條成功流程、一條錯誤流程。 當下次重構後 CI 仍然是綠色,你得到的不只是安心,而是一套可重複驗證的互動規格。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 105: 本文

相關文章

Python pytest fixtures 進階:conftest、factory 與測試資料管理
·8 分鐘· loading · loading
Python Pytest Fixtures Testing Conftest Monkeypatch Developer-Tools
Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
uv + GitHub Actions 實戰:Lockfile、Cache 與可重現 CI
·5 分鐘· loading · loading
Python Uv GitHub Actions Ci Lockfile Cache Developer-Tools
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Streamlit Auth 實戰:session_state、登入狀態與權限頁面
·7 分鐘· loading · loading
Python Streamlit Authentication Session-State OIDC Security
Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis