你一定用過這種 App:語言切成中文,大部分都翻好了,偏偏幾個按鈕、幾個欄位名稱還是英文。
你大概也沒多想,就這樣用下去了。
我做的開源小工具 usage,日文和韓文介面從 5 月 24 日到 8 月 6 日就有這種情形。用量表格的欄位名稱、排序選項、「載入中」「沒有資料」,日文和韓文各 63 句,印出來都是英文。日文介面全部 489 句,其中 63 句有這個問題。這段時間發了 139 個版本,每一版都帶著這 63 句英文。專門檢查翻譯的測試,5 月 29 日加進來之後,一直是綠的。
usage 在終端機和 Mac 選單列上,顯示 Claude Code、Codex 等 AI 工具用了多少 token、花了多少錢。介面有五種語言:繁體中文、簡體中文、英文、日文、韓文。
所有介面文字都放在一個翻譯檔 i18n.json 裡。每種語言一個區塊,每一句話有一個名稱(叫「鍵」),後面接這種語言的內容。簡化一下,長這樣:
{
"en": { "loading": "Loading...", "col_time": "Time" },
"ja": { "loading": "", "col_time": "" }
}
程式要印「載入中」的時候,不寫死文字,而是拿 loading 這個鍵去翻譯檔查,查到哪種語言的內容就印哪個。
日文和韓文各有 63 個鍵,從 5 月 24 日起內容一直是空的:鍵在,引號裡什麼都沒有。
查翻譯的函式叫 _t,裡面最關鍵的一行是這樣:
template = table.get(key) or bundle["en"].get(key) or key
table 是使用者那種語言的區塊,bundle 是整份翻譯檔,key 是鍵。意思是:先找使用者那種語言的內容;找不到,或是空的,改找英文;英文也沒有,就直接印鍵的名稱。
Python 的 or 會跳過「空的」東西,空字串也算。所以日文的 loading 是空的,這行就改拿英文的 Loading...。
我把 8 月 6 日修正前的那一版程式拿出來,寫幾行 Python 直接呼叫 _t,問它日文和韓文要印什麼:
ja loading '' -> 'Loading...'
ja col_time '' -> 'Time'
ko loading '' -> 'Loading...'
ko col_time '' -> 'Time'
程式沒有壞,也沒有空白。日文和韓文使用者看到的那 63 句,就是英文。
這行「找不到就改用英文」的程式,跟那 63 個空字串,是 5 月 24 日同一個 commit(存進版本紀錄的一次改動)加進來的。從第一天起,畫面上就是英文。
5 月 29 日,usage 加了一個測試,專門檢查翻譯檔。它開頭的說明是這樣寫的:
_t degrades gracefully for a missing key (English fallback,
then the raw key), so a forgotten translation never crashes — it just
silently ships English. This test makes that omission fail loudly in CI
instead.
這段話的意思是:翻譯缺了,_t 不會當掉,會先改用英文、再改用鍵名。所以忘了翻譯,程式不會壞,只會悄悄印出英文。這個測試要讓這種遺漏,在 CI(每次推上 GitHub 就自動跑的一輪檢查)裡大聲失敗。
它要抓的,正是日文介面上那 63 句英文。
可是它檢查的是這個:
reference = set(bundle["en"])
...
if set(keys) != reference
把每種語言的鍵收成一份名單,跟英文的名單比。少了哪個鍵、多了哪個鍵,就失敗。
它只比名單,不看內容。那 63 個鍵都在名單上,只是內容是空的。名單一樣,測試通過。
這個測試是 5 月 29 日加的,那時 63 個空字串已經在翻譯檔裡五天了。它第一次跑,就是綠的。

對查翻譯的程式來說,「少一個鍵」和「鍵在但內容是空的」,結果一樣:都改印英文。
對測試來說,這兩件事不一樣:少一個鍵,紅燈;內容是空的,綠燈。
測試的說明從症狀寫起:「悄悄印出英文」。檢查卻只挑了一種會造成這個症狀的原因。另一種原因造成一模一樣的症狀,它看不到。
從 5 月 24 日到 8 月 6 日,GitHub 上也沒有人回報過日文或韓文介面的問題。後來會修,也不是因為有人回報。
8 月 6 日凌晨 0 點 37 分,我問 AI「接下來要修復的有哪些」。0 點 43 分,我下了一句「補 ja / ko 翻譯」(ja 是日文,ko 是韓文)。3 分鐘後,63 句日文、63 句韓文補齊,測試檔多了兩條檢查。第一條是這個:
def test_no_translation_is_blank() -> None:
...
blanks = {
lang: sorted(key for key, value in strings.items() if not value.strip())
...
}
assert not blanks, f"i18n blank translations: {blanks}"
每一種語言、每一個鍵,內容去掉前後空白之後,不能是空的。
第二條檢查佔位符:英文的 Avg: {cost} 裡,{cost} 是留給程式填金額的空格。其他語言要有一模一樣的空格,漏了,金額就印不出來;改了名字,這一句會退回英文。
我把修正後的測試,拿去跑修正前的翻譯檔:
FAILED tests/test_i18n_key_parity.py::test_no_translation_is_blank
FAILED tests/test_i18n_key_parity.py::test_placeholders_match_english
2 failed, 2 passed
測試檔原本的兩條(五種語言都在、鍵名單一樣)照樣通過,新的兩條都紅了。佔位符那條也紅,是因為空字串裡沒有那個空格,跟英文對不上。這一次,那 63 句英文,測試抓到了。

回頭看,這個測試的說明寫得很清楚:要抓的是「悄悄印出英文」。問題在說明和檢查之間:會造成這個症狀的原因不只一種,檢查只寫了一種。
驗收一個測試,除了看它綠不綠,還可以這樣做:讀它的說明,問「會造成這個症狀的,有哪幾種原因?」然後每一種都親手做出來,餵給它跑一次,看它會不會紅。
這次就是兩種:少一個鍵、內容是空的。第二種做出來,舊測試是綠的。
「找不到就改用預設值」的程式特別要這樣做。它的好處是程式不會壞,壞處也在這裡:出錯的時候,程式照樣跑,也不會有任何錯誤訊息。
現在就可以做一件事:在你的專案裡,找一個「找不到就改用預設值」的地方。翻譯、設定檔、使用者頭像、環境變數,都常有這種寫法。
把其中一個值改成空的,不是刪掉,是留著名稱、內容清空。跑一次程式,再跑一次測試。
如果這個值本來不該是空的,畫面卻出現預設值、測試還是綠的,你就找到一個跟 usage 一樣的缺口了。
工作單(交給 AI 的任務說明)裡有一格「做完怎麼算對」,Day 4、Day 8 到 Day 15 各在這格加了幾行。今天再加一行:
做完怎麼算對:
- (Day 4、Day 8 到 Day 15 加的幾行)
- 會改用預設值的功能:測試要各餵一次「少一個」和「內容是空的」,兩種都要紅;測試說明寫要抓什麼症狀,就親手做出那個症狀跑一次
今天的測試,說明寫對了,檢查只做了一半。明天看另一種檢查:型別檢查。8 月 31 日,AI 回報 mypy(型別檢查工具)通過;推上 GitHub 之後,Windows 那台的 mypy 紅了。同一份程式,在 Mac 上一個錯都沒有。