前言
至目前為止,我們已經了解為何要做單元測試、建立單元測試的環境、如何撰寫簡單的測試,以及斷言工具,接下來要討論的是關於如何讓我們的單元測試容易測試,會提到何謂依賴、Stub測試替身,以及建構子依賴注入三個議題。何謂依賴?
我們認識一些關於單元測試的基礎知識後,現在來看看如何控制外部的依賴關係。什麼是依賴?在物件導向的觀點中,當一個物件與另一個物件之間有互動、呼叫、使用、委派等等的關係,都是屬於一種依賴關係,然而這些依賴有強弱之分,若是不當的設計產生高耦合,當你在測試時無法順利的切換或控制依賴的物件時,這時就有重構的必要,並且需要讓你的程式變得可測試或更有彈性。尤其涉及物件的生成時,我們將物件的產生交由給某個物件,這時控制權就是在這個物件身上,當你要測試時,就無法有彈性的抽換測試,以我們先前使用的FileParser為例,我稍微把FileParser弄得複雜一點,變成以下這樣。- 原始的FileParser
public class FileParser {
public boolean isValidLogFileName(String fileName) {
if (fileName == null || fileName.length() == 0) {
throw new IllegalArgumentException("請提供檔名!");
}
return fileName.toLowerCase().endsWith(".exe");
}
}
- 修改後的FileParser
public class FileParser {
public FileParser() {}
public boolean isValidLogFileName(String fileName) {
FileExtensionManager fileExtensionManager = new FileExtensionManager();
return fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
}
}
我們把原本isValidLogFileName的邏輯抽出到FileExtensionManager這個物件,並且多加了FileHelper的靜態方法,來判斷檔名是否超過五個字,以下為FileExtensionManager與FileHelper的程式碼。
- FileExtensionManager
public class FileExtensionManager {
public boolean isValid(String fileName) {
if (fileName == null || fileName.length() == 0) {
throw new IllegalArgumentException("請提供檔名!");
}
// 假設需要連接資料庫、檔案系統或是網路來判斷回傳值,但為求簡單,直接回傳false,表示連接失敗。
return false;
}
- FileHelper
public class FileHelper {
public static String basenameWithoutExtension(String fileName) {
String basename = new File(fileName).getName();
if (basename.contains(".")) {
basename = basename.substring(0, basename.indexOf("."));
}
return basename;
}
}
原本的邏輯抽離到FileExtensionManager物件,然而這個物件我們假設它需要使用資料庫、檔案系統或是網路等外部資源,然後透過這些方式處理檔名後,再回傳布林值。這裡為了簡化,我們就不實作外部資源相關的程式碼,直接回傳一個false,表示連接以上的方式造成失敗;FileHelper就單純取得副檔名小數點前的檔名,並且回傳而已。
看起來似乎沒什麼問題,程式碼也非常簡單,但從設計所帶來的問題,FileParser對FileExtensionManager產生了依賴,變得難以測試,如下圖。為什麼?首先,我們直接生成了FileExtensionManager物件,再來FileExtensionManager的程式碼使用了外部環境的依賴,記得單元測試的特性?其中之一就是"它應該要獨立於其他測試的運行",若是被測單元使用一個或多個真實環境或依賴物件,那這就是整合測試,你的測試程式碼無法根本無法控制這些外部資源。
該如何讓FileParser變得好測試?在物件導向的設計方向中,我們可以使用一個間接層,透過介面化的方式,將這層邏輯給抽離出來,這樣就能好測試了嗎?我們先來看看抽出一個介面會變成什麼樣子,類別圖與程式碼如下。
public interface IExtensionManager {
boolean isValid(String fileName);
}
public class FileExtensionManagerImp implements IExtensionManager {
@Override
public boolean isValid(String fileName) {
return false;
}
}
public class FileParser {
public FileParser() {}
public boolean isValidLogFileName(String fileName) {
IExtensionManager fileExtensionManager = new FileExtensionManagerImp();
return fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
}
}
稍微重構了一下,但是這樣修改跟之前的程式碼不是差不多?我還是沒辦法測試不是嗎?沒錯,所以這時我們就需要使用測試替身和依賴注入的方式,來協助我們完成FileParser的測試。
測試替身 - 先論Stub
所謂的測試替身就是創建一系列「僅供測試」的工具物件,用來隔離被測程式碼、加速執行測試、使隨機行為變得確定、模擬特殊情況,以及使測試存取物件時能隱藏訊息。測試替身有Stub、Fake、Spy、Mock四種,如下圖。Stub在中文有人翻譯為測試樁或測試殘片,但我直接統一用Stub。以上四種都是一種模擬資料物件,Stub比較簡潔,通常也沒做什麼事,就實作介面後,回傳預設值就好,所以我們可以用上面的程式碼產生一個Stub,類別圖與程式碼如下。
public class StubExtensionManager implements IExtensionManager {
public boolean shouldExtensionsBeValid;
@Override
public boolean isValid(String fileName) {
return this.shouldExtensionsBeValid;
}
}
很簡單吧!直接實作IExtensionManager介面,現在就產生好一個模擬的測試資料物件,接下來就是要把原本FileParser內的抽換掉,這時需要利用依賴注入的技巧來幫我們達成。
依賴注入 - 先論建構子依賴注入
所謂的依賴注入(Dependency Injection, DI)就是在你的測試單元中注入一個模擬物件,藉由這個模擬物件協助你完成測試。依賴注入常用的技巧有下列五種方式,這邊我們先以建構子這種方式來實現,如下。- 建構子
- Setter方法
- 工廠類別
- 工廠方法
- 映射
建構子這個定義我就不說明,相信大家都知道。所謂的建構子注入,就是我們將要傳進來的物件,藉由建構子聲明來傳入,以下是程式碼。
public class FileParser {
private IExtensionManager fileExtensionManager;
public FileParser(IExtensionManager fileExtensionManager) {
// 使用建構子注入方便傳入測試物件
this.fileExtensionManager = fileExtensionManager;
}
public boolean isValidLogFileName(String fileName) {
// 原本產生依賴的程式碼,為求方便,我直接註解來觀察差異。
// IExtensionManager fileExtensionManager = new FileExtensionManagerImp();
return this.fileExtensionManager.isValid(fileName) && FileHelper.basenameWithoutExtension(fileName).length() > 5;
}
}
我把原本的程式給註解,方便觀察差異。我直接在建構子中,聲明了要傳入一個IExtensionManager的型態物件,這樣就可以隨時抽換要使用的測試替身。從FileParser的觀點,其實就只需要IExtensionManager回傳布林值,true或false這兩種情況,至於是不是透過外部資源來獲得這兩個值,就不該是FileParser所需關注的,這個又稱為關注點分離。接下來,就來完成測試吧!
完成測試
測試有四種情況,我們根據下列情況完成這四種測試,條件與程式碼如下。
isValid | 檔名字元是否超過5 | 測試結果 | |
Case1 | true | true | true |
Case2 | true | false | false |
Case3 | false | true | false |
Case4 | false | false | false |
public class FileParserTest {
@Test
public void testNameShorterCharactersIsValidEvenWithSupportedExtension() throws Exception {
// Arrange
StubExtensionManager fake = new StubExtensionManager();
fake.shouldExtensionsBeValid = true;
FileParser fileParser = new FileParser(fake);
// Act
boolean actualResult = fileParser.isValidLogFileName("short.txt");
// Assert
assertThat(actualResult, is(false));
}
@Test
public void testNameShorterThan6CharactersIsValidEvenWithSupportedExtension() throws Exception {
// Arrange
StubExtensionManager fake = new StubExtensionManager();
fake.shouldExtensionsBeValid = true;
FileParser fileParser = new FileParser(fake);
// Act
boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
// Assert
assertThat(actualResult, is(true));
}
@Test
public void testNameShorterCharactersIsNotValidEvenWithSupportedExtension() throws Exception {
// Arrange
StubExtensionManager fake = new StubExtensionManager();
fake.shouldExtensionsBeValid = false;
FileParser fileParser = new FileParser(fake);
// Act
boolean actualResult = fileParser.isValidLogFileName("short.txt");
// Assert
assertThat(actualResult, is(false));
}
@Test
public void testNameShorterThan6CharactersIsNotValidEvenWithSupportedExtension() throws Exception {
// Arrange
StubExtensionManager fake = new StubExtensionManager();
fake.shouldExtensionsBeValid = false;
FileParser fileParser = new FileParser(fake);
// Act
boolean actualResult = fileParser.isValidLogFileName("short_file_name.txt");
// Assert
assertThat(actualResult, is(false));
}
}
順利通過單元測試啦!以上的單元測試就是針對那四種情況設計的,可以自己練習看看,執行結果如下。
沒有留言:
張貼留言