iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

AI 寫的測試,誰來測?系列 第 3

【Day3】四個缺陷的驗屍報告

  • 分享至 

  • xImage
  •  

TL;DR

  • 四個缺陷是這一年間由 AI 輔助的人工審查逐行比對找出來的,不是任何測試方法自動抓到的
  • 兩個是輸入沒防好,一分鐘就能修;兩個藏在精算公式的跨期假設裡,看再久也看不出來
  • 同一個系統裡的缺陷不是同一種東西,用同一種力氣去防它們就是浪費

https://ithelp.ithome.com.tw/upload/images/20260903/20103826TnbUW9ynee.jpg


前言

先把歸因釘死:這四個缺陷,是我用 AI 輔助的人工審查、逐行比對找出來的。 不是差分測試抓到的,不是變異測試抓到的——那些東西我還沒開始建。

之所以先講,是因為接下來的方法論很容易被寫成「我用了 X,然後 X 幫我找到四個 bug」。那是倒果為因。這些方法的職責是把已知行為固定住、建立回歸防護,不是事後邀功。

先有屍體,才有驗屍。

缺陷 D:打錯字,系統假裝沒事

這個試算工具的所有輸入都是文字框。文字框裡可以打任何東西。

第 215 行有一個負責把文字轉成數字的函式:

const parseNumber = (str) => parseFloat(String(str).replace(/,/g, '')) || 0;

它會先把逗號拿掉——這是體貼的設計,使用者打 1,000,000 也能用。但後面那個 || 0 是問題所在:轉不出來就給 0

所以當我在「每月投入金額」欄位打了 100萬——很自然的寫法——parseFloat 回傳 NaNNaN || 0 得到 0。畫面不會提示,不會變紅,不會擋你。它就當你這個月存了 0 元,然後繼續往下算。

而年齡欄位更麻煩一點。大筆支出的年齡走的是 parseInt(第 327 行),轉不出來的時候會產生 NaN。而 JavaScript 有一條規則值得記住:

NaN 跟任何數字比大小,答案永遠是 false。

NaN >= 65 是 false,NaN < 65 也是 false,兩個都 false。

第 329 行的判斷式是 if (age >= retirementAge),所以那筆支出被整筆丟掉。不是算錯,是根本沒參與計算。因此,一個打錯字的使用者,會拿到一份完全乾淨、完全沒有警告,而且完全不對的試算結果。

缺陷 B:輸入手誤,變成好消息

第 293 行在算「退休後要活幾年」,就是一個減法,沒有任何檢查:

const retirementYears = lifeExpectancy - retirementAge;

如果使用者不小心把「預期壽命」填得比「退休年齡」還小——比如 65 歲退休、壽命填成 60——這個數字會變成 -5

而第 306 行的迴圈長這樣:

for (let i = 1; i <= retirementYears; i++) {

1 <= -5 是 false,所以這個迴圈一次都不會跑。於是 targetFundForExpenses 就停在初始值 0,需要的目標金額變成了 0 元。最後系統拿你的累積資產去跟 0 比,你當然贏,畫面直接跳出:「恭喜您準備金充足」。

使用者輸入了一組在現實中不可能成立的年齡,系統沒有攔他,反而恭喜他。

缺陷 C:活不到的那筆錢

這個工具可以填「退休後的大筆特殊支出」(醫療準備、長照、給小孩的一筆錢),填入年齡和金額後,它會把這筆錢算進你的目標金額。

但問題在於,它只檢查了下界:

if (age >= retirementAge) {
    // 計入目標金額
}

它問了「這筆支出是不是在退休之後?」,但沒問「這筆支出是不是在你還活著的時候?」

而畫圖那邊的迴圈(第 349 行)只跑到 retirementYears,也就是最多到預期壽命。第 367 行用 === age 去比對哪一年該扣這筆錢——迴圈根本走不到 95 歲,所以永遠配不上。

所以我們可以填一筆「95 歲、200 萬」的支出,而你的預期壽命是 85 歲。系統會把這 200 萬算進目標金額,經過通膨換算,目標金額會變大 306 萬。畫面會告訴你缺口變大了,你得多存錢。

但接著看那張資產消耗折線圖——圖表在 85 歲就結束了,那筆 200 萬在圖上沒有留下任何痕跡。上面的數字說有,下面的圖說沒有,而兩邊都是同一支程式算的。

缺陷 A 與 A':少算一個 (1+r),以及那塊遮羞布

前面三個都還算好懂。這一個不是。

昨天講過,這個工具同時走兩條路:一條算資產累積,一條算退休目標金額。缺陷 A 就是這兩條路對不齊。

算目標金額時,會經過這一段:

for (let i = 1; i <= retirementYears; i++) {
    // ...
    const pvOfNetExpense = netExpenseForYear / Math.pow(1 + postRetirementReturn, i);
    targetFundForExpenses += pvOfNetExpense;
}

i 從 1 開始,第一年的支出就被折現一整期。這是「每年年底才花錢」的假設。

而在模擬資產怎麼被花掉時:

remainingFund = (remainingFund - (netAnnualExpense + largeExpenseForYear)) * (1 + postRetirementReturn);

先扣錢,再滾利息。這是「每年年初就把生活費拿走」的假設。

同一份程式,同一個變數 postRetirementReturn,兩邊對「錢什麼時候離開帳戶」的答案不一樣。年初拿走跟年底拿走,差一年的利息。所以目標金額被系統性地低估了,而且低估的幅度不是隨便一個數,是正好一個 (1 + r)(明天會把推導完整寫出來)。低估目標金額本身已經夠糟——它讓你以為需要準備的錢比實際少。但真正把這件事變成災難的是下一行。

在畫圖之前,把負數的餘額夾回 0:

assetData.push(Math.max(0, remainingFund));

於是當一個使用者剛好存到系統算出來的目標金額,畫面告訴他「缺口 0 元」,圖表也畫出一條漂亮地貼著地面滑行的曲線——而他 85 歲那年的真實帳戶餘額是 −862,034

https://ithelp.ithome.com.tw/upload/images/20260903/20103826zJif1Dt46o.png

那條貼地的曲線不是「剛好花完」,是「已經透支了 86 萬,但我不打算讓你看到」。我把這兩個放在一起編號成 A 和 A',因為單獨看缺陷 A 只是一個少算的公式,加上第 374 行才變成一個會主動製造錯誤安全感的系統。

還有一個不是 bug 的東西

當把年報酬率除以 12,當成月報酬率:

const monthlyPreReturn = preRetirementReturn / 12;

嚴格來說這不是缺陷,是模型假設的簡化——年化 8% 對應的有效月利率應該是 (1 + 0.08)^(1/12) - 1,直接除以 12 會偏高一點點,每個月偏一點點,累積二十幾年就不只一點點。我實測下來,這個簡化讓累積資產高估了 5.0%,金額約 151 萬

而且它不只出現在這一支程式裡。我把去年做的幾個工具全部 grep 過一遍,年報酬率 / 12 在三個獨立生成的檔案裡總共出現七次——每一次我要 AI 做月複利,它就給我這個寫法。這不是打錯字,是穩定行為。 完整比對留到 Day 19 的金融系統錯法目錄。

為什麼要在驗屍報告裡提它?因為它示範了一件事:不是所有的錯都是 bug。 有些錯是「你選了一個跟現實不完全相符的模型」,程式碼本身沒有語法問題,寫的人也不覺得自己寫錯了。這種錯特別難抓,因為它不會爆炸,只會慢慢偏。

它也是 Day 6 要講的「差分測試的天花板」的活體樣本。

對照組:另一具屍體是活的

Day 1 提過,我當年在文章裡示範的那個版本還在,而它一個缺陷都沒有。驗屍報告該附上對照組,所以我把兩份程式並排讀了一次。

缺陷 這一份 文章版 文章版為什麼躲掉
B 壽命倒掛 293 行純減法 沒有 有一行明確驗證,壽命 ≤ 退休年齡就 alert() 然後 return
D 輸入靜默失敗 215 / 327 行 大致擋住 收集支出時明確 !isNaN() 過濾,主要欄位也一併檢查
A 跨路徑假設不一致 306 / 321 vs 372 結構上不可能 它根本沒有目標金額折現路徑
A' 負值遮蔽 374 行 不成立 夾 0 只用在圖表資料,缺口數字用真實餘額,負值會顯示紅色「資產耗盡」
C 幽靈支出 329 行只檢查下界 沒有 沒有目標金額路徑,超壽命的支出上下都不算,一致
/12 297 行 沒有 純年複利

最值得看的是 A 那一列。文章版的原始碼裡有一行註解,是這樣寫的:

// 新的缺口計算方式:直接使用預計壽命結束時的最終資產

它不算目標金額,它就一路模擬到壽命終點,看帳戶裡還剩多少。只有一條路,所以沒有兩條路可以對不齊。

這件事的意思不是「文章版比較好」。它功能少得多,能回答的問題也少得多。真正的意思是:缺陷 A 不是「AI 不會寫年金公式」。 同一個模型,另一次生成,用一個更簡單的架構完全繞過了整個問題。它會寫。它只是在功能變多的時候,選了一條會分岔的路。

缺陷是架構選擇的後果,不是能力問題。而架構選擇沒有人在把關——當時的我只說了「幫我加功能」。

四個缺陷,四種力氣

把它們排在一起看,會發現一件事:它們根本不是同一種東西。

層級 缺陷 該用什麼擋 修起來要多久
一:介面防呆 D(輸入靜默失敗)、B(壽命倒掛) 欄位驗證、一個 if 一分鐘
二:規格與邊界 C(幽靈支出) PRD 寫清楚上下界、需求追溯 規格層的事
三:核心精算 A(跨期假設不一致)、A'(負值遮蔽)、/12 假設 獨立參照實作、差分、蛻變 得先有第二個實作才看得見

D 和 B 用一行防呆就擋住了。為它們建一整套變異測試框架是拿大砲打蒼蠅。而 A 和 A' 你再怎麼防呆都擋不住——輸入完全合法、程式完全不報錯、每一條路單獨算都對。我們需要的是一個不是它的第二意見。

這張表就是整個系列為什麼要分幕的原因。不分級,你會把力氣花在最容易的地方,然後對最貴的缺陷毫無防備。

後記

初稿裡,缺陷 B 我原本寫的是「壽命填反了,變成負數,然後被 Math.max 夾回 0」。結果回頭翻原始碼才發現,根本沒有 Math.max,那就是一個減法。真正的機制是負數讓迴圈一次都不執行,targetFundForExpenses 停在初始值。

結果一模一樣,都是目標金額變成 0。但如果我照初稿那個理解去修,我會加一道防止負數的保護——而那道保護什麼都擋不住,因為問題根本不在那裡。症狀相同,不代表機制相同。而補丁是照機制打的。

只帶走一件事
最危險的那兩個缺陷,剛好是最便宜的手段完全碰不到的。


免責聲明:本文提及之退休試算工具與所有數字皆為假設性試算,不構成任何投資建議。該工具目前已知存在計算偏差,修正版製作中,結果僅供參考。


上一篇
【Day2】三招驗證法,為什麼一招都沒中?
系列文
AI 寫的測試,誰來測?3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言