第三章的最後一天。今天不談 Win32,談一個測試策略問題。
桌面 UI 自動化的測試有個尷尬的性質:幾乎每一件事都需要真的開一個視窗。
要測「能不能找到元素」,得有元素。要測「按鍵有沒有送到」,得有一個會顯示文字的控制項。要測「焦點切換對不對」,得有兩個視窗。
於是整個測試套件變成一堆整合測試:
這是這個領域的宿命,逃不掉。但「逃不掉」不等於「全部都得這樣」。
send_keys("{HOME}+{END}{DELETE}") 這個語法要被解析。而解析這件事跟 Windows 一點關係都沒有——它是「字串進、動作序列出」的純計算。
所以把它切出來:
def parse_key_spec(spec: str) -> list[KeyAction]:
"""Parses a key specification string into actions. Pure — no Win32 calls."""
切出來之後,這個函式可以在 Linux 上測、可以在 macOS 上測、跑一千次只要幾毫秒,而且測試結果跟環境完全無關。
這個切法的判準是:找出「決定要做什麼」和「實際去做」之間的那條線。 前者通常是純邏輯,後者才需要作業系統。這條線在很多系統程式裡都存在,只是常常沒有被畫出來。
wintegrate 裡另一個例子是 Window.find 的比對邏輯(Day 7)——把「桌面上有哪些視窗」抽成一個可替換的資料來源之後,「哪一個符合條件」就變成純函式了。
純函式最適合搭配 property-based testing。
傳統的測試是你想例子:
assert parse_key_spec("{DOWN 5}") == [KeyAction("DOWN", repeat=5)]
assert parse_key_spec("{HOME}") == [KeyAction("HOME", repeat=1)]
問題是你只會想到你想得到的例子。而 bug 通常藏在你沒想到的地方。
Hypothesis 反過來做:你描述一個性質,它產生大量輸入去試圖推翻它。
@given(st.text())
def test_parse_never_crashes(spec):
"""Any string is either parsed or rejected with ValueError — never a crash."""
try:
parse_key_spec(spec)
except ValueError:
pass
這個測試說的是:不管使用者丟什麼字串進來,都不可以出現 ValueError 以外的例外。 Hypothesis 會去生成各種怪東西——空字串、只有 {、巢狀括號、極長字串、詭異的 Unicode——去找反例。
而且它找到反例後會做 shrinking:自動把反例縮到最小。它不會跟你說「{DOWN \x00123}}} 會爆」,而是縮成「{} 會爆」。這個能力常常直接指出根本原因。
重複次數的解析是這樣:
send_keys("{DOWN 5}") # 按五次
直覺寫法:
count = int(text_after_space)
int() 看起來很安全。但它接受的東西比你想的多:
int("-5") # -5 → 負的重複次數
int("+3") # 3 → 你沒打算支援的語法
int("1_000_000") # 1000000 → 底線分隔!
最後那個是 Python 3.6 加入的數字底線語法。int("1_000_000") 會安然回傳一百萬——於是 {DOWN 1_000_000} 這個看起來會被拒絕的輸入,變成一百萬次按鍵。你的測試看起來像當機。
這就是語言的寬容變成你的漏洞:你以為 int() 在幫你驗證,它其實在幫你猜測。
修法是明確一點:
MAX_KEY_REPEAT = 1000
if not raw.isdigit(): # 只接受純數字
raise ValueError(f"Invalid repeat count: {raw!r}")
count = int(raw)
if not 1 <= count <= MAX_KEY_REPEAT:
raise ValueError(f"Repeat count out of range: {count}")
isdigit() 拒絕負號、正號和底線。範圍檢查擋掉那個上限。
上限本身也是一個設計決定:{DOWN 1000} 是合理的(捲到底),{DOWN 100000} 幾乎一定是打錯字或攻擊。API 應該在「使用者不可能是這個意思」的地方拒絕,這跟 Day 7 那個「沒有任何條件就拋例外」是同一個原則。
最後的分層是這樣:
| 層 | 測什麼 | 在哪跑 | 速度 |
|---|---|---|---|
| 純函式 | 按鍵語法、查詢比對邏輯 | 任何 OS | 毫秒 |
| 隔離性 | DLL handle 沒有洩漏(Day 12) | Windows,不需 GUI | 毫秒 |
| 整合 | 真的開視窗、真的打字 | Windows + 互動桌面 | 秒~分 |
下層不會取代上層——那些整合測試還是得存在,因為真正的價值在「它在真實 Windows 上有沒有動」。
但每一個能往下推的測試,都換來三件事:更快的回饋、更明確的失敗訊息、更少的環境相依。 而在一個天生就慢又脆的領域裡,這三件事都很貴。
int() 接受 -5、+3、1_000_000——語言的寬容就是你的漏洞
第三章結束。明天進入第四章:讓 CI 穩定。第一個問題是那個貫穿整個系列的數字——為什麼同一個記事本,在一台機器上 0.10 秒開好,在另一台常態冷啟動要近 8 秒(高負載下甚至飆到 17 秒)。