iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 15

Day 15:在一個「全都是整合測試」的領域裡,把純函式挖出來

  • 分享至 

  • xImage
  •  

第三章的最後一天。今天不談 Win32,談一個測試策略問題。


這個領域的先天困境

桌面 UI 自動化的測試有個尷尬的性質:幾乎每一件事都需要真的開一個視窗。

要測「能不能找到元素」,得有元素。要測「按鍵有沒有送到」,得有一個會顯示文字的控制項。要測「焦點切換對不對」,得有兩個視窗。

於是整個測試套件變成一堆整合測試:

  • ——每個測試要啟動應用程式(Store App 冷啟動 12 秒起跳)
  • ——任何一個彈窗、任何一次時序抖動都可能讓它紅
  • 只能在 Windows 上跑——你的 Linux CI、你的 Mac 開發機都幫不上忙

這是這個領域的宿命,逃不掉。但「逃不掉」不等於「全部都得這樣」。


找出不需要 Windows 的那一塊

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:讓機器去想壞例子

純函式最適合搭配 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}}} 會爆」,而是縮成「{} 會爆」。這個能力常常直接指出根本原因。


Hypothesis 找到的東西:Python 的寬容

重複次數的解析是這樣:

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 上有沒有動」。

但每一個能往下推的測試,都換來三件事:更快的回饋、更明確的失敗訊息、更少的環境相依。 而在一個天生就慢又脆的領域裡,這三件事都很貴。


小結

  • 桌面自動化天生以整合測試為主,但不代表全部都得是
  • 找出「決定要做什麼」和「實際去做」之間那條線,把前者切成純函式
  • 純函式適合 property-based testing:讓機器去想你想不到的壞例子
  • int() 接受 -5+31_000_000——語言的寬容就是你的漏洞
  • 在「使用者不可能是這個意思」的地方要拒絕,而不是照做

第三章結束。明天進入第四章:讓 CI 穩定。第一個問題是那個貫穿整個系列的數字——為什麼同一個記事本,在一台機器上 0.10 秒開好,在另一台常態冷啟動要近 8 秒(高負載下甚至飆到 17 秒)。


上一篇
Day 14:為什麼切換鍵盤佈局永遠失敗?——跨處理程序的輸入法控制
下一篇
Day 16:0.10 秒 vs 7.84 秒——冷啟動比任何人的預算都久
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言