iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 27 篇

Day 27 AI 寫 iOS:使用者的手機不是你的手機,而那支 UI 測試對著空畫面也會過

  • 分享至 

  • xImage
  •  

昨天把併發的帳算清楚,後端到此定案。今天換到我最熟的那一端:iOS。AI 在這裡幫得上多少忙?它寫出來的東西,又要怎麼驗?

先講結論:型別交給契約產生,AI 只寫畫面;兩端一致,交給逐欄比對。真正的坑都長在「使用者的手機跟我的不一樣」的地方 —— 換成德文語系,AI 寫的金額變成 NT$9.999.99;字級放到最大,座位圖的字還是 9pt;那支 AI 寫的 UI 測試,對著一個完全空白的票券頁照樣通過。這些在我的電腦上全是綠的。


AI 寫畫面,契約管型別

中間有一段沒寫進文章:web 版五個畫面(登入、活動列表、10×10 選位、倒數確認、票券)接上 API,上了 Cloudflare Pages。今天換第二個前端來吃同一份 API。

iOS 的三個畫面是寫作 session 的 AI 寫的(讀過規格的那一個):登入(refresh token 存 Keychain、遇到 401 自動 refresh 一次)、活動列表(唯讀)、我的票券(QR、離線時讀最後一次的快取)。它不用手寫任何一個型別、任何一個 HTTP 呼叫 —— 那些全是 swift-openapi-generator 從 openapi.yaml 產生的。契約改了,它的畫面 build 就紅。

day27-one-contract

但「同一份契約」在兩邊的意思不一樣,這要老實講。iOS 是真的從契約產出來的 —— Day 23 那個半形逗號,就是它紅出來的。web 是 vanilla JS,沒有從契約產出型別,只剩 API 側那支契約測試:它保證伺服器送出去的符合契約,web 拿到之後怎麼讀,目前沒有人驗。

所以「型別從契約產」這句話,在這個專案裡只有一半是真的。

用 XCUITest 在模擬器上接線上的 Worker 走一遍:登入 → 活動 → 票券,連跑兩次都過。

day27-ios-tickets

刻意不做的事,照非目標清單:不上架(沒有付費開發者帳號,只跑模擬器)、不做推播、不做離線編輯 —— 離線送出的報名,在你連上線的那一刻可能已經沒有位子了。

語系:在我的電腦上是綠的

契約說金額一律是分(*_cents),分變成元的那一步,web 和 iOS 各做一次。iOS 這邊,AI 的第一版把千分位交給 NumberFormatter:

let f = NumberFormatter(); f.numberStyle = .decimal      // ← 跟著裝置語系走
let y = f.string(from: NSNumber(value: yuan)) ?? String(yuan)

整數運算、沒有浮點數,金額檢查是綠的。我拿 web 與 iOS 共用的 14 組顯示向量,在同一台電腦上換語系跑兩次:

locale=zh_TW  14/14 相同
locale=de_DE   9/14 相同
  ✗ 999999: got NT$9.999.99  want NT$9,999.99

同一張 9,999.99 元的票,在德文語系的手機上會顯示成 NT$9.999.99 —— 而那份共用測試在我的電腦上是全綠的。當時是重讀程式才發現的,不是測試抓到的;修法是自己拆千分位、固定用逗號。

那是 AI 的通病,還是剛好那一次?我開了無菌室,兩組 prompt 各跑三次:A 把規則寫完整,B 只給一句「轉成像 NT$4,444.20 這樣的字串」。六次都沒用 NumberFormatter,回覆裡還主動說明「不跟手機的地區設定走」。B 三次都自己決定「一律顯示兩位小數」(NT$1,000.00)—— 跟 Day 25 一樣,規則沒寫的地方,它先替你選一個答案。

所以這個坑不是每次都踩,但踩的那一次,我的測試是綠的。 開發機的語系下測試抓不到,這一條就交給靜態檢查:check-money.sh 加一條,iOS 出現 NumberFormatter、.formatted(.number) 這類跟語系走的格式化一律紅燈。把 AI 那一版放回去:

Tickets/Money.swift:9:        let f = NumberFormatter(); f.numberStyle = .decimal
❌ iOS 金額用了跟裝置語系走的格式化 —— 千分位固定逗號,在 Tickets/Money.swift 自己拆

下一次不管是誰、哪個模型把它寫回來,在任何一台電腦上都會紅。

五種環境,交給機器去看

語系只是其中一種「使用者的手機跟我的不一樣」。iOS 上還有字級、深色模式、螢幕大小 —— 我平常開發時,一樣也不會去切。

Xcode 15 起,XCUITest 有一個 performAccessibilityAudit():對當下的畫面跑一次無障礙稽核,回報對比不足、字級不會放大、文字被截斷這類問題,結果是一份清單。我讓 UI 測試走過五個畫面(登入、活動、座位、我的票券、票券明細),每個畫面跑一次稽核、截一張圖,在五種環境各跑一輪(後端用本機的 wrangler dev 和示範資料,不碰線上)。五種環境各報了 106–146 筆,大宗是座位圖。

一百個座位格的字寫死成 .font(.system(size: 9)) —— 字級放到最大,標題和價格都放大了好幾倍,座位編號還是 9pt:

day27-seats-xxxl-before

但這份清單不能照單全收。登入按鈕的「對比不足」,是欄位還沒填時按鈕停用的灰字 —— 停用的控制項本來就不在對比要求內;還有一批問題沒指到任何元件,找不到是哪裡。反過來,有一個真問題它完全沒報:座位狀態只靠顏色分辨,VoiceOver 念到你自己的座位,只會念「C3」。

把稽核報告交給 AI

接著照 Day 25 的流程:讓它改、讓它問。我在 repo 外開了無菌室,只給畫面相關的 Swift 檔和這份稽核報告:

請直接修改 src/ 裡的檔案,把你判斷是真問題的修掉。稽核也會誤報。你覺得不是問題、或需要我決定的,不要改,列在回覆最後並說明理由。

8 輪、$0.45、兩分半,改了兩個檔:座位格換成會跟著放大的字級和格子大小,放不下就橫向捲動;有色底的格子一律用黑字;票券明細的長標題改成小標題,完整名稱放進內文。

環境 修之前 其中座位畫面 AI 修一輪後
iPhone 16 Pro・預設 131 110 24
iPhone 16 Pro・最大無障礙字級 121 108 12
iPhone 16 Pro・深色 134 114 20
iPhone SE・預設 146 125 23
iPhone SE・最大無障礙字級 106 93 10

day27-seats-xxxl-after

更值得講的是它沒改的。停用按鈕、系統藍的「登出」、沒指到元件的那一批,它判成誤報或要我決定,每一條寫了理由 —— 跟我自己讀清單的判斷幾乎一樣。它還做了兩件稽核沒要求的事:自己幫座位加上給 VoiceOver 的狀態(「C3 我的」)和一排圖例;並指出「離線資料」的橘色小字對比只有約 2:1 —— 稽核碰不到,因為稽核時 App 不在離線狀態。

我回答它的問題後,它再改了一輪(圖例保留,離線和錯誤訊息改成對比夠的顏色 —— 稽核時不會出現,所以數字沒變);兩輪它都照實說沒辦法編譯,也沒重跑稽核。

但重跑稽核,還是抓到兩件事。

第一,它修的時候引進了新問題。 它自己加的那排圖例,四個標籤被報對比不足或差一點過 ——「空位」那格的淺灰色塊,在白底上幾乎看不見:

day27-seats-legend

第二,它的修改撞上我們自己的檢查。 為了讓格子跟著字級放大,它寫了 @ScaledMetric private var cellWidth: CGFloat = 30 —— 而 check-money.sh 把 Swift 的 CGFloat 當成金額浮點,紅燈。CGFloat 是排版尺寸,金額不會用它;錯的是我們的檢查,抓太寬。拿掉 CGFloat(Double、Float、Decimal 照擋),自我測試補一個「不准誤報」的探針,這一筆記進帳本。

還有一件要照實講:第一次修的無菌室,我開在 repo 的暫時 worktree 底下,Claude Code 往上讀到了專案的 CLAUDE.md —— 那不算無菌室。上面的數字全部來自 repo 外重跑的乾淨版;被汙染的那次放在紀錄的附錄,結論方向一樣。

UI 測試:空白的畫面也會過

前面那張票券截圖,來自一支 AI 寫的 XCUITest:登入 → 活動 → 票券。我在本機又連跑了 10 次,10 次都過。

照這個系列的習慣,我故意弄壞一個地方:把票券頁的載入拿掉,讓它永遠不去打 GET /orders。畫面是全空的 —— 測試照樣通過。原因在最後幾行:

sleep(3)
snap(app, "3-tickets")
XCTAssertFalse(app.staticTexts["離線資料"].exists)   // ← 空畫面也沒有「離線資料」

day27-empty-passed

它只檢查「沒有出現離線資料」,沒有檢查「票券有出現」。改成等第一列真的出現,拿掉固定的 sleep:

XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "票券畫面是空的 —— GET /orders 沒回來")

弄壞的版本紅在這一行;正常的版本連跑 10 次都過,每次還從 18 秒降到 14 秒。一支從沒紅過的測試,你不知道它是在保護你,還是什麼都沒在看。

伺服器和 iOS,逐欄比對

「兩端讀到的資料一樣」聽起來像是得靠人眼判定的事,而人眼判定正是這個系列一直在打的東西。

所以裁判換成這樣:同一個帳號、資料沒有變動時,對同一個 GET 拿到的 JSON 必須逐欄相同。 iOS 那一端不是抓網路封包,是讓產生出來的 client 把回應解碼、再編回 JSON —— 這樣比的才是「iOS 讀進去的東西」,不是「伺服器送出來的東西」。

BASE_URL=https://… ACCESS_TOKEN=… bash scripts/compare-clients.sh
✅ GET /events:2 筆,curl 與 iOS 逐欄相同
✅ GET /orders:1 筆,curl 與 iOS 逐欄相同

day27-compare

第一次跑其實紅了:promo_code: null 在 iOS 那一邊不見了。原因是 Swift 的 JSONEncoder 遇到 nil 就直接省略那個 key —— 解碼出來的值還是 nil,是再編碼的時候才消失的。所以比對時「null」和「沒有這個 key」視為相同,除此之外不做任何正規化。這一條規則寫在腳本的註解裡,因為一支會替你「正規化」的比對腳本,也可以把真的差異正規化掉。

web 沒有另外比,理由也寫在腳本裡:web/src/api.js 直接用 res.json(),沒有任何轉換,它拿到的就是 curl 拿到的。這是論證,不是實測 —— 我照實這樣標。web 畫面本身的實測留到 Day 29。

AI 跑 iOS:failed: 0 不代表全過

AI 要自己 build、跑測試,在 iOS 這邊靠的是 Xcode 內建的 MCP。建置失敗時,它會照實回 isError: true;但正常那一次藏著另一個形狀:passed 0 / failed 0 / skipped 1 / total 1 —— 唯一的那支 UI 測試在 Xcode 裡拿不到示範帳號,自己跳過了。

failed: 0 在「測試全過」和「測試根本沒跑」兩種情況下長得一模一樣。讓 AI 透過 Xcode MCP 判斷綠燈,這條要寫進它的指示裡:看 passed == total − disabled,不要只看 failed == 0。 disabled 是測試清單裡標成停用的數量(這次是 0);跑起來才自己跳過的(skipped)不能扣掉,所以這一次是 0 ≠ 1,紅。

最後一道門:CI

功能到此做完。剩下要蓋的是門:讓這一路擋下錯誤的東西永遠留著 —— 不是靠 AI 下次還會做對,也不是靠我記得。判準跟 Day 17 那張表一樣,有客觀對錯、誤報率低的才進門:

道 檢查 進 CI 門?
0 靜態檢查的自我測試 ✅ 34 個「必須抓到」、17 個「不准誤報」的探針
1 五條硬規則的靜態檢查(金額、時間、簽章、併發、價格快照) ✅ 掃 API、web、iOS
2 schema 裁判的自我測試 + 測試(含契約、JWT、金額 fuzz) ✅ 245 個
3 併發,重跑 5 次 ✅ 單次通過不算
— 覆蓋率 ❌ 它量的是「執行到」,不是判別力
— iOS 的 build 與測試 ❌ CI 是 Linux runner,iOS 只在本機跑

第 0 道是這一路最貴的一課:檢查的第一版用了 rg,而 rg 在 sh 底下不存在 —— 三支檢查全部因為工具不存在而通過。所以 CI 的第一步,是每一支檢查先證明自己會紅、也不會亂紅。

截至 10/11,CI 跑了 45 次,只紅過一次,而那一次是逾時造成的誤報:三方競態那條測試在我的筆電上 2.3 秒跑完,GitHub 的 runner 跑了 5,010 毫秒,超過 vitest 預設的 5 秒逾時。程式是對的,門錯了。AI 那些被擋下的第一版,則從來沒進過 CI —— 它們在本機就被同一批檢查擋下了。CI 門守的,是哪天有人(或哪個模型)沒在本機跑就直接 push 的那一天。


總結

iOS 這一端,AI 幫得上的忙很具體:型別從契約產生,它只寫畫面;稽核報告丟給它,一輪修掉八成,還分得出誤報、補上稽核看不到的問題。

但今天反覆出現的是同一件事:在我的電腦上是綠的。 AI 寫的金額在德文語系下錯了,共用測試在開發機上全綠;座位圖在最大字級下看不清,我平常的設定看不出來;UI 測試對著空畫面也會過;CI 唯一一次紅燈,在我的筆電上也跑不出來。測試跟著你的環境跑,你的環境不是使用者的環境。

所以裁判要換到使用者的環境去跑:換語系、放大字級、切深色、弄壞一個地方看測試會不會紅。AI 修完,用同一套裁判再跑一次 —— 它修得快,也會順手帶進新的問題。(這幾種環境下的畫面,下一步可以用快照測試存成基準圖,讓版面一變就紅;這個專案還沒做。)

這一篇留下的心法:

在你的電腦上全綠,不代表在使用者的手機上也對 —— 換語系、放大字級、切深色,讓機器去看你看不到的地方。AI 修得快、分得出誤報,但也會順手帶進新的問題;修完,用同一套裁判再跑一次。

明天:Android。同一份契約、同一組金額向量、同一套「換語系、放大字級、切深色」,換一個平台,AI 會不會踩同一個坑。


參考資料


上一篇
Day 26 時間的那一刻:AI 寫對了判斷,卻留下測不到的時鐘,和一條我沒寫成測試的規則
下一篇
Day 28 AI 從契約寫出 Android:編不起來的是產生器,走不完的是小螢幕,照樣綠的是空清單
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言