如何寫出具備良好可測試性的代碼?

  單元測試在一個完整的軟件開發流程中是必不可少的、很是重要的一個環節。一般寫單元測試並不難,但有的時候,有的代碼和功能難以測試,致使寫起測試來困難重重。所以,寫出良好的可測試的(testable)代碼是很是重要的。接下來,咱們簡要地討論一下什麼樣的代碼是難以測試的,咱們應該如何避免寫出難以測試的代碼,以及要寫出可測試性強的代碼的一些最佳實踐。java

什麼是單元測試(unit test)?

在計算機編程中,單元測試(英語:Unit Testing)又稱爲模塊測試, 是針對程序模塊(軟件設計的最小單位)來進行正確性檢驗的測試工做。程序單元是應用的最小可測試部件。在過程化編程中,一個單元就是單個程序、函數、過程等;對於面向對象編程,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法。spring

一般一個單元測試主要有三個行爲:數據庫

  1. 初始化須要測試的模塊或方法。
  2. 調用方法。
  3. 觀察結果(斷言)。

這三個行爲分別被稱爲Arrange, Act and Assert。以java爲例,通常測試代碼以下:編程

@Test
    public void isPalindrome() {

        //初始化:初始化須要被測試的模塊,這裏就是一個對象。
        //也可能沒有初始化模塊,例如測試一個靜態方法。
        PalindromeDetector detector = new PalindromeDetector();

        //調用方法:記錄返回值,以便後續驗證。
        //若是方法無返回值,那麼咱們須要驗證它在執行過程當中是否對系統的其餘部分形成了影響,或產生了反作用。
        boolean isPalindrome = detector.isPalindrome("kayak");

        //斷言:驗證返回結果是否和預期一致。
        Assert.assertTrue(isPalindrome);
    }
複製代碼

單元測試和集成測試的區別

  單元測試的目的是爲了驗證顆粒度最小的、獨立單元的行爲,例如一個方法,一個對象。經過單元測試,咱們能夠確保這個系統中的每一個獨立單元都正常工做。單元測試的範圍僅僅在這個獨立單元中,不依賴其餘單元。而集成測試的目的是驗證整個系統在真實環境下的功能行爲,即將不一樣模塊組合在一塊兒進行測試。集成測試一般須要將項目啓動起來,而且可能會依賴外部資源,例如數據庫,網絡,文件等。設計模式

良好的單元測試的特色

  1. 代碼簡潔清晰
    咱們會針對一個單元寫多個測試用例,所以咱們但願用盡可能簡潔的代碼覆蓋到全部的測試用例。api

  2. 可讀性強
    測試方法的名稱應該直截了當地代表測試內容和意圖,若是測試失敗了,咱們能夠簡單快速地定位問題。經過良好的單元測試,咱們能夠無需經過debug,打斷點的方式來修復bug。bash

  3. 可靠性強
    單元測試只在所測的單元中真的有bug纔會不經過,不能依賴任何單元外的東西,例如全局變量、環境、配置文件或方法的執行順序等。當這些東西發生變化時,不會影響測試的結果。網絡

  4. 執行速度快
    一般咱們每一次打包都會運行單元測試,若是速度很是慢,影響效率,也會致使更多人在本地跳過測試。數據結構

  5. 只測試獨立單元
    單元測試和集成測試的目的不一樣,單元測試應該排除外部因素的影響。框架

如何寫出可測試的代碼

咱們從一個簡單的例子開始探討這個問題。咱們正在編寫一個智能家居控制器的程序,其中一個需求是在夜晚觸摸到檯燈時自動開燈。咱們經過如下方法來判斷當前時間:

public static String getTimeOfDay() {

    Calendar calendar = GregorianCalendar.getInstance();
    calendar.setTime(new Date());
    int hour = calendar.get(Calendar.HOUR_OF_DAY);

    if (hour >= 0 && hour < 6) {
        return "Night";
    }
    if (hour >= 6 && hour < 12) {
        return "Morning";
    }
    if (hour >= 12 && hour < 18) {
        return "Afternoon";
    }
    return "Evening";
}
複製代碼

以上代碼有什麼問題呢?若是咱們以單元測試的角度來看,就會發現這段代碼根本沒法編寫測試, new Date() 表明當前時間,這是一個內嵌在方法裏的隱含輸入,這個輸入是隨時變化的,不一樣時間運行這個方法,返回的值也會不一樣。這個方法的不可預測性致使了沒法測試。若是要測試,咱們的測試代碼可能要這樣寫:

@Test
public void getTimeOfDayTest() {
    try {
        // 修改系統時間,設爲6點
            ...

        String timeOfDay = getTimeOfDay();

        Assert.assertEquals("Morning", timeOfDay);
    } finally {
        // 恢復系統時間
            ...
    }
}
複製代碼

像這樣的單元測試違反了許多咱們上述的良好的測試的特色,好比運行測試代價過高(還要改系統時間),不可靠(這個測試有可能由於設置系統時間失敗而fail),速度也可能比較慢。其次,這個方法違反了幾個原則:

  1. 方法和數據源緊耦合在了一塊兒
    時間這個輸入沒法經過其餘的數據源獲得,例如從文件或者數據庫中獲取時間。

  2. 違反了單一職責原則(Single Responsibility Principle)
    SRP是指每個類或者方法應該有一個單一的功能。而這個方法具備多個職責:1. 從某個數據源獲取時間。 2. 判斷時間是早上仍是晚上。SRP的一個重要特色是:一個類或者一個模塊應該有且只有一個改變的緣由,在上述代碼中,卻有兩個緣由會致使方法的修改:1. 獲取時間的方式改變了(例如改爲從數據庫獲取時間)。 2. 判斷時間的邏輯改變了(例如把從6點開始算晚上改爲從7點開始)。

  3. 方法的職責不清晰
    方法簽名 String getTimeOfDay() 對方法職責的描述不清晰,用戶若是不進入這個api查看源碼,很難了解這個api的功能。

  4. 難以預測和維護
    這個方法依賴了一個可變的全局狀態(系統時間),若是方法中含有多個相似的依賴,那在讀這個方法時,就須要查看它依賴的這些環境變量的值,致使咱們很難預測方法的行爲。

簡單改進

public static String GetTimeOfDay(Calendar time) {

    int hour = time.get(Calendar.HOUR_OF_DAY);

    if (hour >= 0 && hour < 6) {
        return "Night";
    }
    if (hour >= 6 && hour < 12) {
        return "Morning";
    }
    if (hour >= 12 && hour < 18) {
        return "Noon";
    }
    return "Evening";
}
複製代碼

如今,這個方法沒有了獲取時間的職責,他的輸出徹底依賴於傳遞的輸入。所以很容易對它進行測試:

@Test
public void getTimeOfDayTest() {

    Calendar time = GregorianCalendar.getInstance();
    //設置時間
    time.set(2018, 10, 1, 06, 00, 00);

    String timeOfDay = GetTimeOfDay(time);

    Assert.assertEquals("Morning", timeOfDay);
}
複製代碼

很好~這個方法具備了可測試性,可是問題依舊沒有解決,如今獲取時間的職責,轉移到了更高層的代碼上,即調用這個方法的模塊:

public class SmartHomeController {

    private Calendar lastMotionTime;

    public void actuateLights(boolean motionDetected) {

        //更新最後一次觸摸的時間
        if (motionDetected) {
            lastMotionTime.setTime(new Date());
        }

        // Ouch!
        Calendar nowTime = GregorianCalendar.getInstance();
        nowTime.setTime(new Date());

        //判斷時間
        String timeOfDay = getTimeOfDay(nowTime);
        if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) {
            //晚上觸摸檯燈,開燈!
            BackyardLightSwitcher.Instance.TurnOn();
        } else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 ||
            ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) {
            //超過一分鐘沒有觸摸,或者白天,關燈!
            BackyardLightSwitcher.Instance.TurnOff();
        }
    }
}
複製代碼

要解決這個問題,一般可使用依賴注入(控制反轉,IoC),控制反轉是一種重要的設計模式,對於單元測試來講尤爲有效。實際工程中,大多數應用都是由多個類經過彼此的合做來實現業務邏輯的,這使得每一個對象都須要得到與其合做的對象(也就是他所依賴的對象)的引用,若是這個獲取過程要靠自身實現,那會致使代碼高度耦合而且難以測試。那如何反轉呢?即把控制權從業務對象手中轉交到用戶,平臺或者框架中。

引入了控制反轉後的代碼

public class SmartHomeController {

    private Calendar lastMotionTime;
    private Calendar nowTime;

    public SmartHomeController(Calendar nowTime) {
        this.nowTime = nowTime;
    }
    public void actuateLights(boolean motionDetected) {

        //更新最後一次觸摸的時間
        if (motionDetected) {
            lastMotionTime.setTime(new Date());
        }

        //判斷時間
        String timeOfDay = getTimeOfDay(nowTime);
        if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) {
            //晚上觸摸檯燈,開燈!
            BackyardLightSwitcher.Instance.TurnOn();
        } else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 ||
            ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) {
            //超過一分鐘沒有觸摸,或者白天,關燈!
            BackyardLightSwitcher.Instance.TurnOff();
        }
    }
}
複製代碼

在以前代碼中,nowTime的獲取是由SmartHomeController本身實現的,引入控制反轉後,nowTime是在初始化時由咱們注入到對象中。若是使用spring框架,那注入的工做就由spring框架完成,即控制權轉移到了用戶或框架手中,這就是控制反轉的意思。

接下來,咱們就能夠在測試中mock時間屬性:

@Test
public void testActuateLights() {
    Calendar time = GregorianCalendar.getInstance();
    time.set(2018, 10, 1, 06, 00, 00);

    SmartHomeController controller = new SmartHomeController(time);

    controller.actuateLights(true);

    Assert.assertEquals(time, controller.getLastMotionTime());
}
複製代碼

到這裏,已經能夠方便地對其作單元測試了,你認爲這段代碼已經具備良好的可測試性了嗎?

方法的反作用(Side Effects)

咱們仔細看這段開燈關燈的代碼:

if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) {
    //晚上觸摸檯燈,開燈!
    BackyardLightSwitcher.Instance.TurnOn();
} else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 ||
    ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) {
    //超過一分鐘沒有觸摸,或者白天,關燈!
    BackyardLightSwitcher.Instance.TurnOff();
}
複製代碼

這裏經過控制BackyardLightSwitcher這個單例來控制檯燈,這是一個全局的變量,意味着每次運行這個單元測試,可能會修改系統中變量的值。換句話說,這個測試產生了反作用。若是有其餘的單元測試也依賴了BackyardLightSwitcher的值,那麼測試的結果就變得不可控了。所以這個方法依舊不具備良好的可測試性。

函數式、一等公民

java8中引入了函數式和一等公民的概念。咱們熟悉的對象是數據的抽象,而函數是某種行爲的抽象。

頭等函數(first-class function)是指在程序設計語言中,函數被看成頭等公民。這意味着,函數能夠做爲別的函數的參數、函數的返回值,賦值給變量或存儲在數據結構中。 [1] 有人主張應包括支持匿名函數(函數字面量,function literals)。[2]在這樣的語言中,函數的名字沒有特殊含義,它們被看成具備函數類型的普通的變量對待。

其實咱們能夠看到,上述函數依舊不符合單一職責原則,它有兩個職責:1. 判斷當前時間。 2. 操做檯燈。咱們如今將操做檯燈的職責從這個方法中移除,做爲參數傳遞進來:

@FunctionalInterface
public interface Action {
    void doAction();
}
複製代碼
public class SmartHomeController {

    private Calendar lastMotionTime;
    private Calendar nowTime;

    public SmartHomeController(Calendar nowTime) {
        this.nowTime = nowTime;
    }

    public void actuateLights(boolean motionDetected, Action turnOn, Action turnOff) {

        //更新最後一次觸摸的時間
        if (motionDetected) {
            lastMotionTime.setTime(new Date());
        }

        //判斷時間
        String timeOfDay = getTimeOfDay(nowTime);
        if (motionDetected && ("Evening".equals(timeOfDay) || "Night".equals(timeOfDay))) {
            //晚上觸摸檯燈,開燈!
            turnOn.doAction();
        } else if (getIntervalMinutes(lastMotionTime, nowTime) > 1 ||
            ("Morning".equals(timeOfDay) || "Noon".equals(timeOfDay))) {
            //超過一分鐘沒有觸摸,或者白天,關燈!
            turnOff.doAction();
        }
    }
}
複製代碼

如今,對這個方法作測試,咱們能夠將虛擬的行爲傳遞進來:

@Test
public void testActuateLights() {
    Calendar time = GregorianCalendar.getInstance();
    time.set(2018, 10, 1, 06, 00, 00);

    MockLight mockLight = new MockLight();

    SmartHomeController controller = new SmartHomeController(time);

    controller.actuateLights(true, mockLight::turnOn, mockLight::turnOff);

    Assert.assertTrue(mockLight.turnedOn);
}

//用於測試
public class MockLight {

    boolean turnedOn;

    void turnOn() {
        turnedOn = true;
    }

    void turnOff() {
        turnedOn = false;
    }
}
複製代碼

如今,咱們真正擁有了一個可測試的方法,它很是穩定、可靠,沒必要擔憂對系統產生反作用,同時咱們也具備了清晰易懂、可讀性強、可重用的api。

在函數式編程中,有一個概念叫純函數,純函數的主要特色是:

  • 此函數在相同的輸入值時,需產生相同的輸出。函數的輸出和輸入值之外的其餘隱藏信息或狀態無關,也和由I/O設備產生的外部輸出無關。 該函數不能有語義上可觀察的函數反作用,諸如「觸發事件」,使輸出設備輸出,或更改輸出值之外物件的內容等。
  • 像這樣的函數通常具備很是好的可測試性,對它作單元測試方便、且不會出問題,咱們須要作的就只是傳參數進去,而後檢查返回結果。對於不純的函數,例如某個函數 Foo() ,它依賴了一個有反作用的函數 Bar() ,那麼 Foo() 也變成了一個有反作用的函數,最終,反作用可能會遍及整個系統。

參考資料:www.toptal.com/qa/how-to-w…

相關文章
相關標籤/搜索