2016年12月4日 星期日

Testing: 解除依賴!測試替身與依賴注入

前言

至目前為止,我們已經了解為何要做單元測試、建立單元測試的環境、如何撰寫簡單的測試,以及斷言工具,接下來要討論的是關於如何讓我們的單元測試容易測試,會提到何謂依賴、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測試結果
Case1truetruetrue
Case2truefalsefalse
Case3falsetruefalse
Case4falsefalsefalse

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));
    }
}

順利通過單元測試啦!以上的單元測試就是針對那四種情況設計的,可以自己練習看看,執行結果如下。


小結

我們順利完成測試,大概也知道若遇到依賴關係時,我們該如何抽離成介面、建立測試替身,以及藉由建構子依賴注入讓我們的測試更容易,接下來的內容會討論其他測試替身與依賴注入的方法,讓我們持續學習單元測試吧!

Source Code

Github: https://github.com/xavier0507/UnitTestSample.git

沒有留言:

張貼留言