第五章開始。前面二十天講的每一個坑,都是我在自己的測試裡踩到的——而自己的測試有一個結構性的問題:
一個為了測函式庫而寫的應用程式,只能證明函式庫跟自己一致。
tests/win32_controls_app.py、wpf_grid_app.ps1 這些測試用的小程式是我寫的。我知道每個控制項叫什麼、什麼時候建立、有沒有 automation_id。它們很好用,但它們不會給我意外。
所以這一章換一種做法:拿四個從來沒聽過這個函式庫的真實開源應用來驅動。
挑選的標準只有一個:一個標的代表一代 Windows UI 技術。
| 應用程式 | 技術 | 為什麼選它 |
|---|---|---|
| Notepad++ | Scintilla | 最廣泛被內嵌的第三方編輯器控制項 |
| WinMerge | Win32 / MFC | 純 Win32,零外部相依,而且是視覺工具 |
| DB Browser for SQLite | Qt | 跨平台框架,自繪,靠 QAccessible 橋接 |
| Files | WinUI 3 / .NET | 微軟最新的桌面堆疊,XAML content island |
這四個加起來覆蓋了從 1990 年代的 MFC 到 2020 年代的 WinUI 3。而它們對 UI Automation 的支援程度差異之大,超過我原本的預期。
UIA 承諾的是一個統一的抽象:不管底下是什麼框架,你都用 find_descendant、Invoke、Value 這一組東西。實際量出來是這樣:
| 怎麼識別元素 | 讀得到內容嗎 | 表格 | |
|---|---|---|---|
| Win32/MFC(WinMerge) | 按鈕靠名字(在地化);編輯窗格的 class name 含模組載入基底位址 | 完全不行 | N/A |
| Scintilla(Notepad++) | class name 穩定;工具列按鈕沒有 id | WM_GETTEXT 可讀 |
N/A |
| Qt(DB Browser) | automation_id 是完整物件路徑——但看你拿到哪個 Qt | Text 元素可讀 | pattern 不穩 |
| WinUI 3(Files) | automation_id 穩定英文——但側邊欄的是在地化名稱 | Value pattern 可讀 | N/A |
這張表本身就是這一章的論點:UIA 的實際覆蓋率取決於每個框架的 provider 實作了多少,而失敗方式各不相同。 有的沒有 pattern,有的 pattern 時有時無,有的連 class name 都不穩定,有的同一個 app 內部就不一致。
沒有一個「通用寫法」能同時吃下四個。有的是四組各自量過的寫法,加上一個共通的原則:先量,再寫。
還沒開始測任何功能之前,先有一個共通的問題要解決:應用程式會記住上次的樣子。
這在人手上是貼心,在 CI 上是不可重現。四個標的,四種不同的機制:
| App | 不可重現的來源 | 處理方式 |
|---|---|---|
| Notepad++ | 上次的 session 與開啟的檔案 | -nosession -multiInst -noPlugin |
| WinMerge | 使用者設定 | /noprefs |
| DB Browser | 上次開的 db 與選的分頁 | 每次給一個新的 db 檔 |
| Files | user_settings.json 裡的幾個鍵 |
改成 false |
Files 那個值得展開,因為它跟第四章講的 Store 版記事本 TabState 是同一個形狀,只是換了外觀——一個 JSON 鍵而不是一個資料夾:
DETERMINISTIC_STARTUP = {
"ContinueLastSessionOnStartUp": False,
"RestoreTabsOnStartup": False,
"OpenSpecificPageOnStartup": False,
}
不設這些的話,Files 首次啟動會導覽到一個 版本說明頁,而那一頁是內嵌的 WebView2。整棵 UIA 樹的一半會變成 Chromium 的無障礙樹——包括一個 RootWebArea 節點,而它會被 find_text_input() 誤當成主要文字輸入回傳,造成定位直接失準。
四個 app、四種機制、同一個需求。這是 GUI CI 真正的入門門檻,而且沒有任何一個 app 的文件會告訴你。
這四組測試在我的機器上跑得動,在別人的機器上不會——因為別人沒裝這四個 app。最自然的寫法是這樣:
requires_notepadpp = pytest.mark.skipif(
not NPP.exists(), reason="Notepad++ is not installed on this machine"
)
test_scintilla.py 從 Scintilla 那次就這樣寫,8 個測試。而我在做這一章的時候才發現:它從來沒有在 CI 上執行過。
CI 沒有裝 Notepad++,於是 8 個測試全部 skip,而摘要行寫的是「118 passed, 2 skipped」——那 2 個 skip 沒有人看。
一組會 skip 的測試,在儀表板上跟一組會通過的測試長得完全一樣。
這個問題跟它的修法留到 Day 29 一起講,因為它跟「fail-closed」是同一個主題。這裡先記下結論:本機缺 app 就 skip 是對的預設,release gate 上缺 app 必須是失敗,而這兩件事要能分開設定。
skipif 是安靜的:會 skip 的測試在儀表板上跟通過的測試一樣明天從 Scintilla 開始,而它的第一課是一個我連錯三次的結論:USER32 會幫你把系統訊息跨處理程序 marshal,但不會幫你 marshal 自訂訊息。