今天進到核心技術的第三個系列—重構 (Refactoring) 與接縫 (Seam),那不免俗的先來看 Roy Osherove 提供的定義:
重構:在不改變程式碼功能的前提下,修改程式碼的動作。
接縫:程式碼中可以抽換不同功能的地方,這些功能例如:使用虛設常式或模擬物件類別;增加一個建構函式(Constructor)參數;增加一個可設定的公開屬性;把一個方法改成可供覆寫的虛擬方法;又或是把一個委派拉出來變成一個參數或屬性供類別外部來決定內容。
換言之,重構就是程式碼改變(如精簡化等)但程式碼的行為與原先的一樣;而接縫就比較複雜了,在探討的是如何在一個類別或介面找到適合的切入點注入假物件的方法,單元測試要寫得好,相伴的也要有清楚的設計模式(若採用緊耦合的寫法則會像 Day-10 一樣,無法找到適當的接縫點),同時,接縫也需符合開放封閉原則(Open-Closed Principle)的方式實作,可進行擴充但不直接修改內部原始碼功能。
因此,我們在接手已撰寫好的原始碼,若無接縫點則一開始先需進行重構,而單元測試的藝術提供了兩種重構方式解除依賴,其中後者是相依於前者,如下:
A 型:將具象類別(concrete class)抽象成介面(interfaces)或委派(delegates)
B 型:重構程式碼,以便將委派或介面的偽實作注入至目標物件中 (Ex: 被測試類別注入虛設常式)
那一樣,我們還是用程式碼來說會比較清楚。
首先,先介紹今天的商業邏輯程式碼 — LogAnalyzer,其實單元測試的藝術都是以這隻類別做出發及擴充(但前幾天我想說來設計看看自己的商業邏輯原始碼,所以就沒拿出來提了XD),其原始碼如下:
public class LogAnalyzer
{
public bool IsValidLogFileName(string fileName)
{
FileExtensionManager Manager = new FileExtensionManager();
return Manager.IsValid(fileName);
}
}
public class FileExtensionManager
{
public bool IsValid(string fileName)
{
// Read some file here
}
}
從這段原始碼可以看出 LogAnalyzer 的 IsValidLogFileName 方法已經跟 FileExtensionManager 的 IsValid 方法產生緊耦合了(如同 Day-10 的情境)。那在進行 B 型重構之前,我們先需進行 A 型重構,產生接縫點:
public class LogAnalyzer
{
private IExtensionManager Manager;
public bool IsValidLogFileName(string fileName)
{
return Manager.IsValid(fileName);
}
}
public interface IExtensionManager
{
bool IsValid(string fileName);
}
接下來來示範 B 型重構,第一個方式是在一開始初始化的時候就先注入相對應的物件(Day-11 也有類似的範例),程式碼如下:
public class LogAnalyzer
{
private IExtensionManager Manager;
public LogAnalyzer(IExtensionManager inManager)
{
Manager = inManager;
}
public bool IsValidLogFileName(string fileName)
{
return Manager.IsValid(fileName);
}
}
public interface IExtensionManager
{
bool IsValid(string fileName);
}
[TestFixture]
public class LogAnalyzerUnitTests
{
[Test]
public void DemoConstructorTest()
{
// Arrange
var manager = Substitute.For<IExtensionManager>();
manager.IsValid(default).ReturnsForAnyArgs(true);
var log = new LogAnalyzer(manager);
// Act
bool result = log.IsValidLogFileName("short.ext");
// Assert
Assert.True(result);
}
}
除了像上述使用建構函式的方式處理外,還有屬性注入的方式(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);
}
}
public interface IExtensionManager
{
bool IsValid(string fileName);
}
public class FileExtensionManager : IExtensionManager
{
public bool IsValid(string fileName)
{
// Read some file here
}
}
[TestFixture]
public class LogAnalyzerUnitTests
{
[Test]
public void DemoGetSetTest()
{
// Arrange
var manager = Substitute.For<IExtensionManager>();
manager.IsValid(default).ReturnsForAnyArgs(true);
var log = new LogAnalyzer();
log.ExtensionManager = manager;
// Act
bool result = log.IsValidLogFileName("short.ext");
// Assert
Assert.True(result);
}
}
這兩種方式沒有誰優誰劣,我的習慣是看所待的專案的風格大多採用哪一種,就採用那種(保持程式碼風格一致)。而最後一種在方法被呼叫前注入一個假物件,則會在明天做介紹(這個可以寫一個很深的坑,明天來討論吧)。