iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

第五章最後一天。前面六天都在證明「這個函式庫驅動得動別人的 app」,今天要回答更難的那個問題:

它抓得到 bug 嗎? 不是抓我自己寫的測試 app 的 bug——那只能證明它跟自己一致。

做法是挑四個已經被修好的真實 issue,找出「有 bug 的那一版」和「修好的那一版」,然後讓測試在前者上失敗、在後者上通過。


挑版本,要從修正挑,不要從回報挑

第一個案例我就挑錯邊,而錯法很值得記。

WinMerge #3055:「在 Options 選『插入製表符』,離開設定後會跳回『插入空格』。」issue 開在 2025-11-29,而 2.16.52.2 是 11-27 發佈的。看起來很清楚:52.2 有 bug,下一版 2.16.53 修好。

我把兩版都抓下來、寫好測試——兩版行為完全一樣,都是正確的。

回去翻 issue,維護者第一則留言就寫了:

Duplicates of #3015. This issue has been fixed in the latest version 2.16.52.2.

#3055 是 #3015 的重複,而 #3015 已經在 2.16.52.2 修好了。真正的配對是 2.16.52 / 2.16.52.2。

回報日期只告訴你誰在什麼時候注意到;只有修正 commit 才告訴你分界線在哪。

而 #3015 的根因漂亮得像教科書:PropEditor.cpp 把一個 std::clamp(v, 1, MAX_TABSIZE) 的驗證器掛到了 OPT_TAB_TYPE,但它是要給 OPT_TAB_SIZE 的。「插入製表符」的值是 0,低於 clamp 的下界,所以被夾成 1——那就是「插入空格」。

這也讓測試多了一個必要的斷言

因為這個失敗是不對稱的:

選擇 寫入的值 2.16.52 2.16.52.2
插入空格 1 ✅ 正確 ✅ 正確
插入製表符 0 → 被夾成 1 ❌ 變成空格 ✅ 正確

「插入空格」的方向在兩版都正常,因為 1 本來就在 clamp 範圍內。

所以測試除了斷言壞掉的方向,也斷言正常的方向。少了後者,一個「不管你選什麼都寫 1」的版本會完美通過前者。

一個只證明舊版會壞的測試,沒有告訴你新版是修好了還是只是壞得不一樣。


四個案例

app issue 症狀 為什麼值得示範 測試實作
Notepad++ #16326 Ctrl+Shift+D 插入看不見的 0x04 那個字元不會被畫出來,照著重現步驟做的人什麼都看不到 tests/test_regression_notepadpp_16326.py
WinMerge #3015 clamp 掛錯 option 失敗是不對稱的 tests/test_regression_winmerge_3015.py
DB Browser #3735 複製一格多一個換行 後置條件在應用程式之外:剪貼簿 tests/test_regression_sqlitebrowser_3735.py
Files #18815 Alt+Enter 會叫一聲 整個症狀就只是一個聲音 tests/test_regression_files_18815.py

最後一個特別值得說。

一個症狀只有聲音的 bug

Alt+Enter 會讓 DefWindowProc 進入功能表追蹤,於是送出 WM_MENUCHAR 去找對應的助憶字元;找不到,預設回答是 MNC_IGNORE,而那會播放系統的 Asterisk 音效。修正是在 Files 自己的 window subclass 裡回答 MNC_CLOSE。

兩個版本的畫面像素完全相同。截圖看不出來,錄影也看不出來——除非你錄音。

而一個訊息的回傳值就講完了:

版本 Enter a Esc
4.2.9.0(修好) 0x00010000 0 0
4.2.7.0(有 bug) 0x00000000 0 0

沒有任何 UI 互動要等,沒有時序要抓。而且 WM_MENUCHAR 是 0x0120,小於 WM_USER——所以 USER32 會把它派送進別的處理程序的 window subclass。這正是 Day 22 那條界線:自訂訊息只會被當成兩個整數送過去,根本進不到那個 handler。

修正只攔 '\r',所以 a 和 Esc 也要斷言:一個對所有字元都回 MNC_CLOSE 的版本會吃掉所有真正的助憶字元,卻在只測 Enter 的測試裡看起來是修好的。


一個我沒能重現的案例

同一個 app 的 #18820(在改名框裡 Ctrl+C 複製不到文字)我試了很久,最後放棄 了,而放棄的理由值得寫下來。

修正的內容是:右鍵選單的主要命令列在關閉後沒有清掉 KeyboardAccelerators,於是 Ctrl+C 被 CopyItem 攔走。我照著這條路做,一度以為成功了——剪貼簿上是檔案(CF_HDROP)而不是文字。

然後我去跑修好的那一版。它也一樣。

真相是:在 flyout 真的關閉之前複製,兩版都會拿到檔案;等它關閉之後再複製,兩版都會拿到文字。我做的不是重現,是在跟修正所安裝的那個 Closed handler 賽跑,而修好的版本輸掉這場賽跑的機率一樣高。

一個只在有 bug 的版本上跑過的「重現」,不是重現。 對照組不是嚴謹,是最低限度。

換成 #18815 之後,同一個 app 換來一個更好的案例。


順帶測出來的一課:click() 會安靜地什麼都不做

這一天真正花掉最多時間的不是任何一個 bug,是同一個 API 行為的三種化身。

click() 瞄準元素邊界矩形的中心,而在沒有矩形的時候直接 return:

def click(self):
    left, top, right, bottom = self.bounding_rectangle
    if right > left and bottom > top:      # ← 沒有矩形就什麼都不做
        send_mouse_click((left + right) // 2, (top + bottom) // 2)

它長出來的三個樣子:

  1. Files 的 flyout 按鈕——那些按鈕多數時候回報 (0, 0, 0, 0)。點了沒反應,而唯一的症狀是「焦點一直沒有進到改名框」。
  2. WinMerge 的 Options 樹——捲出可視範圍的節點沒有矩形,於是被跳過。
  3. 上一條在 CI 上的樣子:clicked 23 of 27 pages and none carried control 1038——而且只有 arm64 會壞,因為 runner 的桌面比我的 VM 小,捲出去的節點更多。

沒有一個看起來像「這個點擊根本沒發生」。

兩個修法各自對應到不同的成因:Files 那邊改用 invoke()(UIA pattern,不需要座標);WinMerge 那邊改成先點一個有矩形的節點取得焦點,然後用 {DOWN} 走——樹狀控制項會自己把游標捲進可視範圍,這是視窗大小無關的走法。

而真正的修法是改掉那個 API

上面兩個都是繞路。第四次撞到之後我做了該做的事:讓 click() 在沒有矩形時拋例外。

def click(self, require_rectangle: bool = True):
    left, top, right, bottom = self.bounding_rectangle
    if right > left and bottom > top:
        send_mouse_click((left + right) // 2, (top + bottom) // 2)
        return True
    if require_rectangle:
        raise ActionVerificationError(
            f"{self} has an empty bounding rectangle ..., so there is no point to "
            "click. It may be scrolled out of view, not laid out yet, or hosted "
            "somewhere that publishes no rectangle — try invoke(), or "
            "scroll_into_view() first."
        )
    return False

require_rectangle=False 保留舊行為,而整個函式庫裡只有一個地方用得到它:set_focus() 的實體點擊備援。那裡本來就是「SetFocus 可能已經成功了,這個備援對沒有矩形的元素也幫不上忙」。

這是一個 breaking change,所以它自己也帶出一課:專案的 CHANGELOG 開頭原本寫「1.0 之前 API 只會在 minor 版之間變動」,而這次是 patch 版。與其留一個發版行為並不遵守的承諾,不如把承諾改成真的——現在寫的是「1.0 之前任何版本都可能改 API,每一項都會在 ### Changed 裡點出來並說明怎麼處理;那個條目才是保證,不是版本號」。

一個安靜的 no-op 不會只讓你付一次代價。 它會在每一個不同的地方,用一個不同的、看起來無關的症狀,再向你收一次。


語言無關的把手,不是你以為的那個

要點 WinMerge 的「選項」,我第一版比對的是 '(O)'——括號裡的助憶字元。在中文桌面上這看起來完全與語言無關。

英文 runner 上全部的測試都掛了,錯誤是一個光禿禿的 StopIteration。

因為括號助憶字元是 CJK 的慣例,英文 Windows 寫的是 &Options...。兩種寫法真正共有的是快捷鍵,而它就在項目名稱裡:

zh-TW   ' 選項 (O)...\tCtrl+,'
en-US   '&Options...\tCtrl+,'

同樣的道理,Files 那六顆主要命令按鈕的 automation id 帶著在地化的後綴(ContextMenuPrimaryButton_重新命名),但它們的 AccessKey 是 Alt, M,來自命令定義而不是翻譯過的文字。

而 WinMerge 的 Options 頁面,我完全不去比對節點名字——那 27 個節點全是在地化的。改成一頁一頁走,直到某一頁擁有 control id 1038 為止。資源 ID 不會被翻譯。

「與語言無關」是一個要驗證的性質,不是一個看起來像就算數的性質。


第五章回顧

天 主題 核心教訓
21 四個標的 為測函式庫而寫的 app 只能證明它跟自己一致
22 Scintilla 系統訊息會被 marshal,自訂訊息不會
23 WinMerge UIA 看不到的東西,像素是唯一證據
24 Qt5 vs Qt6 同一個版本可以是兩個不同的程式
25 Grid 與虛擬化 幾何 ≠ 語意;找不到可能是真的不存在
26 空答案與路徑 呼叫成功 ≠ 結果可用;多步驟要帶脈絡
27 抓別人的 bug 版本配對要從修正挑;沒有對照組的重現不算重現

小結

  • 挑版本配對要看修正 commit,不是 issue 的回報日期
  • 不對稱的失敗要兩個方向都斷言,否則測不出「壞得不一樣」
  • 症狀是聲音、是不可見字元的 bug,反而最適合自動化——人眼本來就看不到
  • 只在壞版本上跑過的重現不是重現;#18820 就是這樣被我放棄的
  • click() 在元素沒有矩形時安靜地什麼都不做,四個症狀一個成因——最後的修法是改掉那個 API,不是繞過它
  • 「與語言無關」要驗證:括號助憶字元是 CJK 慣例,快捷鍵和資源 ID 才是

明天進入最後一章,也是這個系列真正的目的地:把前面 27 天的東西組成一個可以照抄的 GitHub Actions workflow。


上一篇
Day 26:空的答案,與一條要走三步的路
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言