今天講一個最自然的做法,以及它在跨環境時會遇到什麼。
入門範例通常長這樣,我自己一開始也是這樣寫的:
window = find_window(title="Notepad")
直覺、好讀、馬上能動。作為第一個範例它很稱職——重點是讓人看懂在做什麼。
它會一路動到你的測試遇到第一台非英文的機器為止。
在我那台 zh-TW 的虛擬機上,記事本的標題是「記事本」。日文機器上是「メモ帳」,德文是「Editor」。
而且不只語言的問題。同一台機器上,同一個記事本的標題會是:
Untitled - Notepad(Windows 10 風格)*Untitled - Notepad(有未儲存變更,前面多一個星號)Notepad(Windows 11 新版,開新檔案時就只有這樣)我在 CI 上實際抓到的兩筆紀錄:
x64: Window 'Untitled - Notepad' (HWND: 1048594, PID: 8388)
ARM64: Window 'Notepad' (HWND: 262788, PID: 8588)
同一個應用程式、同一個測試、兩台 runner,標題不一樣。 任何寫死標題的比對都會在其中一邊失敗。
根本問題在這裡:視窗標題是使用者介面文字。它會被翻譯、會隨狀態改變、會在版本更新時被重新設計。
拿 UI 文字當程式的識別依據,等於是說「這個功能的正確性取決於在地化團隊不要改字」。
那該用什麼?三種比標題穩定得多的東西:
Window.find(class_name="#32770") # 標準對話框
類別名稱是開發者寫死在程式碼裡的,不會被翻譯。#32770 在全世界每一台 Windows 上都是對話框。
限制是它不夠精確——#32770 到處都是(Day 7 那個 bug 的來源)。所以它通常要跟別的條件組合。
AppSpec(cmd=["notepad.exe"], process_names=["notepad.exe"])
執行檔名稱不會被在地化。搭配 QueryFullProcessImageNameW 從 PID 反查,就能問「這個視窗是不是那個執行檔開的」。
這是最可靠的一種,因為它完全不依賴任何名稱:
before = WindowCensus.capture()
launch()
# 新出現的那個就是我的
「我剛才啟動了什麼」這個問題的答案,跟語言、跟版本、跟標題格式都無關。
wintegrate 的探索邏輯就是把這三種疊起來用——diff 找出候選,process_names 和 window_classes 縮小範圍,標題(如果有給)只是最後一道可選的篩子,而不是主要依據。
DEFAULT_TEXT_INPUT_LADDER同樣的問題在視窗裡面也存在。「找到這個視窗的文字輸入區」——怎麼找?
不同的應用程式,輸入區可能是:
| 識別方式 | 值 | 常見於 |
|---|---|---|
| 視窗類別 | RichEditD2DPT |
Win11 分頁式記事本 |
| 視窗類別 | Edit |
傳統 Win32 編輯控制項 |
| UIA 控制項型別 | 50030(Document) |
多數編輯器 |
| UIA 控制項型別 | 50004(Edit) |
傳統對話框 |
而且如同 Day 6 講的,這些東西還可能藏在子 HWND 後面。
所以 find_text_input() 的做法是一個階梯:按照可能性排序,依序嘗試,第一個找到的就用:
DEFAULT_TEXT_INPUT_LADDER: tuple[dict, ...] = (
{"class_name": "RichEditD2DPT"}, # Win11 tabbed Notepad
{"class_name": "Edit"}, # classic Win32 edit control
{"control_type_id": 50030}, # UIA Document
{"control_type_id": 50004}, # UIA Edit
)
這個模式(有序的候選清單,逐一嘗試)比「寫一個聰明的條件」好,理由是它可以被讀、被擴充、被除錯。當某個應用程式的輸入區找不到時,你看一眼這個清單就知道要加什麼。
而如果它是一個複雜的布林條件,你得先看懂它才能改。
順序決定誰先被選中,所以清單裡刻意排除掉什麼,跟放進什麼一樣重要。原始碼裡這段註解就掛在那個 tuple 下面:
# Note: "NotepadTextBox" is deliberately absent — it is the *container* hwnd
# around the RichEdit child, so it wins the race while the child is still
# materializing, and its get_value() is always empty (verification can never
# pass against it).
記事本的輸入區其實有兩層:外面一個叫 NotepadTextBox 的容器 HWND,裡面才是真正的 RichEditD2DPT。
把 NotepadTextBox 加進階梯看起來很合理——它確實在那裡、名字也對。但它會製造一個很難查的失敗:
send_keys 回傳成功(Day 2 的老問題:送出成功不代表送到了)get_value() 永遠是空字串
真正的原因不是打字壞了,是你拿到的是外殼。而這件事只在冷啟動時發生——機器夠快時 RichEdit 早就好了,容器不會贏。所以它在你的開發機上完全重現不出來,只在 CI 上偶爾出現(Day 16)。
這裡有兩個一般性的教訓。
一、候選清單裡不該放「一定會失敗驗證」的項目。 一個永遠回空字串的元素,不管多容易找到都是負分——它只會讓真正的目標被遮住。
二、把排除的理由寫進註解。 這種「為什麼不做某件事」的知識,沒寫下來就一定會流失。三個月後有人看到 NotepadTextBox 不在清單裡,很合理地把它加回去,然後這個 bug 重新上演一次——而且他找不到任何線索說明前一次是怎麼回事。
程式碼記錄了你做的決定;只有註解能記錄你否決過的決定。
同樣的原則反過來用:斷言也不該依賴 UI 文字。
assert "儲存成功" in status_label.name # 脆
assert save_button.is_enabled is False # 穩
前者會在翻譯更新、文案調整時壞掉,而那些改動跟你要測的行為毫無關係。後者測的是狀態,不是它怎麼被顯示。
當然有些時候你就是要測顯示出來的文字對不對——那是合理的,只是那時候你測的是在地化,該放在專門測在地化的地方,而不是散落在功能測試裡。
automation_id 也不是一致地與語言無關這一篇的結論是「用類別名稱與 automation_id,不要用視窗標題與 UI 文字」。那個結論成立,但它的邊界比我原本以為的窄。
我在驅動一個 WinUI 3 應用程式時量到這個:
[Button] aid='Back' name='返回/上一頁'
[Button] aid='Refresh' name='重新整理'
[Button] aid='Up' name='返回上層'
[Edit] aid='CurrentPathGet'
[Button] aid='TabBarAddNewTabButton' name='新索引標籤'
工具列的 id 是穩定的英文識別碼,name 是在地化的字串——教科書般的正確做法。
然後是側邊欄:
[ListItem] aid='首頁' name='首頁'
[ListItem] aid='已釘選' name='已釘選'
[ListItem] aid='桌面' name='桌面'
[ListItem] aid='下載' name='下載'
[ListItem] aid='SettingsButton' name='設定' ← 只有這一個是穩定 id
同一個應用程式裡,chrome 的 id 與語言無關、側邊欄的 id 就是在地化的顯示名稱。
所以「用 automation_id 就免疫在地化」是一條每個容器都要各自驗證的規則,不是一條可以整包套用的規則。
而這個例外的代價很具體:一個假設它整包成立的測試,會在英文機器上通過、在翻譯過的機器上失敗,而失敗訊息只會說「找不到 aid='Desktop' 的元素」。
還有第三種情況,比上面兩種都糟:同一個版本的同一個應用程式,在不同架構上連 id 有沒有都不一樣。 那個留到第五章講。
明天講怎麼在測試開始前把 runner 清乾淨——以及為什麼我為了走一棵處理程序樹,寧可自己寫 CreateToolhelp32Snapshot 也不裝 psutil。