在前幾集的內容裡,我們透過寄信程式的例子說明了 Interface 如何幫助不同的團隊在不互相 "認識" 的情況下一起工作,也說明了當 Interface 需要修改時,該如何用繼承的方式讓新舊版本都能正常運作。這一集,我們繼續用同一個寄信程式的故事,但要切入一個完全不同的角度 — Unit Test。
你的程式,有辦法被測試嗎?
先問你一個問題。假設你今天寫好了第一集裡那個 SendEmail() method,你怎麼確認它有正確地執行呢?也許你會說,我執行程式,看一下信箱,如果信收到了,就代表它是對的。這個方法當然可行,但你有沒有想過,這樣的驗證方式有幾個很明顯的問題。
問題一,每次你改了 SendEmail() 裡的任何一行程式碼,你就得再跑一次整個程式,然後去信箱確認,這很麻煩。
問題二,如果你的測試信件伺服器今天不穩定或是連不上,你的測試就失敗了,但這個失敗並不是你的程式有問題,而是外部環境的問題,這樣的結果讓人很困惑。
問題三,如果你的程式有五十個 method,難道每一個都要用手動的方式去驗證嗎?
相信你在業界工作久了,應該對 Unit Test 這個詞不陌生。Unit Test 的精神就是讓每一個 method 都可以用程式碼自動驗證,讓你不需要靠手動的方式來確認程式有沒有正確執行。而 Interface,就是讓 Unit Test 變得可行的關鍵工具之一。
問題的根源 — 直接依賴 Class 讓測試變得很痛
讓我們回頭看第一集裡的寄信程式。在最原始的版本裡,SendEmail() 長這樣:
public void SendEmail(IMailContent mail)
{
EmailServer server = new EmailServer();
server.IP = "1.1.1.1";
server.Username = "admin";
server.Password = "password";
server.Connect();
server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
server.Close();
}
你看到第一行的 new EmailServer() 了嗎?這一行,就是讓這個 method 難以被測試的根本原因。
當 SendEmail() 裡面直接 new 出一個 EmailServer,這就代表你的程式和 EmailServer 這個 class 是緊緊綁在一起的,用業界的說法叫做 hard dependency。只要你呼叫 SendEmail(),它就一定會去嘗試連線到那台 IP 是 1.1.1.1 的 email 伺服器。如果你想寫一個 Unit Test 來驗證 SendEmail() 有沒有正確執行,你就被迫要依賴那台真實的伺服器。一旦那台伺服器不在,或是你的測試環境根本連不到那個 IP,你的 Unit Test 就永遠無法正常跑完,這樣的 Unit Test 是沒有意義的。
解法: 用 Interface 把「怎麼寄」抽離出來
從前幾集的內容,相信你已經能猜到解法的方向了。既然直接依賴 EmailServer class 是問題的根源,那麼我們就把 EmailServer 的行為抽成一個 Interface。
public interface IEmailServer
{
void Connect(string ip, string username, string password);
void Send(string receipent, string content, string subject, bool importance);
void Close();
}
然後讓真正的 EmailServer 去實作這個 Interface:
public class EmailServer : IEmailServer
{
public void Connect(string ip, string username, string password)
{
// 真正的連線邏輯
}
public void Send(string receipent, string content, string subject, bool importance)
{
// 真正的寄信邏輯
}
public void Close()
{
// 真正的關閉連線邏輯
}
}
接下來,把原本的寄信程式改造一下。我們把它包成一個 class,並且讓 IEmailServer 從外面傳進來,而不是在 method 裡面直接 new:
public class EmailSender
{
private readonly IEmailServer _server;
public EmailSender(IEmailServer server)
{
_server = server;
}
public void SendEmail(IMailContent mail)
{
_server.Connect("1.1.1.1", "admin", "password");
_server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
_server.Close();
}
}
你有沒有注意到,EmailSender 現在完全不知道傳進來的 IEmailServer 到底是哪一個 object,它只認得 IEmailServer 這個 Interface 的長相。
做一個假的 EmailServer
因為 EmailSender 現在依賴的是 IEmailServer 而不是真正的 EmailServer,所以在寫 Unit Test 的時候,我們完全可以自己做一個假的 EmailServer object 傳進去。這個假的 object 業界通常叫做 Mock 或是 Fake。
public class FakeEmailServer : IEmailServer
{
public bool ConnectWasCalled { get; private set; }
public bool SendWasCalled { get; private set; }
public string LastReceipent { get; private set; }
public void Connect(string ip, string username, string password)
{
ConnectWasCalled = true;
}
public void Send(string receipent, string content, string subject, bool importance)
{
SendWasCalled = true;
LastReceipent = receipent;
}
public void Close() { }
}
這個 FakeEmailServer 不會真的連線到任何伺服器,也不會真的把信寄出去。它做的事情非常簡單,就是記錄「有沒有被呼叫過」以及「傳進來的值是什麼」。也許你現在覺得這樣的 class 感覺很蠢,但等一下你就會看到它的用途。
Unit Test 實際長什麼樣子
有了 FakeEmailServer,我們就可以寫出一個完全不依賴真實伺服器的 Unit Test:
[TestMethod]
public void SendEmail_ShouldCallSendOnServer_WithCorrectReceipent()
{
// Arrange — 準備測試所需的物件
var fakeServer = new FakeEmailServer();
var sender = new EmailSender(fakeServer);
var mail = new MailContent
{
ReceipentEmail = "test@example.com",
HtmlContent = "<p>Hello</p>",
Subject = "Test Subject",
Importance = false
};
// Act — 執行你要測試的動作
sender.SendEmail(mail);
// Assert — 驗證結果是否符合預期
Assert.IsTrue(fakeServer.ConnectWasCalled);
Assert.IsTrue(fakeServer.SendWasCalled);
Assert.AreEqual("test@example.com", fakeServer.LastReceipent);
}
Unit Test 通常會分成三個階段,分別是 Arrange、Act、Assert,這三個階段在業界也常被簡稱為 AAA。Arrange 是準備測試所需的物件和資料,Act 是執行你想要測試的動作,Assert 則是驗證執行結果是否符合你的預期。
在這個例子裡,你可以看到 fakeServer 被傳進 EmailSender,當 sender.SendEmail(mail) 被呼叫後,我們透過 fakeServer 裡記錄的狀態來確認 Connect() 有被呼叫、Send() 有被呼叫,而且傳進去的收件人 email 也是正確的。整個測試過程完全不需要真實的伺服器,你在任何一台電腦上跑這個 test,結果都會是一致的。
這一集的重點
回頭看一下整個過程,其實我們做的事情並不複雜。我們把第一集的寄信程式稍微整理了一下,用 Interface 把對 EmailServer 的依賴抽離出來,然後透過建構子把 IEmailServer 從外面傳進去。光是這兩個動作,就讓原本難以測試的程式碼變成可以被自動驗證的程式碼。
記得第一集說過的一句話嗎?「一個 object 可以有多個外衣」。EmailServer 和 FakeEmailServer 都穿著 IEmailServer 這件外衣,對 EmailSender 來說,它完全看不出來傳進來的是哪一個,也不需要知道。在正式環境裡,你傳進去的是真正的 EmailServer;在測試環境裡,你傳進去的是 FakeEmailServer。程式碼一行都不用改,行為卻可以完全不同,這就是 Interface 帶給你的自由。
Interface 帶來的好處,遠遠不只是讓元件之間 decouple 而已。可測試性只是其中一個受益者,而且在實務上,這個好處往往是最直接讓你感受到程式品質提升的地方。如果你的程式裡有很多直接 new 出來的 class,不妨試著問自己一個問題:這個 class,有辦法換成 Interface 嗎?

