<?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>Sociable on Tarragon</title><link>https://tarrragon.github.io/blog/tags/sociable/</link><description>Recent content in Sociable on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 25 Aug 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/sociable/index.xml" rel="self" type="application/rss+xml"/><item><title>Sociable vs Solitary Unit Test（單元的兩種定義）</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/unit-definition-two-schools/</link><pubDate>Tue, 25 Aug 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/unit-definition-two-schools/</guid><description>&lt;p>Unit test 的「單元」有兩個並存的定義，所以「這裡該不該換成 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">test double&lt;/a>」這個問題在兩派下有不同的正確答案。差別在測試邊界畫在哪一層。&lt;/p>
&lt;p>&lt;strong>Sociable&lt;/strong> 把單元定義為模組——一組協同工作的類別，對外提供公開 API。測試只透過這個 API 互動，替換掉的只有跨越應用邊界的依賴，也就是跨出本應用進程、需要外部世界回應的那些（資料庫、檔案系統、外部服務、時鐘、亂數）。&lt;/p>
&lt;p>&lt;strong>Solitary&lt;/strong> 把單元定義為單一類別，把所有協作者換成 test double（在測試裡頂替真實協作者的物件），測試看得見類別之間的每一次呼叫。&lt;/p>
&lt;p>想要一句最小分流：內部的類別劃分未來會不會反覆重組？會，畫在模組；已經定案而失敗定位很花時間，畫在類別；已經定案而定位本來就不花時間，兩者皆可，這時看真實協作者造起來與跑起來的代價。完整的判讀方式——邊界畫錯時看得出來的症狀、以及每個症狀先試哪個不移動邊界的解法——在 &lt;a href="https://tarrragon.github.io/blog/record/behavior-first-tdd-methodology/" data-link-title="TDD 的兩種做法：邊界畫錯時看得出來的症狀" data-link-desc="測試的維護成本開始超過它提供的保護、或重構時測試大量壞掉時，用來診斷測試邊界該畫在模組還是類別">TDD 的兩種做法&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>分野的後果落在&lt;strong>耦合線&lt;/strong>上。一條耦合線是測試依賴的一個結構點：測試知道它長什麼樣，它一改，測試就要跟著改。計數規則是受測對象算一個，每個替身各算一個。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Sociable 耦合線
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> Test ──→ Module 公開 API 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> └─ Class A · B · C（黑盒，測試看不見）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Solitary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> Test ──→ Class A（受測對象） 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> Test ──→ Double(B)（設定回應、斷言呼叫） 2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> Test ──→ Double(C)（設定回應、斷言呼叫） 3
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl"> Class A 對 B、C 的呼叫方式，測試全都知道&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>條數本身不是重點，兩種條各自的穩定性才是。Sociable 那一條是模組的公開契約，它本來就被設計成不常變；Solitary 多出來的那幾條是模組內部的協作協定，沒有人承諾過它穩定，而它正是重構會動到的東西。所以維護成本不看條數的絕對值，看的是有幾條掛在「沒有人承諾穩定」的東西上。&lt;/p>
&lt;p>Solitary 因此多承擔一種盲區：類別之間的真實協作行為在測試裡不可見。機制與 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽&lt;/a>相同——替身忠實模擬方法簽名、跳過被替換那一方的實際行為——差別在範圍。那一篇處理的是替換發生在應用邊界時被跳過的兩層：協議層（訊息的序列與錯誤語意）與環境層（真實延遲與資源限制）。這裡被跳過的是模組內部類別之間的協作。兩派替換掉的外部依賴一樣多，多出來的替身全在模組內。&lt;/p>
&lt;p>書市對兩派另有一組慣稱：Sociable 是古典學派（classicist）寫出來的測試形態，Solitary 是倫敦學派（mockist、London school）的。兩組詞的差別在命名者關注的是測試的社交性，還是學派的傳承來源。兩派的原始定義、最完整的一次陳述、以及對倫敦學派的系統性批評各在哪本，見書單的 &lt;a href="https://tarrragon.github.io/blog/books/craft/verification/" data-link-title="驗證自己寫對了：軟體測試的選讀書目與各書的分歧" data-link-desc="測試驅動開發的原始定義、以 mock 為主的一派、評估測試的判斷標準、機器執行判斷標準之後人的工作，以及從規格推出測試案例的程序，分別由哪一本書討論，還有這幾本書彼此不同意的地方">驗證自己寫對了&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>判斷手上一套測試屬於哪一派，有一個操作程序（前提是手上已經有一套測試；零測試的專案不適用，它還沒有派別可判）：改變模組的內部邏輯、調整類別結構、重新命名內部方法，讓外部行為保持不變。全部測試依然通過就是 Sociable；有測試要跟著改，那些測試就是 Solitary 的，而且耦合線的位置同時現形。用重構的 diff 也看得出來：測試的改動量跟生產程式碼相當、而這次改動沒有改變任何外部行為時，測試耦合的是結構。&lt;/p>
&lt;p>這個程序判的是&lt;strong>測試現在耦合在什麼上，不判對錯&lt;/strong>。結果是 Solitary 而那個模組確實有它的理由（結構已凍結、失敗定位很貴、或替身是拿來逼出還不存在的介面），那是正確的設計；理由一個都不成立，才是該重畫邊界的訊號。理由清單在 &lt;a href="https://tarrragon.github.io/blog/record/behavior-first-tdd-methodology/" data-link-title="TDD 的兩種做法：邊界畫錯時看得出來的症狀" data-link-desc="測試的維護成本開始超過它提供的保護、或重構時測試大量壞掉時，用來診斷測試邊界該畫在模組還是類別">TDD 的兩種做法&lt;/a>。&lt;/p>
&lt;p>兩種定義寫出來的測試長什麼樣，以訂單提交為例。兩者斷言不同的對象。Sociable 只替換跨邊界的 Repository，驗證的是可觀察的結果：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;使用者提交訂單成功&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="n">when&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mockRepository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">any&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="n">thenAnswer&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">SaveResult&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">success&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;order-123&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">submitOrderUseCase&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isSuccess&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">orderId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;order-123&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c1">// Order、OrderValidator、PriceCalculator 都是真實物件
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Solitary 把協作者全部換成替身，驗證的是呼叫本身：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;OrderService.submitOrder calls Repository.save&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">order&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Order&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 值物件用真實實例，兩派都不替換
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">final&lt;/span> &lt;span class="n">mockValidator&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">MockOrderValidator&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">mockCalculator&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">MockPriceCalculator&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">mockRepository&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">MockOrderRepository&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">when&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mockValidator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">thenReturn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">when&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mockCalculator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">calculate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">thenReturn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">when&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mockRepository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="n">thenAnswer&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">_&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">SaveResult&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">success&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;order-123&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">orderService&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">submitOrder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="n">verify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mockRepository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">called&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="n">verify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mockValidator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">order&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="n">called&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>選單元定義的人同時選定了測試失敗時會拿到什麼樣的訊號。Sociable 的紅燈說「這個模組的對外行為變了」，定位範圍是整個模組，而它變紅時外部行為確實變了。Solitary 的紅燈定位精確到類別，代價是重構期間的誤報——測試變紅而外部行為沒壞，成因是內部結構動了，跟 &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/flaky-test-root-cause/" data-link-title="Flaky test 根因分類" data-link-desc="計時依賴 / 環境差異 / 資源競爭 / 非確定性輸出 — 四類 flaky test 根因的辨識和處理策略">flaky&lt;/a> 那種每次跑結果不同的不確定性來源不同。兩者是把誤報成本與定位成本分配到不同位置，不是精度的高低之分。&lt;/p>
&lt;p>替身的角色分布是這個選擇的下游結果。邊界畫在模組時替身只出現在應用邊界，多半是 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/stub/" data-link-title="Stub" data-link-desc="測試作者手動寫死回應資料的 test double：驗證的是假設成立時邏輯是否正確，假設本身錯誤時無法檢出">stub&lt;/a>（寫死回應資料）或 fake（有狀態、可運作的簡化實作），任務是讓外部依賴可控；邊界畫在類別時替身深入模組內部，多半是 mock（預設期望、呼叫不符即失敗）與 spy（記錄呼叫供事後檢查），任務是斷言互動。選錯角色的後果在 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">test double 分類&lt;/a>那張卡。&lt;/p>
&lt;p>有一類測試落在兩種定義的分類之外，用它們的判斷標準評會得到誤導的結論：&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">characterization test&lt;/a> 斷言的是現狀而非正確性，判斷標準與退場條件在那張卡。&lt;/p>
&lt;p>兩派都預設行為由寫程式的人決定，所以兩者都沒有回答「這條斷言的預期值從哪裡來」。那是分野之外的另一個問題，見 &lt;a href="https://tarrragon.github.io/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判斷標準的推導來源：測試與實作同源的形態、同源時抓得到的錯誤類別與拿回獨立性的變數" data-link-desc="出處同源與判斷標準被實作污染的差別，產出、組織與契約各層由誰決定判斷標準，以及不再逐行讀產出時由哪個環節接手驗證">判斷標準的推導來源&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Unit test 的「單元」有兩個並存的定義，所以「這裡該不該換成 <a href="/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">test double</a>」這個問題在兩派下有不同的正確答案。差別在測試邊界畫在哪一層。</p>
<p><strong>Sociable</strong> 把單元定義為模組——一組協同工作的類別，對外提供公開 API。測試只透過這個 API 互動，替換掉的只有跨越應用邊界的依賴，也就是跨出本應用進程、需要外部世界回應的那些（資料庫、檔案系統、外部服務、時鐘、亂數）。</p>
<p><strong>Solitary</strong> 把單元定義為單一類別，把所有協作者換成 test double（在測試裡頂替真實協作者的物件），測試看得見類別之間的每一次呼叫。</p>
<p>想要一句最小分流：內部的類別劃分未來會不會反覆重組？會，畫在模組；已經定案而失敗定位很花時間，畫在類別；已經定案而定位本來就不花時間，兩者皆可，這時看真實協作者造起來與跑起來的代價。完整的判讀方式——邊界畫錯時看得出來的症狀、以及每個症狀先試哪個不移動邊界的解法——在 <a href="/blog/record/behavior-first-tdd-methodology/" data-link-title="TDD 的兩種做法：邊界畫錯時看得出來的症狀" data-link-desc="測試的維護成本開始超過它提供的保護、或重構時測試大量壞掉時，用來診斷測試邊界該畫在模組還是類別">TDD 的兩種做法</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>分野的後果落在<strong>耦合線</strong>上。一條耦合線是測試依賴的一個結構點：測試知道它長什麼樣，它一改，測試就要跟著改。計數規則是受測對象算一個，每個替身各算一個。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Sociable                                    耦合線
</span></span><span class="line"><span class="ln">2</span><span class="cl">  Test ──→ Module 公開 API                    1
</span></span><span class="line"><span class="ln">3</span><span class="cl">             └─ Class A · B · C（黑盒，測試看不見）
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">Solitary
</span></span><span class="line"><span class="ln">6</span><span class="cl">  Test ──→ Class A（受測對象）                 1
</span></span><span class="line"><span class="ln">7</span><span class="cl">  Test ──→ Double(B)（設定回應、斷言呼叫）      2
</span></span><span class="line"><span class="ln">8</span><span class="cl">  Test ──→ Double(C)（設定回應、斷言呼叫）      3
</span></span><span class="line"><span class="ln">9</span><span class="cl">             Class A 對 B、C 的呼叫方式，測試全都知道</span></span></code></pre></div><p>條數本身不是重點，兩種條各自的穩定性才是。Sociable 那一條是模組的公開契約，它本來就被設計成不常變；Solitary 多出來的那幾條是模組內部的協作協定，沒有人承諾過它穩定，而它正是重構會動到的東西。所以維護成本不看條數的絕對值，看的是有幾條掛在「沒有人承諾穩定」的東西上。</p>
<p>Solitary 因此多承擔一種盲區：類別之間的真實協作行為在測試裡不可見。機制與 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>相同——替身忠實模擬方法簽名、跳過被替換那一方的實際行為——差別在範圍。那一篇處理的是替換發生在應用邊界時被跳過的兩層：協議層（訊息的序列與錯誤語意）與環境層（真實延遲與資源限制）。這裡被跳過的是模組內部類別之間的協作。兩派替換掉的外部依賴一樣多，多出來的替身全在模組內。</p>
<p>書市對兩派另有一組慣稱：Sociable 是古典學派（classicist）寫出來的測試形態，Solitary 是倫敦學派（mockist、London school）的。兩組詞的差別在命名者關注的是測試的社交性，還是學派的傳承來源。兩派的原始定義、最完整的一次陳述、以及對倫敦學派的系統性批評各在哪本，見書單的 <a href="/blog/books/craft/verification/" data-link-title="驗證自己寫對了：軟體測試的選讀書目與各書的分歧" data-link-desc="測試驅動開發的原始定義、以 mock 為主的一派、評估測試的判斷標準、機器執行判斷標準之後人的工作，以及從規格推出測試案例的程序，分別由哪一本書討論，還有這幾本書彼此不同意的地方">驗證自己寫對了</a>。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>判斷手上一套測試屬於哪一派，有一個操作程序（前提是手上已經有一套測試；零測試的專案不適用，它還沒有派別可判）：改變模組的內部邏輯、調整類別結構、重新命名內部方法，讓外部行為保持不變。全部測試依然通過就是 Sociable；有測試要跟著改，那些測試就是 Solitary 的，而且耦合線的位置同時現形。用重構的 diff 也看得出來：測試的改動量跟生產程式碼相當、而這次改動沒有改變任何外部行為時，測試耦合的是結構。</p>
<p>這個程序判的是<strong>測試現在耦合在什麼上，不判對錯</strong>。結果是 Solitary 而那個模組確實有它的理由（結構已凍結、失敗定位很貴、或替身是拿來逼出還不存在的介面），那是正確的設計；理由一個都不成立，才是該重畫邊界的訊號。理由清單在 <a href="/blog/record/behavior-first-tdd-methodology/" data-link-title="TDD 的兩種做法：邊界畫錯時看得出來的症狀" data-link-desc="測試的維護成本開始超過它提供的保護、或重構時測試大量壞掉時，用來診斷測試邊界該畫在模組還是類別">TDD 的兩種做法</a>。</p>
<p>兩種定義寫出來的測試長什麼樣，以訂單提交為例。兩者斷言不同的對象。Sociable 只替換跨邊界的 Repository，驗證的是可觀察的結果：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單成功&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">      <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">submitOrderUseCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="s1">&#39;order-123&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// Order、OrderValidator、PriceCalculator 都是真實物件
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>Solitary 把協作者全部換成替身，驗證的是呼叫本身：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;OrderService.submitOrder calls Repository.save&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">.</span><span class="n">sample</span><span class="p">();</span>          <span class="c1">// 值物件用真實實例，兩派都不替換
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">mockValidator</span> <span class="o">=</span> <span class="n">MockOrderValidator</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">final</span> <span class="n">mockCalculator</span> <span class="o">=</span> <span class="n">MockPriceCalculator</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">final</span> <span class="n">mockRepository</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockValidator</span><span class="p">.</span><span class="n">validate</span><span class="p">(</span><span class="n">order</span><span class="p">)).</span><span class="n">thenReturn</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockCalculator</span><span class="p">.</span><span class="n">calculate</span><span class="p">(</span><span class="n">order</span><span class="p">)).</span><span class="n">thenReturn</span><span class="p">(</span><span class="m">100</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">order</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kd">await</span> <span class="n">orderService</span><span class="p">.</span><span class="n">submitOrder</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="n">verify</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">order</span><span class="p">)).</span><span class="n">called</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="n">verify</span><span class="p">(</span><span class="n">mockValidator</span><span class="p">.</span><span class="n">validate</span><span class="p">(</span><span class="n">order</span><span class="p">)).</span><span class="n">called</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="設計責任">設計責任</h2>
<p>選單元定義的人同時選定了測試失敗時會拿到什麼樣的訊號。Sociable 的紅燈說「這個模組的對外行為變了」，定位範圍是整個模組，而它變紅時外部行為確實變了。Solitary 的紅燈定位精確到類別，代價是重構期間的誤報——測試變紅而外部行為沒壞，成因是內部結構動了，跟 <a href="/blog/testing/05-test-design-judgment/flaky-test-root-cause/" data-link-title="Flaky test 根因分類" data-link-desc="計時依賴 / 環境差異 / 資源競爭 / 非確定性輸出 — 四類 flaky test 根因的辨識和處理策略">flaky</a> 那種每次跑結果不同的不確定性來源不同。兩者是把誤報成本與定位成本分配到不同位置，不是精度的高低之分。</p>
<p>替身的角色分布是這個選擇的下游結果。邊界畫在模組時替身只出現在應用邊界，多半是 <a href="/blog/testing/knowledge-cards/stub/" data-link-title="Stub" data-link-desc="測試作者手動寫死回應資料的 test double：驗證的是假設成立時邏輯是否正確，假設本身錯誤時無法檢出">stub</a>（寫死回應資料）或 fake（有狀態、可運作的簡化實作），任務是讓外部依賴可控；邊界畫在類別時替身深入模組內部，多半是 mock（預設期望、呼叫不符即失敗）與 spy（記錄呼叫供事後檢查），任務是斷言互動。選錯角色的後果在 <a href="/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">test double 分類</a>那張卡。</p>
<p>有一類測試落在兩種定義的分類之外，用它們的判斷標準評會得到誤導的結論：<a href="/blog/testing/knowledge-cards/characterization-test/" data-link-title="Characterization Test" data-link-desc="斷言「行為不變」而非「行為正確」的測試形態；與正確性測試的語意分界，決定紅燈能不能歸因">characterization test</a> 斷言的是現狀而非正確性，判斷標準與退場條件在那張卡。</p>
<p>兩派都預設行為由寫程式的人決定，所以兩者都沒有回答「這條斷言的預期值從哪裡來」。那是分野之外的另一個問題，見 <a href="/blog/testing/06-agent-authored-code/test-provenance-independence/" data-link-title="判斷標準的推導來源：測試與實作同源的形態、同源時抓得到的錯誤類別與拿回獨立性的變數" data-link-desc="出處同源與判斷標準被實作污染的差別，產出、組織與契約各層由誰決定判斷標準，以及不再逐行讀產出時由哪個環節接手驗證">判斷標準的推導來源</a>。</p>
]]></content:encoded></item></channel></rss>