<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>L10n on Tarragon</title><link>https://tarrragon.github.io/blog/tags/l10n/</link><description>Recent content in L10n on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/l10n/index.xml" rel="self" type="application/rss+xml"/><item><title>紅燈在量什麼 — 測試訊號的三層失真：斷言、量測、環境</title><link>https://tarrragon.github.io/blog/work-log/flutter_test_signal_credibility_three_layers/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_test_signal_credibility_three_layers/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>觸發場景&lt;/strong>：Flutter 專案的測試修復戰役尾聲，同一週內連續三次發現「紅燈數字不可信」——不是測試錯、是訊號本身失真：兩個被判 flaky 的測試翻案、worktree 環境的 88 個失敗查出來是環境問題、效能斷言在兩台環境上結論相反
&lt;strong>疑問來源&lt;/strong>：目標「全套件失敗降至 0」看起來是明確的驗收條件，為什麼一路追下去發現這個數字先要能被信任？
&lt;strong>整理目的&lt;/strong>：把「測試訊號可信度」拆成三層（斷言設計 / 量測工具 / 執行環境）、各記下失真機制與修法
&lt;strong>本文邊界&lt;/strong>：素材是該專案 v0.38 的 main log（含對照實驗數據）；三個 case 發生在同一週、彼此互相印證&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="第一層失真斷言在量機器不在量程式">第一層失真：斷言在量機器、不在量程式&lt;/h2>
&lt;p>&lt;code>expect(elapsed, lessThan(Duration(milliseconds: 10)))&lt;/code> 這類&lt;strong>絕對計時門檻&lt;/strong>做 pass-fail 斷言，在這個專案留下了自我矛盾的記錄：&lt;code>ErrorHandler &amp;lt; 500μs&lt;/code> 在 main 環境 FAIL、worktree 環境 PASS；&lt;code>batchUpdate &amp;lt; 10ms&lt;/code> 某次實測 40ms FAIL、後續兩個環境都 PASS。同一份程式碼、同一條斷言、結論隨機器負載擺盪。&lt;/p>
&lt;p>log 的判語直接：&lt;strong>它們量測機器負載而非程式正確性&lt;/strong>。這類斷言的破壞不只是自己不穩——它讓「全套件紅燈」失去診斷力：紅燈亮起時無法分辨是程式缺陷還是環境抖動，於是「降至 0」的目標先天無法驗證。效能要守，用專門的 benchmark 管道（相對比較、多次取樣、獨立於 pass-fail 之外）；單元測試套件裡的計時斷言是把非決定性摻進決定性訊號裡。&lt;/p>
&lt;h2 id="第二層失真量測工具讀錯了結果">第二層失真：量測工具讀錯了結果&lt;/h2>
&lt;p>flaky 判定的翻案暴露了更隱蔽的一層。兩個測試被判定 flaky、進豁免清單——後來在乾淨 baseline 上以 N=5 重新取樣，兩檔各 5/5 綠、全套件逐筆驗證 100% 成功：&lt;strong>非 flaky&lt;/strong>。誤判有兩個成因、缺一不可：取樣只有 3 次（低於專案自訂的 N ≥ 5 門檻、三連勝的機率不足以排除運氣）、且判定當時 baseline 還有 4 項確定性紅燈在干擾——「間歇失敗」跟「被其他紅燈拖累」根本分不開。&lt;/p>
&lt;p>追這個案子時還挖出量測工具自己的 bug：&lt;code>flutter test --reporter compact&lt;/code> 的純文字輸出在高並行大套件下有嚴重的&lt;strong>行覆寫交錯&lt;/strong>，拿檔名或測試描述去搜尋輸出會產生假陰性——執行中一度誤判某測試「全套件中完全未執行」。修法是改用 &lt;code>--file-reporter json:&amp;lt;path&amp;gt;&lt;/code> 的結構化 &lt;code>testDone&lt;/code> 事件逐筆比對。這層的教訓可以一般化：&lt;strong>給人看的輸出格式不能當機器判讀的資料源&lt;/strong>，凡是要在輸出上做字串比對的自動化，都該走結構化 reporter。&lt;/p>
&lt;h2 id="第三層失真環境讓整包結果作廢">第三層失真：環境讓整包結果作廢&lt;/h2>
&lt;p>最大的一筆失真來自環境。worktree（git 的隔離工作目錄）上跑全套件出現 88 個失敗、其中 87 個是 &lt;code>Compilation failed&lt;/code>。第一個歸因假說是「高並行編譯器資源耗盡」——聽起來合理、而且把失敗歸為環境噪音。但另一個競爭假說沒被排除：worktree 是 fresh checkout，&lt;code>lib/l10n/generated/&lt;/code> 是 gitignored 的生成產物、新 checkout 裡不存在，缺了就連鎖編譯失敗。&lt;/p>
&lt;p>兩個假說的含義相反（噪音 vs 系統性不可信），處置是&lt;strong>不在未驗證的假說上直接修&lt;/strong>、先做單一變因的對照實驗：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>環境&lt;/th>
 &lt;th>編譯失敗&lt;/th>
 &lt;th>明確指向 l10n&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>main（有 generated）&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>0&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>worktree（無 generated）&lt;/td>
 &lt;td>86&lt;/td>
 &lt;td>85&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>結果決定性：所有在 worktree 環境跑的全套件結果、在補跑 gen-l10n 之前全部不可信。根治不是「記得先跑 gen-l10n」的 SOP、是把 &lt;code>lib/l10n/generated/&lt;/code> 移出 gitignore 納入版控（對齊該專案 &lt;code>.mocks.dart&lt;/code> 的既有慣例）——fresh checkout 預設就有產物、不依賴任何人記得任何事。納入版控前的驗證方法也值得記：重跑 &lt;code>flutter gen-l10n&lt;/code> 看有沒有 diff，零 diff 才證明納進去的不是 stale 產物；直接讀檔案內容分辨不出新舊。&lt;/p>
&lt;p>這個 case 還有一筆流程教訓：更早的一張 ticket 其實&lt;strong>觀察過&lt;/strong>「worktree 缺 generated l10n 導致連鎖編譯失敗」，但觀察停在那張 ticket 的執行紀錄裡、沒有升格成 SOP 或 error-pattern，於是下一個執行者重蹈覆轍還給出錯誤歸因。log 的原句：「單張 ticket 裡的環境觀察若未升格，下一個執行者不會讀到它。」&lt;/p>
&lt;h2 id="收束訊號先於目標">收束：訊號先於目標&lt;/h2>
&lt;p>三層排在一起，共同的結構是：「全套件失敗數」這個數字要能當目標用，先要三層都乾淨——&lt;strong>斷言只量程式（決定性）、量測工具忠實轉錄（結構化）、環境具備完整前置（可重現）&lt;/strong>。任何一層失真，追「降至 0」就是在追一個會說謊的數字：修好的測試可能本來就沒壞（flaky 誤判）、沒修的失敗可能不是程式的錯（環境缺檔）、紅綠的翻轉可能只是機器忙不忙（計時斷言）。&lt;/p>
&lt;p>這週的三個修正各對一層，最後全套件基準首次成立：4557 個測試、失敗 1、exit code 與失敗數一致——這個「1」才開始值得追。&lt;/p>
&lt;h2 id="相關閱讀">相關閱讀&lt;/h2>
&lt;ul>
&lt;li>假綠的產品側形態：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_viewmodel_mock_implementation_passes_tests/" data-link-title="產品碼自己是 mock — ViewModel 假實作通過了 15 個測試" data-link-desc="ViewModel 用 Future.delayed 加硬編碼資料交付、狀態轉換測試全綠，因為測試耦合的是狀態機、不是業務效果。假實作被當成完成的基礎，直到 63 個測試「寫不出來」才現形——測試的可寫性是實作真實性的探針。">產品碼自己是 mock&lt;/a>——那篇是綠燈說謊、本文是紅燈說謊，合起來是「測試訊號可信度」的兩面&lt;/li>
&lt;li>環境觀察要升格的原則層：&lt;a href="https://tarrragon.github.io/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 檢查規則的作用域要顯式列舉&lt;/a>——「觀察存在」與「觀察被制度化」是兩個獨立的 fact，同構於「規則存在 vs 規則涵蓋」&lt;/li>
&lt;li>兩次門檻的取樣版：&lt;a href="https://tarrragon.github.io/blog/report/two-occurrence-threshold/" data-link-title="2 次門檻：第一次是運氣、第二次是訊號" data-link-desc="同一個問題出現第 2 次時、就該停下來把處理層級升一階 — 從推理升到量測、從手動驗證升到自動化、從同方向嘗試升到換思路。第 1 次失敗的資訊不足、第 2 次提供「重複出現」的證據、值得付出升級成本。本文是 #11 / #15 / #20 / #23 四篇實作的共同抽象。">#42 2 次門檻：第一次是運氣、第二次是訊號&lt;/a>——flaky 判定的 N ≥ 5 是同一個統計直覺在「連續成功」方向的應用：三連勝不足以證明穩定&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>觸發場景</strong>：Flutter 專案的測試修復戰役尾聲，同一週內連續三次發現「紅燈數字不可信」——不是測試錯、是訊號本身失真：兩個被判 flaky 的測試翻案、worktree 環境的 88 個失敗查出來是環境問題、效能斷言在兩台環境上結論相反
<strong>疑問來源</strong>：目標「全套件失敗降至 0」看起來是明確的驗收條件，為什麼一路追下去發現這個數字先要能被信任？
<strong>整理目的</strong>：把「測試訊號可信度」拆成三層（斷言設計 / 量測工具 / 執行環境）、各記下失真機制與修法
<strong>本文邊界</strong>：素材是該專案 v0.38 的 main log（含對照實驗數據）；三個 case 發生在同一週、彼此互相印證</p></blockquote>
<hr>
<h2 id="第一層失真斷言在量機器不在量程式">第一層失真：斷言在量機器、不在量程式</h2>
<p><code>expect(elapsed, lessThan(Duration(milliseconds: 10)))</code> 這類<strong>絕對計時門檻</strong>做 pass-fail 斷言，在這個專案留下了自我矛盾的記錄：<code>ErrorHandler &lt; 500μs</code> 在 main 環境 FAIL、worktree 環境 PASS；<code>batchUpdate &lt; 10ms</code> 某次實測 40ms FAIL、後續兩個環境都 PASS。同一份程式碼、同一條斷言、結論隨機器負載擺盪。</p>
<p>log 的判語直接：<strong>它們量測機器負載而非程式正確性</strong>。這類斷言的破壞不只是自己不穩——它讓「全套件紅燈」失去診斷力：紅燈亮起時無法分辨是程式缺陷還是環境抖動，於是「降至 0」的目標先天無法驗證。效能要守，用專門的 benchmark 管道（相對比較、多次取樣、獨立於 pass-fail 之外）；單元測試套件裡的計時斷言是把非決定性摻進決定性訊號裡。</p>
<h2 id="第二層失真量測工具讀錯了結果">第二層失真：量測工具讀錯了結果</h2>
<p>flaky 判定的翻案暴露了更隱蔽的一層。兩個測試被判定 flaky、進豁免清單——後來在乾淨 baseline 上以 N=5 重新取樣，兩檔各 5/5 綠、全套件逐筆驗證 100% 成功：<strong>非 flaky</strong>。誤判有兩個成因、缺一不可：取樣只有 3 次（低於專案自訂的 N ≥ 5 門檻、三連勝的機率不足以排除運氣）、且判定當時 baseline 還有 4 項確定性紅燈在干擾——「間歇失敗」跟「被其他紅燈拖累」根本分不開。</p>
<p>追這個案子時還挖出量測工具自己的 bug：<code>flutter test --reporter compact</code> 的純文字輸出在高並行大套件下有嚴重的<strong>行覆寫交錯</strong>，拿檔名或測試描述去搜尋輸出會產生假陰性——執行中一度誤判某測試「全套件中完全未執行」。修法是改用 <code>--file-reporter json:&lt;path&gt;</code> 的結構化 <code>testDone</code> 事件逐筆比對。這層的教訓可以一般化：<strong>給人看的輸出格式不能當機器判讀的資料源</strong>，凡是要在輸出上做字串比對的自動化，都該走結構化 reporter。</p>
<h2 id="第三層失真環境讓整包結果作廢">第三層失真：環境讓整包結果作廢</h2>
<p>最大的一筆失真來自環境。worktree（git 的隔離工作目錄）上跑全套件出現 88 個失敗、其中 87 個是 <code>Compilation failed</code>。第一個歸因假說是「高並行編譯器資源耗盡」——聽起來合理、而且把失敗歸為環境噪音。但另一個競爭假說沒被排除：worktree 是 fresh checkout，<code>lib/l10n/generated/</code> 是 gitignored 的生成產物、新 checkout 裡不存在，缺了就連鎖編譯失敗。</p>
<p>兩個假說的含義相反（噪音 vs 系統性不可信），處置是<strong>不在未驗證的假說上直接修</strong>、先做單一變因的對照實驗：</p>
<table>
  <thead>
      <tr>
          <th>環境</th>
          <th>編譯失敗</th>
          <th>明確指向 l10n</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>main（有 generated）</td>
          <td>0</td>
          <td>0</td>
      </tr>
      <tr>
          <td>worktree（無 generated）</td>
          <td>86</td>
          <td>85</td>
      </tr>
  </tbody>
</table>
<p>結果決定性：所有在 worktree 環境跑的全套件結果、在補跑 gen-l10n 之前全部不可信。根治不是「記得先跑 gen-l10n」的 SOP、是把 <code>lib/l10n/generated/</code> 移出 gitignore 納入版控（對齊該專案 <code>.mocks.dart</code> 的既有慣例）——fresh checkout 預設就有產物、不依賴任何人記得任何事。納入版控前的驗證方法也值得記：重跑 <code>flutter gen-l10n</code> 看有沒有 diff，零 diff 才證明納進去的不是 stale 產物；直接讀檔案內容分辨不出新舊。</p>
<p>這個 case 還有一筆流程教訓：更早的一張 ticket 其實<strong>觀察過</strong>「worktree 缺 generated l10n 導致連鎖編譯失敗」，但觀察停在那張 ticket 的執行紀錄裡、沒有升格成 SOP 或 error-pattern，於是下一個執行者重蹈覆轍還給出錯誤歸因。log 的原句：「單張 ticket 裡的環境觀察若未升格，下一個執行者不會讀到它。」</p>
<h2 id="收束訊號先於目標">收束：訊號先於目標</h2>
<p>三層排在一起，共同的結構是：「全套件失敗數」這個數字要能當目標用，先要三層都乾淨——<strong>斷言只量程式（決定性）、量測工具忠實轉錄（結構化）、環境具備完整前置（可重現）</strong>。任何一層失真，追「降至 0」就是在追一個會說謊的數字：修好的測試可能本來就沒壞（flaky 誤判）、沒修的失敗可能不是程式的錯（環境缺檔）、紅綠的翻轉可能只是機器忙不忙（計時斷言）。</p>
<p>這週的三個修正各對一層，最後全套件基準首次成立：4557 個測試、失敗 1、exit code 與失敗數一致——這個「1」才開始值得追。</p>
<h2 id="相關閱讀">相關閱讀</h2>
<ul>
<li>假綠的產品側形態：<a href="/blog/work-log/flutter_viewmodel_mock_implementation_passes_tests/" data-link-title="產品碼自己是 mock — ViewModel 假實作通過了 15 個測試" data-link-desc="ViewModel 用 Future.delayed 加硬編碼資料交付、狀態轉換測試全綠，因為測試耦合的是狀態機、不是業務效果。假實作被當成完成的基礎，直到 63 個測試「寫不出來」才現形——測試的可寫性是實作真實性的探針。">產品碼自己是 mock</a>——那篇是綠燈說謊、本文是紅燈說謊，合起來是「測試訊號可信度」的兩面</li>
<li>環境觀察要升格的原則層：<a href="/blog/report/lint-scope-must-be-explicit-fact/" data-link-title="檢查規則的作用域要顯式列舉：零 error 可能是沒被檢查" data-link-desc="新增與既有受檢目錄同類的內容目錄時、或工具鏈長期零 error 卻累積出違規時使用。規則的作用域由路徑常數決定、該常數常同時被多個檢查共用，擴作用域會連帶擴語意；作用域是獨立於規則內容的 fact，驗收方式是先確認新規則對已知違規報錯。">#221 檢查規則的作用域要顯式列舉</a>——「觀察存在」與「觀察被制度化」是兩個獨立的 fact，同構於「規則存在 vs 規則涵蓋」</li>
<li>兩次門檻的取樣版：<a href="/blog/report/two-occurrence-threshold/" data-link-title="2 次門檻：第一次是運氣、第二次是訊號" data-link-desc="同一個問題出現第 2 次時、就該停下來把處理層級升一階 — 從推理升到量測、從手動驗證升到自動化、從同方向嘗試升到換思路。第 1 次失敗的資訊不足、第 2 次提供「重複出現」的證據、值得付出升級成本。本文是 #11 / #15 / #20 / #23 四篇實作的共同抽象。">#42 2 次門檻：第一次是運氣、第二次是訊號</a>——flaky 判定的 N ≥ 5 是同一個統計直覺在「連續成功」方向的應用：三連勝不足以證明穩定</li>
</ul>
]]></content:encoded></item></channel></rss>