開發日誌 · 2026-07-03

2026-07-03#visual-regression#openapi-contract#ios-testing

以下程式碼皆為說明用的示意片段(通用命名),重點在技術概念而非特定實作。

從「diff 量級」反推 flaky 的根因

Appium/WDIO 的視覺回歸裡,有個透過 navigation push 進入的表單畫面,基準圖一直出現 ~3% 差異,但沒改任何 UI。與其重截圖碰運氣,diff 的量級本身就是線索:

  • caret 閃爍大約 0.05–0.1%,anti-aliasing 抖動更小 → 3% 太大,不是雜訊。
  • 鍵盤蓋掉半個畫面會是 40%+ → 3% 又太小,不是整塊遮擋。
  • 3% 這種量級 = 一個區域性的位移;畫面大部分是白底(白對白仍相符),只有欄位與文字在動,才會落在 3–7%。

根因是 navigation 的橫向滑入動畫還沒定格就截圖——元素一 waitForDisplayed 回來就截,抓到動畫中途那一影格:

// 舊:元素「存在」不代表畫面「靜止」
await screen.waitForDisplayed()
await compareToBaseline('form')   // 抓到滑動中的影格 → 整畫面水平位移

修法是在截圖前加一道穩定門檻:連拍兩張、像素一致才視為定格。

// 示意:一個 stability gate
async function waitUntilStable({ interval = 150, tries = 10 } = {}) {
  let prev = await takeScreenshot()
  for (let i = 0; i < tries; i++) {
    await browser.pause(interval)
    const next = await takeScreenshot()
    if (pixelDiff(prev, next) === 0) return   // 兩張一致 → 已定格
    prev = next
  }
}

await screen.waitForDisplayed()
await waitUntilStable()
await compareToBaseline('form')

埋著的次要風險(multi-state-capture 陷阱):同一個 visual tag 若對應兩個結構不同的到達點——例如「初次進入的空白表單」與「返回時的表單」——會共用同一 baseline,由先跑到的測試決定基準。今天兩者剛好像素一致所以沒事,但哪天返回時某個欄位被預填,那塊就會冒出區域性 diff、而且看測試執行順序而定。通則:一個 visual tag 只該對應一個確定狀態

用 OpenAPI spec 當單一事實來源驗合約

合約測試的核心思路是讓 OpenAPI spec 成為唯一事實來源,而不是各處各自維護一份預期格式。把 spec 從 yaml 轉 json、測試載入解析;validator 的關鍵是累積所有落差(一次看完,而非遇錯即停)並正確處理 nullable:

// 示意:遞迴比對 JSON 與 schema,回傳所有 issue
func validate(_ value: JSONValue, against schema: Schema, at path: String) -> [Issue] {
    if value == .null {
        return schema.nullable ? [] : [Issue(path, "unexpected null")]   // nullable 才放行
    }
    switch schema {
    case .object(let props): return props.flatMap { validate(value[$0.key], against: $0.value, at: "\(path).\($0.key)") }
    case .array(let items):  return value.array.enumerated().flatMap { validate($1, against: items, at: "\(path)[\($0)]") }
    case .ref(let name):     return validate(value, against: resolve(name), at: path)   // $ref 解析
    default:                 return checkScalar(value, schema, path)
    }
}

要把「model 欄位」對到「spec 欄位」時,decode 後用 Mirror 取欄位名,就能算 required 覆蓋與缺漏:

let decoded = try decoder.decode(ModelType.self, from: sampleJSON)
let modelFields = Set(Mirror(reflecting: decoded).children.compactMap { $0.label })
let missing = requiredFields.subtracting(modelFields)

驗證分層、而且先 report-only 再強制,才不會一上線紅一片:先驗 fixtures 是否符合 schema、再比對 model 欄位與 spec 的 required、接著算欄位覆蓋率、最後輸出覆蓋率報告。

CI exit 65:根因在啟動時序,不是編譯

單元測試在 CI 掛 exit 65,容易被當成編譯錯誤,但根因是執行時序:測試過程中 app 會 relaunch,加上部分 UIKit 斷言跑在非主緒

// UIKit 存取必須在 main thread
await MainActor.run {
    XCTAssertEqual(label.text, expected)
}

再加一道 relaunch-safe 的啟動 gate(重啟時別重跑會 crash 的初始化)。通則:iOS 單元測試看到 65,先查啟動流程與主緒假設,再懷疑編譯。

← 回部落格