iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

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

Day 8:回傳成功,不代表已經發生——UI 操作是非同步的

  • 分享至 

  • xImage
  •  

昨天的問題是「找到錯的東西」。今天的問題是:你操作了對的東西,函式回傳成功,但那件事還沒發生。


送出,不等於完成

edit.set_value("hello")
assert edit.get_value() == "hello"     # 有時候會失敗

這兩行之間發生了什麼?

set_value 底下是一次跨處理程序的 COM 呼叫。它把「請把值設成 hello」這個請求送到被測應用程式那邊,然後回來。回來的意思是請求送到了,不是那個應用程式已經處理完了。

對方要處理這個請求,得先讓它的訊息迴圈跑到、更新內部狀態、重繪、再更新 UIA provider 回報的屬性。這一連串在忙碌的 CI runner 上可能要幾十毫秒,偶爾更久。

於是你得到一個間歇性失敗的測試。而它有兩個最糟的特性:在你的開發機上重現不出來(機器快、沒負載),而且失敗率跟 runner 當下的負載相關(所以它會在最忙的時候壞,也就是你最不想處理它的時候)。


錯誤的解法:sleep

edit.set_value("hello")
time.sleep(0.5)                        # 不要
assert edit.get_value() == "hello"

這個做法錯在兩頭:

  • 太短:在慢的機器上還是會壞,你只是把失敗率從 5% 降到 1%,然後它變成一個更難重現的鬼故事。
  • 太長:每個操作多等半秒,一百個操作就是 50 秒。而且 99% 的時候那半秒是純粹浪費。

更根本的問題是: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-L1311src/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 \n while Notepad returns \r\n or \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 去還原,你會把機器設定成一個呼叫端從來沒要求過的狀態。

清理只還原你確實讀到過的東西。 讀不到就不要動——你不知道原本是什麼。

哪些狀態值得包起來

判準是:這個狀態是不是全域的、而且沒有明確的擁有者?

  • 輸入法轉換模式——全域(執行緒層級但跨測試共享)
  • 前景視窗——全域,而且搶了不還會直接害到下一個測試
  • Caps Lock——這個我是被咬到才想到的,Day 17 會講

反過來,elem.set_value("x") 不需要 with,因為那個狀態屬於被測物,而被測物本來就會被清掉。


什麼時候不該用 _verified

不是每個操作都適合。有些操作沒有可觀察的後置條件:

window.set_foreground(verify=False)

「把視窗叫到前景」在某些情況下無法可靠驗證——特別是當有另一個東西正在跟你搶前景時。這時候硬要驗證只會製造假失敗。

所以 verify 是個參數,預設 True,但呼叫端可以關掉。API 應該讓「我知道我在做什麼」變得可能,而不是把安全機制焊死。


小結

寫法 問題
直接斷言 快機器過、慢機器壞,間歇性失敗
sleep(n) 猜錯就壞,猜對就浪費,而且表達了錯的意圖
輪詢後置條件 快的時候不等,慢的時候等到成立

五個要點:

  1. UI 操作是非同步的,回傳成功只代表請求送到了
  2. 輪詢後置條件取代 sleep,並把這件事寫進 API 命名
  3. 後置條件本身要能真的失敗——「多出現一次」而不是「包含」
  4. _verified 往前確認、with 往回收拾,兩者互補
  5. 清理只還原你讀到過的狀態,讀不到就不要猜

明天講一個連後置條件都救不了的東西:焦點之後的選取狀態,在 x64 和 ARM64 上根本不一樣,而且兩邊都不算錯。


上一篇
Day 7:對的 PID,錯的 HWND——app 自己的錯誤對話框也在它的處理程序裡
下一篇
Day 9:穿透 Content Island——WinUI 3 焦點路由與 Win32 佇列賽跑實錄
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言