本章探討當待測程式碼依賴於外部資源(檔案系統、Web 服務、時間等)時,如何透過 stub 來打破這些依賴,讓程式碼變得可測試。核心思路是:找到依賴的介面、加入一層間接層、用可控的替代品取代真實實作。
3.1 Stub 介紹#
作者以 NASA 太空梭模擬器為比喻:太空人不可能在真正的太空中接受測試,所以 NASA 打造了模擬器,用搖桿和螢幕模擬外部世界,讓太空人在受控環境中練習。單元測試的 stub 正是同樣的概念。
外部依賴(External Dependency):系統中你的待測程式碼會互動、但你無法控制的物件。常見範例包括檔案系統、執行緒、記憶體、時間等。
Stub:系統中某個既有依賴的可控替代品(也稱 collaborator)。使用 stub 可以在不直接碰觸依賴的情況下測試你的程式碼。
Stub 與 mock 的關鍵差異:你不會對 stub 做 assert,你對 stub 只是提供假的輸入值;而 mock 才是用來驗證互動行為的對象(第 4 章會詳細討論)。
3.2 辨識 LogAnalyzer 中的檔案系統依賴#
LogAnalyzer 的 IsValidLogFileName 方法需要讀取檔案系統上的設定檔,來判斷某個副檔名是否被允許:
public bool IsValidLogFileName(string fileName)
{
// 讀取設定檔
// 如果設定檔說該副檔名被支援,就回傳 true
}
Figure 3.1: Your method has a direct dependency on the filesystem
這就是所謂的 test-inhibiting design(妨礙測試的設計):程式碼對外部資源有直接依賴,導致測試可能因為外部原因(設定檔不存在、磁碟故障等)而失敗,即使程式邏輯本身是正確的。在 legacy 系統中,一個工作單元可能有大量這類外部依賴。
3.3 決定如何輕鬆測試 LogAnalyzer#
打破依賴的模式源自太空梭模擬器的類比:

Figure 3.2: A space shuttle simulator with realistic joysticks and screens to simulate the outside world
將這個模式套用到程式碼上,有三個步驟:
- 找到介面:找到待測工作單元所依賴的介面或 API。在 LogAn 專案中,這就是檔案系統的設定檔。
- 加入間接層:如果依賴是直接連接的(直接呼叫檔案系統),就把存取檔案的程式碼抽到獨立的類別(如
FileExtensionManager),建立一層間接層。 - 替換底層實作:把
FileExtensionManager替換成你能控制的StubExtensionManager,讓測試程式碼掌控外部依賴的行為。
flowchart LR
A["1. 找到介面<br/>識別依賴的 API"] --> B["2. 加入間接層<br/>抽取到獨立類別"]
B --> C["3. 替換底層實作<br/>用 Stub 取代真實依賴"]
Figure 3.3: Introducing a layer of indirection to the design

Figure 3.4: Introducing a stub to break the dependency
替換後的實例不會真的碰檔案系統,從而打破了依賴。因為我們測的不是「與檔案系統互動的那個類別」,而是「呼叫那個類別的程式碼」,所以 stub 什麼都不做也完全沒問題。
3.4 重構設計使其更可測試#
本節介紹兩個重要術語:
重構(Refactoring):在不改變程式碼功能的前提下改變其結構。重構前後,程式碼做的事完全一樣,只是看起來不同。
接縫(Seam):程式碼中可以插入不同功能的地方,例如建構子參數、可設定的屬性、可覆寫的虛擬方法、可外部化的委派等。接縫是實踐**開放封閉原則(Open-Closed Principle)**的結果。
重構的兩種類型:
- Type A:把具體物件抽象化為介面(interface)或委派(delegate)
- Type B:讓假實作可以被注入到待測類別中
3.4.1 Extract Interface:擷取介面以允許替換實作#
第一步是把碰檔案系統的程式碼抽到獨立類別,再為該類別擷取出介面:
public class FileExtensionManager : IExtensionManager
{
public bool IsValid(string fileName)
{
// 讀取檔案...
}
}
public interface IExtensionManager
{
bool IsValid(string fileName);
}接著建立一個實作該介面的 fake 類別:
public class AlwaysValidFakeExtensionManager : IExtensionManager
{
public bool IsValid(string fileName)
{
return true;
}
}命名注意:作者建議用 Fake 而非 Stub 或 Mock 來命名這類替代物件(如
FakeExtensionManager),因為同一個 fake 未來可能被當作 stub 或 mock 使用。使用AlwaysValidFakeExtensionManager這樣的名稱,讓讀者不用看原始碼就能理解它的行為。
classDiagram
class IExtensionManager {
<<interface>>
+IsValid(fileName) bool
}
class FileExtensionManager {
+IsValid(fileName) bool
}
class FakeExtensionManager {
+WillBeValid bool
+IsValid(fileName) bool
}
class LogAnalyzer {
-IExtensionManager manager
+IsValidLogFileName(fileName) bool
}
IExtensionManager <|.. FileExtensionManager : 正式實作
IExtensionManager <|.. FakeExtensionManager : 測試替身
LogAnalyzer --> IExtensionManager : 依賴3.4.2 依賴注入:將 fake 實作注入待測單元#
建立了介面和 fake 之後,需要一種方式把 fake 「塞進」待測程式碼中。常見的注入方式包括:
- 透過建構子接收介面,存到欄位供後續使用
- 透過屬性 get/set 注入
- 在方法呼叫前注入(參數傳遞、工廠類別、區域工廠方法)
mindmap
root((依賴注入方式))
建構子注入
明確表達必要依賴
API 語意清晰
屬性注入
依賴為可選
更簡潔易讀
工廠類別
待測類別不變
透過工廠 setter 替換
Extract and Override
覆寫 virtual 方法
對程式碼改變最小3.4.3 建構子注入(Constructor Injection)#
在建構子中加入介面型別的參數,讓測試時可以傳入 fake:
public class LogAnalyzer
{
private IExtensionManager manager;
public LogAnalyzer(IExtensionManager mgr)
{
manager = mgr;
}
public bool IsValidLogFileName(string fileName)
{
return manager.IsValid(fileName);
}
}
Figure 3.5: Flow of injection via a constructor
測試程式碼如下:
[Test]
public void IsValidFileName_NameSupportedExtension_ReturnsTrue()
{
FakeExtensionManager myFakeManager = new FakeExtensionManager();
myFakeManager.WillBeValid = true;
LogAnalyzer log = new LogAnalyzer(myFakeManager);
bool result = log.IsValidLogFileName("short.ext");
Assert.True(result);
}
internal class FakeExtensionManager : IExtensionManager
{
public bool WillBeValid = false;
public bool IsValid(string fileName)
{
return WillBeValid;
}
}注意 fake 物件與先前的 AlwaysValidFakeExtensionManager 不同:它可以透過 WillBeValid 屬性從測試端設定回傳值,一個 fake 類別就能在多個測試案例中重複使用。
建構子注入的注意事項:當待測類別的依賴越來越多,建構子參數會不斷膨脹,降低可讀性和可維護性。解決方案包括:參數物件重構(Parameter Object Refactoring)——把所有依賴包成一個類別傳入;或使用 IoC 容器(如 Unity、StructureMap、Castle Windsor、Autofac、Ninject)自動處理依賴解析。
何時使用建構子注入:當你想明確告訴 API 使用者「這些依賴是必要的、不可省略的」時。如果依賴是可選的,請改用屬性注入。
3.4.4 模擬例外(Simulating Exceptions from Fakes)#
Fake 不只能回傳值,還能模擬拋出例外的情境:
internal class FakeExtensionManager : IExtensionManager
{
public bool WillBeValid = false;
public Exception WillThrow = null;
public bool IsValid(string fileName)
{
if (WillThrow != null)
{
throw WillThrow;
}
return WillBeValid;
}
}測試時只需設定 WillThrow 屬性,就能驗證待測程式碼在面對例外時的行為。
3.4.5 屬性注入(Property Injection)#
透過公開的 property get/set 來注入依賴,待測類別在建構子中使用預設的真實實作:
public class LogAnalyzer
{
private IExtensionManager manager;
public LogAnalyzer()
{
manager = new FileExtensionManager();
}
public IExtensionManager ExtensionManager
{
get { return manager; }
set { manager = value; }
}
public bool IsValidLogFileName(string fileName)
{
return manager.IsValid(fileName);
}
}
Figure 3.6: Using properties to inject a fake dependency
測試端透過屬性設定注入 fake:
LogAnalyzer log = new LogAnalyzer();
log.ExtensionManager = someFakeManagerCreatedEarlier;何時使用屬性注入:當依賴是可選的,或者依賴有預設實例不會在測試中造成問題時。屬性注入比建構子注入更簡潔易讀。
和建構子注入的差異在於 API 設計的語意:建構子參數表達「這是必要的」;屬性則表達「這個依賴不是必要的,可以替換」。
3.4.6 在方法呼叫前注入(Inject Before Method Call)#
這類做法的特點是:由待測類別自己在內部取得依賴的實例,而非由外部設定。
使用工廠類別(Factory Class)#
待測類別在建構子中從工廠取得依賴,測試透過工廠的 setter 方法設定要回傳的 fake:
public class LogAnalyzer
{
private IExtensionManager manager;
public LogAnalyzer()
{
manager = ExtensionManagerFactory.Create();
}
public bool IsValidLogFileName(string fileName)
{
return manager.IsValid(fileName)
&& Path.GetFileNameWithoutExtension(fileName).Length > 5;
}
}
class ExtensionManagerFactory
{
private IExtensionManager customManager = null;
public IExtensionManager Create()
{
if (customManager != null)
return customManager;
return new FileExtensionManager();
}
public void SetManager(IExtensionManager mgr)
{
customManager = mgr;
}
}
Figure 3.7: A test configures the factory class to return a stub object
測試端:
ExtensionManagerFactory.SetManager(myFakeManager);
LogAnalyzer log = new LogAnalyzer();使用靜態工廠時,務必在每個測試前後重設工廠狀態,避免測試之間互相影響。
不同的間接層深度#
| 層級 | 做法 | 說明 |
|---|---|---|
| Layer 1:待測類別內部成員 | 建構子注入 | 類別內部的成員變成 fake,其他程式碼不變 |
| Layer 2:工廠回傳的依賴 | 設定工廠的屬性 | 工廠內部的成員是 fake,待測類別完全不變 |
| Layer 3:工廠類別本身 | 替換整個工廠 | 用 fake 工廠取代真實工廠 |
越深入底層,你的操控能力越強,但測試也越難理解。關鍵是找到可讀性與操控能力之間的平衡。
使用區域工廠方法(Extract and Override)#
在待測類別中建立 virtual 的工廠方法,測試時繼承該類別並覆寫工廠方法,回傳 fake:

Figure 3.8: Inheriting from the class under test to override its virtual factory method
public class LogAnalyzerUsingFactoryMethod
{
public bool IsValidLogFileName(string fileName)
{
return GetManager().IsValid(fileName);
}
protected virtual IExtensionManager GetManager()
{
return new FileExtensionManager();
}
}
// 測試用的衍生類別
class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
{
public IExtensionManager Manager;
public TestableLogAnalyzer(IExtensionManager mgr)
{
Manager = mgr;
}
protected override IExtensionManager GetManager()
{
return Manager;
}
}測試程式碼:
[Test]
public void overrideTest()
{
FakeExtensionManager stub = new FakeExtensionManager();
stub.WillBeValid = true;
TestableLogAnalyzer logan = new TestableLogAnalyzer(stub);
bool result = logan.IsValidLogFileName("file.ext");
Assert.True(result);
}這種技巧稱為 Extract and Override,是一種強大且簡潔的依賴打破手法。
3.5 重構技巧的變體:用 Extract and Override 建立假結果#

Figure 3.9: Using Extract and Override to return a logical result instead of calling an actual dependency
除了覆寫工廠方法回傳 fake 物件,你也可以直接覆寫回傳邏輯結果的方法。在待測類別中,把包含依賴的邏輯抽成 virtual 方法,衍生類別直接覆寫回傳值,連 stub 物件都不需要:
public class LogAnalyzerUsingFactoryMethod
{
public bool IsValidLogFileName(string fileName)
{
return this.IsValid(fileName);
}
protected virtual bool IsValid(string fileName)
{
FileExtensionManager mgr = new FileExtensionManager();
return mgr.IsValid(fileName);
}
}
// 測試用的衍生類別——直接回傳假結果
class TestableLogAnalyzer : LogAnalyzerUsingFactoryMethod
{
public bool IsSupported;
protected override bool IsValid(string fileName)
{
return IsSupported;
}
}測試程式碼更加簡潔:
[Test]
public void overrideTestWithoutStub()
{
TestableLogAnalyzer logan = new TestableLogAnalyzer();
logan.IsSupported = true;
bool result = logan.IsValidLogFileName("file.ext");
Assert.True(result);
}何時使用 Extract and Override:適合用來模擬待測程式碼的輸入(回傳值、模擬介面)。但如果要驗證程式碼的輸出互動(例如是否正確呼叫了某個 Web 服務),Extract and Override 就不太適合,應改用 isolation framework(下一章的主題)。作者偏好在不需要新介面且類別未被 sealed 時優先使用此技巧,因為它對程式碼的語意改變最小。
3.6 克服封裝問題#
為了測試而新增建構子、setter、工廠等,看起來似乎違反了物件導向的封裝原則。作者對此的觀點很直接:測試是你 API 的第二種使用者,它和生產程式碼的使用者同等重要,只是目標不同。過度保護的設計(private 建構子、sealed 類別、不可覆寫的方法)是阻礙測試的常見原因。
作者提出 TOOD(Testable Object-Oriented Design) 的概念:在設計時就考慮可測試性,這與傳統的 OOD 觀點有時會衝突。以下是幾種在 release 模式中隱藏測試用接縫的技巧。
3.6.1 使用 internal 與 [InternalsVisibleTo]#
不想讓所有人看到額外的建構子?用 internal 取代 public,再用 [InternalsVisibleTo] 組件層級屬性把 internal 成員暴露給測試組件:
public class LogAnalyzer
{
// ...
internal LogAnalyzer(IExtensionManager extensionMgr)
{
manager = extensionMgr;
}
// ...
}
// 在 AssemblyInfo.cs 中
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AOUT.CH3.Logan.Tests")]這是在沒有其他方式讓測試存取時的好選擇。
3.6.2 使用 [Conditional] 屬性#
System.Diagnostics.ConditionalAttribute 可以讓方法的呼叫端在特定 build flag 不存在時被移除:
[Conditional("DEBUG")]
public void DoSomething()
{
}當 build flag 不是 DEBUG 時,所有對 DoSomething() 的呼叫都會被移除,但方法本身仍然存在。可用於只在 debug 模式下才被呼叫的 setter 方法。
[Conditional]只能用在方法上,不能用在建構子上。被標注的方法本身不會從 production code 中隱藏,這與條件編譯不同。
3.6.3 使用 #if / #endif 條件編譯#
把測試專用的建構子或方法放在 #if DEBUG … #endif 之間,只有在該 build flag 被設定時才會編譯:
#if DEBUG
public LogAnalyzer(IExtensionManager extensionMgr)
{
manager = extensionMgr;
}
#endif條件編譯容易讓程式碼變得凌亂、降低可讀性。建議優先使用
[InternalsVisibleTo]。
3.7 總結#
本章的核心要點:
- Stub 是外部依賴的可控替代品,用來提供假的輸入給待測程式碼
- 打破依賴的關鍵是找到正確的間接層(layer of indirection),或自己建立一個,然後把它當作**接縫(seam)**來注入 stub
- 這些替代類別統稱為 fake,因為我們不想在建立時就決定它是 stub 還是 mock
- 注入 stub 的方式有多種:建構子注入、屬性注入、工廠類別、區域工廠方法(Extract and Override)
- 間接層越深,操控能力越強,但測試也越難理解;越接近待測物件的表面,測試越容易理解和維護
- Extract and Override 適合模擬輸入;若需要驗證物件之間的互動,應改用 interface 搭配 isolation framework
- TOOD(Testable Object-Oriented Design) 允許在設計中保留測試接縫,同時透過
internal、[InternalsVisibleTo]、[Conditional]、條件編譯等機制在 release 模式中隱藏這些接縫