一. 前言:手動點過,不等於下次還能用 #
Textual 很適合快速做終端機介面。 但 App 一旦有 Input、Button、Screen、背景工作與 CSS,測試就容易停在一句:「我剛剛有按過,看起來正常。」 問題是,下一次改版可能讓快捷鍵失效、按鈕點了沒有更新狀態,或只在窄終端機把版面擠壞。 這些 bug 不一定會讓程式 crash,卻會讓使用者卡住。 這篇拍拍君要把 Textual App 放進 pytest:
- 用
App.run_test()在 headless 模式啟動 TUI - 用
Pilot模擬鍵盤與滑鼠 - 用
pytest-asyncio執行非同步測試 - 固定 terminal size,測不同版面邊界
- 用
pytest-textual-snapshot比較 SVG 畫面 這是 Textual 基礎篇 的測試專篇。 若你還在設計多步驟流程,可先看 Textual Form Wizard;長任務則可搭配 Background Workers 一起讀。
二. 安裝與專案結構 #
建立一個乾淨專案:
uv init textual-testing-demo
cd textual-testing-demo
uv add textual
uv add --dev pytest pytest-asyncio pytest-textual-snapshot
如果使用 pip:
python -m pip install textual pytest pytest-asyncio pytest-textual-snapshot
範例結構如下:
textual-testing-demo/
├── pyproject.toml
├── task_app.py
└── tests/
├── test_task_app.py
└── test_snapshots.py
Textual 的測試需要 asyncio 支援。
專案只使用 asyncio 時,可以在 pyproject.toml 設成 auto mode:
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
這樣每個 async def test_* 不必重複加 @pytest.mark.asyncio。
若同一個測試套件還要共存 Trio 等 async plugin,保留 strict mode 並明確標記會更安全。
三. 準備一個可測試的任務 App #
先做一個小型任務清單。 它有輸入框、新增按鈕、狀態列與清單,剛好能測到焦點、輸入、點擊和 DOM 更新。
# task_app.py
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Button, Footer, Header, Input, Label, ListItem, ListView
class TaskApp(App[None]):
TITLE = "拍拍君任務板"
CSS = """
Screen {
align: center top;
}
#editor {
width: 90%;
height: auto;
margin: 1 0;
}
#task {
width: 1fr;
}
#add {
width: 14;
margin-left: 1;
}
#status, #tasks {
width: 90%;
}
#tasks {
height: 1fr;
border: round $accent;
}
"""
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="editor"):
yield Input(placeholder="輸入任務", id="task")
yield Button("新增", id="add", variant="primary")
yield Label("尚無任務", id="status")
yield ListView(id="tasks")
yield Footer()
def on_mount(self) -> None:
self.query_one("#task", Input).focus()
async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != "add":
return
task_input = self.query_one("#task", Input)
title = task_input.value.strip()
status = self.query_one("#status", Label)
if not title:
status.update("請先輸入任務")
return
tasks = self.query_one("#tasks", ListView)
await tasks.append(ListItem(Label(title)))
task_input.value = ""
status.update(f"已新增 {len(tasks.children)} 個任務")
if __name__ == "__main__":
TaskApp().run()
這個範例刻意把 widget 都加上穩定的 id。
測試若依賴「畫面上的第二顆按鈕」,一調整 layout 就會壞;用 #add 查詢更能表達意圖。
四. 第一個 run_test():先確認 App 能啟動
#
run_test() 是非同步 context manager。
它會讓 App 在 headless 模式執行,不會真的接管目前的終端機,並回傳可操作介面的 Pilot。
# tests/test_task_app.py
from textual.widgets import Input, Label, ListView
from task_app import TaskApp
async def test_app_starts_empty():
app = TaskApp()
async with app.run_test() as pilot:
await pilot.pause()
assert app.query_one("#task", Input).value == ""
assert len(app.query_one("#tasks", ListView).children) == 0
assert str(app.query_one("#status", Label).render()) == "尚無任務"
Context manager 結束時,Textual 會關閉測試中的 App。
因此每個案例建立自己的 App,通常比跨 test 共用同一個 UI instance 更乾淨。
pilot.pause() 不是固定睡一秒。
它會先讓 pending messages 被處理,避免測試在 UI 還沒穩定時就急著 assert。
五. 用 Pilot 模擬鍵盤輸入與點擊 #
Pilot 的價值是讓測試走過接近使用者的操作路徑。 以下案例先點 Input、輸入文字,再點新增按鈕:
async def test_add_task_with_keyboard_and_mouse():
app = TaskApp()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.click("#task")
await pilot.press(*"ship docs")
await pilot.click("#add")
await pilot.pause()
task_input = app.query_one("#task", Input)
tasks = app.query_one("#tasks", ListView)
status = app.query_one("#status", Label)
assert task_input.value == ""
assert len(tasks.children) == 1
assert str(status.render()) == "已新增 1 個任務"
Pilot.press() 接受一串 key 名稱。
一般字元可以逐字傳入,也可以傳入 tab、enter、escape、ctrl+s 等按鍵名稱。
Pilot.click() 可以接受 CSS selector、widget type 或座標。
優先用 selector;只有真的要測 hit box 或滑鼠位置時,才需要 offset。
六. 不只測 happy path:空白輸入也要留下證據 #
UI 測試很容易只驗證「成功新增」。 但 validation 通常才是改版時最容易壞掉的路徑。
async def test_blank_task_is_rejected():
app = TaskApp()
async with app.run_test() as pilot:
await pilot.click("#add")
await pilot.pause()
tasks = app.query_one("#tasks", ListView)
status = app.query_one("#status", Label)
assert len(tasks.children) == 0
assert str(status.render()) == "請先輸入任務"
如果規格是「純空白也不能加入」,再加一個參數化案例:
import pytest
@pytest.mark.parametrize("value", ["", " ", "\t"])
async def test_invalid_task_values(value):
app = TaskApp()
async with app.run_test() as pilot:
app.query_one("#task", Input).value = value
await pilot.click("#add")
assert len(app.query_one("#tasks", ListView).children) == 0
這裡直接設定 .value 是刻意的。
測 validation 時,重點是資料邊界;沒必要每一組參數都重新模擬逐字輸入。
七. 固定 terminal size,測出 responsive 邊界 #
run_test() 的預設尺寸是 (80, 24)。
尺寸以 (寬, 高) 表示;測 layout 時最好明確寫出來,避免案例的意圖藏在預設值裡。
@pytest.mark.parametrize(
"terminal_size",
[
(60, 20),
(80, 24),
(120, 36),
],
)
async def test_editor_is_visible_at_supported_sizes(terminal_size):
app = TaskApp()
async with app.run_test(size=terminal_size) as pilot:
await pilot.pause()
assert app.query_one("#task", Input).region.width > 0
assert app.query_one("#add").region.width > 0
這不是完整的視覺驗證,但能快速抓出 widget 被擠成零寬、沒有 mount 或 selector 消失等結構性問題。 真正的顏色、border、對齊與換行變化,交給後面的 snapshot。
八. 非同步 UI 最常見的 flaky test:訊息還沒跑完 #
Textual 以 message loop 驅動畫面。 按下按鈕後,可能還要等 handler、worker callback、reactive watcher 和 layout refresh。 不要用這種方式賭機器速度:
import asyncio
await pilot.click("#add")
await asyncio.sleep(0.5) # 慢、脆弱,而且 0.5 秒沒有語意
優先改成:
await pilot.click("#add")
await pilot.pause()
若要觀察哪些 message 經過 App,也可以使用 message_hook:
async def test_button_emits_messages():
seen = []
app = TaskApp()
async with app.run_test(message_hook=seen.append) as pilot:
app.query_one("#task", Input).value = "release"
await pilot.click("#add")
await pilot.pause()
message_types = {type(message).__name__ for message in seen}
assert "Pressed" in message_types
message_hook 很適合診斷事件到底有沒有送出。
正式 assertion 仍應優先檢查使用者看得到的狀態,而不是把內部 message 順序綁得太死。
九. SVG Snapshot Regression:把畫面納入測試 #
互動測試回答「按鈕有沒有作用」。
Snapshot 則回答「最後畫面是不是意外變了」。
安裝 pytest-textual-snapshot 後,pytest 會提供 snap_compare fixture:
# tests/test_snapshots.py
from task_app import TaskApp
def test_task_app_default_snapshot(snap_compare):
assert snap_compare(
TaskApp(),
terminal_size=(100, 30),
)
第一次執行時沒有 baseline,測試會失敗並產生 SVG comparison report。 先打開報告,確認畫面真的正確,再更新 snapshot:
uv run pytest tests/test_snapshots.py
uv run pytest tests/test_snapshots.py --snapshot-update
uv run pytest tests/test_snapshots.py
--snapshot-update 不是「讓 CI 變綠」按鈕。
它代表你接受目前畫面成為新的 ground truth,所以一定要先人工看 diff。
十. Snapshot 也能先操作,再截圖 #
只截初始畫面不夠。
我們可以透過 run_before 讓 Pilot 先新增任務,再保存操作後的 UI:
from textual.pilot import Pilot
from textual.widgets import Input
from task_app import TaskApp
async def add_release_task(pilot: Pilot) -> None:
pilot.app.query_one("#task", Input).value = "release"
await pilot.click("#add")
await pilot.pause()
def test_task_app_with_item_snapshot(snap_compare):
assert snap_compare(
TaskApp(),
run_before=add_release_task,
terminal_size=(100, 30),
)
也可以針對窄畫面建立另一份 baseline:
def test_task_app_narrow_snapshot(snap_compare):
assert snap_compare(
TaskApp(),
terminal_size=(60, 20),
)
一個實用原則是:只替「支援的尺寸」保存 snapshot。 如果產品根本不承諾 30 欄可用,就不必為 30 欄的破版維護一張基準圖。
十一. Snapshot 失敗時,先分類再更新 #
看到 SVG diff,先問是哪一類變更:
- 預期的 UI 改版:人工檢查後更新 baseline。
- Textual 或字型環境變動:先確認 dependency lockfile 與 CI 環境。
- 真正的 regression:修 CSS、DOM 或互動流程,不更新 snapshot。
- 不穩定內容:時間、隨機值、游標 blink 等應固定或在截圖前關閉。 Snapshot 不適合取代所有 assertion。 一張畫面可以看出「有一列任務」,卻不一定能清楚表達內部資料真的存成正確狀態。 拍拍君推薦三層搭配:
純函式 unit test
↓ 快速驗證 validation 與資料轉換
Pilot interaction test
↓ 驗證事件、焦點與狀態改變
SVG snapshot test
↓ 驗證 layout、樣式與完整畫面
十二. CI:不要在每次失敗時自動更新 Snapshot #
CI 只應比較,不應接受新 baseline。
name: tests
on:
push:
pull_request:
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv sync --dev --locked
- run: uv run pytest -q
更新 snapshot 應在本機完成、檢查 SVG diff,並把 baseline 跟程式碼一起送進 review。
CI 若直接加 --snapshot-update,視覺 regression 反而會被自動蓋掉。
Dependency 版本也要鎖定。
Textual renderer 或 snapshot plugin 升級可能合理地改變 SVG,這類更新最好獨立成一個 PR,讓 diff 容易審查。
十三. 常見踩雷整理 #
1. 忘記 async test 設定 #
run_test() 必須在 coroutine 中使用。
設定 asyncio_mode = "auto",或在 strict mode 明確加 @pytest.mark.asyncio。
2. 用 sleep 等 UI #
固定延遲會讓測試又慢又 flaky。
先用 pilot.pause() 等待 pending messages。
3. Selector 綁畫面位置 #
Button:nth-of-type(2) 很容易因 layout 重構失效。
替互動 widget 加穩定 id,例如 #add、#save。
4. Snapshot 包含隨機資料 #
固定時間、亂數 seed 與測試資料。 Snapshot 必須可重現,才有資格當 baseline。
5. 一看到 diff 就 update #
先看報告,再決定是接受變更或修 regression。 不經檢查的 snapshot update,等於刪掉測試的警報器。
6. 一個測試模擬完整人生 #
不要把二十個 click 都塞進同一個案例。 一個 test 聚焦一條行為,失敗時才知道壞在哪裡。
結語:讓 TUI 的「看起來正常」變成可重跑證據 #
Textual 已經把 headless test、Pilot 與固定 terminal size 準備好了。 搭配 pytest-asyncio,我們可以驗證鍵盤、滑鼠、事件與狀態;再加 SVG snapshot,連 CSS 和 layout regression 都能提早抓到。 不必一開始就替每個畫面保存十張 snapshot。 先替核心流程寫一個啟動測試、一個互動測試,再挑最重要的寬窄版面建立 baseline。 下一次有人說「我有手動點過」,拍拍君就可以把測試報告推過去:很好,現在它每天都會自己點。🧪