昨天的問題是「找到錯的東西」。今天的問題是:你操作了對的東西,函式回傳成功,但那件事還沒發生。
edit.set_value("hello")
assert edit.get_value() == "hello" # 有時候會失敗
這兩行之間發生了什麼?
set_value 底下是一次跨處理程序的 COM 呼叫。它把「請把值設成 hello」這個請求送到被測應用程式那邊,然後回來。回來的意思是請求送到了,不是那個應用程式已經處理完了。
對方要處理這個請求,得先讓它的訊息迴圈跑到、更新內部狀態、重繪、再更新 UIA provider 回報的屬性。這一連串在忙碌的 CI runner 上可能要幾十毫秒,偶爾更久。
於是你得到一個間歇性失敗的測試。而它有兩個最糟的特性:在你的開發機上重現不出來(機器快、沒負載),而且失敗率跟 runner 當下的負載相關(所以它會在最忙的時候壞,也就是你最不想處理它的時候)。
sleepedit.set_value("hello")
time.sleep(0.5) # 不要
assert edit.get_value() == "hello"
這個做法錯在兩頭:
更根本的問題是:sleep 表達的是「我猜這要花多久」,但你真正想說的是「等到這件事成立」。這兩件事只是碰巧常常一致。
def type_verified(self, text: str, timeout: float = 2.0) -> bool:
self.send_keys(text)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._content_check_passes(text):
return True
time.sleep(0.05)
raise ActionVerificationError(...)
差別是:
wintegrate 把這個模式做成命名慣例,看到 _verified 就知道它會等:
type_verified() # 打完字,確認內容真的變了
select_verified() # 選取後,確認選取狀態真的成立
expand_verified() # 展開後,確認真的展開了
navigate_path_verified() # 走完整條路徑,每一步都確認
set_checked_verified() # CheckBox / RadioButton 勾選並驗證
set_value_verified() # Slider 滑桿數值設定並驗證
select_tab_verified() # TabControl 分頁切換並驗證
相關原始碼:
src/wintegrate/element.py#L1149-L1311、src/wintegrate/locators.py#L428-L443以及控制項封裝src/wintegrate/controls.py#L403-L669
命名把「這個呼叫會等」變成 API 的一部分,而不是一段藏在文件裡的注意事項。
「確認內容真的變了」聽起來很簡單,其實不然。
假設你在記事本裡按了三次換行,然後驗證換行數:
assert content.count("\n") == 3
這個斷言會失敗,而且原因跟你的程式完全無關:Windows 的換行是 \r\n。而更麻煩的是,不同控制項回報的內容可能是 \n、\r\n、甚至 \r——取決於它是傳統 EDIT、RichEdit、還是某個 XAML 控制項。
這件事 wintegrate 的 README 列在陷阱清單裡:
Unverified fire-and-forget: Typing without post-condition assertions hides silent failures (e.g. counting
\nwhile Notepad returns\r\nor\r).
所以 type_verified 的驗證邏輯有一個刻意的設計:它檢查的是有沒有新出現一次目標內容,而不是「最終值等於某個字串」。
為什麼要這樣?因為欄位裡本來可能就有東西。如果你要打的是 abc,而欄位原本就有 abc,那「最終值包含 abc」是恆真的——這個驗證什麼都沒驗到。要求「多出現一次」才能真正證明你的輸入生效了。
這是我覺得整個驗證機制裡最容易被寫錯的地方:一個永遠會通過的斷言,比沒有斷言更糟。
_verified 只解決一半的問題到這裡有個容易忽略的空缺:_verified 保證的是「我的操作生效了」,但它完全不管「我改動的狀態有沒有還原」。
這兩件事會壞在不同的地方:
| 問題 | 症狀 | |
|---|---|---|
_verified 沒做 |
操作還沒生效就繼續 | 這個測試間歇失敗 |
| 沒有還原 | 狀態留給下一個人 | 別的測試莫名失敗 |
第二種難查得多,因為失敗的地方跟原因隔了好幾個測試。
而 Python 對這件事有一個專門的構造,而且它保證會執行清理:
with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
edit.send_physical_keys("hello") # 保證是英數模式
# 離開時還原,即使中間拋了例外
with 和 _verified 是互補的:_verified 往前確認,with 往回收拾。 一個 API 如果會改動全域狀態,就該同時提供這兩者。
original = get_ime_conversion(self.hwnd)
...
finally:
if original is not None: # ← 這個判斷很重要
self.set_ime_conversion(int(original))
None 代表「沒有 IME 視窗回答」,那不等於「英數模式」。如果把 None 當成 0 去還原,你會把機器設定成一個呼叫端從來沒要求過的狀態。
清理只還原你確實讀到過的東西。 讀不到就不要動——你不知道原本是什麼。
判準是:這個狀態是不是全域的、而且沒有明確的擁有者?
反過來,elem.set_value("x") 不需要 with,因為那個狀態屬於被測物,而被測物本來就會被清掉。
_verified不是每個操作都適合。有些操作沒有可觀察的後置條件:
window.set_foreground(verify=False)
「把視窗叫到前景」在某些情況下無法可靠驗證——特別是當有另一個東西正在跟你搶前景時。這時候硬要驗證只會製造假失敗。
所以 verify 是個參數,預設 True,但呼叫端可以關掉。API 應該讓「我知道我在做什麼」變得可能,而不是把安全機制焊死。
| 寫法 | 問題 |
|---|---|
| 直接斷言 | 快機器過、慢機器壞,間歇性失敗 |
sleep(n) |
猜錯就壞,猜對就浪費,而且表達了錯的意圖 |
| 輪詢後置條件 | 快的時候不等,慢的時候等到成立 |
五個要點:
sleep,並把這件事寫進 API 命名_verified 往前確認、with 往回收拾,兩者互補明天講一個連後置條件都救不了的東西:焦點之後的選取狀態,在 x64 和 ARM64 上根本不一樣,而且兩邊都不算錯。