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

Textual 測試實戰:Pilot、pytest-asyncio 與 Snapshot Regression

·7 分鐘· loading · loading · ·
Python Textual TUI Pytest Pytest-Asyncio Pilot Snapshot Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 107: 本文

featured

一. 前言:手動點過,不等於下次還能用
#

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 名稱。 一般字元可以逐字傳入,也可以傳入 tabenterescapectrl+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,先問是哪一類變更:

  1. 預期的 UI 改版:人工檢查後更新 baseline。
  2. Textual 或字型環境變動:先確認 dependency lockfile 與 CI 環境。
  3. 真正的 regression:修 CSS、DOM 或互動流程,不更新 snapshot。
  4. 不穩定內容:時間、隨機值、游標 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。 下一次有人說「我有手動點過」,拍拍君就可以把測試報告推過去:很好,現在它每天都會自己點。🧪

延伸閱讀
#

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

相關文章

Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools
Textual Background Workers 實戰:長任務、Progress、取消與 Log Console
·9 分鐘· loading · loading
Python Textual TUI Background-Workers Async Developer-Tools
Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal
Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session-State Ci