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

NO IMAGE

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

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

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

通常一個單元測試主要有三個行為:

  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. 代碼簡潔清晰
    我們會針對一個單元寫多個測試用例,因此我們希望用盡量簡潔的代碼覆蓋到所有的測試用例。

  2. 可讀性強
    測試方法的名稱應該直截了當地表明測試內容和意圖,如果測試失敗了,我們可以簡單快速地定位問題。通過良好的單元測試,我們可以無需通過debug,打斷點的方式來修復bug。

  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…

相關文章

從零開始開發IM(即時通訊)服務端(二)

從零開始開發IM(即時通訊)服務端

MySQL索引的原理,B+樹、聚集索引和二級索引的結構分析

數據庫索引的優化及SQL處理過程