單元測試在一個完整的軟體開發流程中是必不可少的、非常重要的一個環節。通常寫單元測試並不難,但有的時候,有的程式碼和功能難以測試,導致寫起測試來困難重重。因此,寫出良好的可測試的(testable)程式碼是非常重要的。接下來,我們簡要地討論一下什麼樣的程式碼是難以測試的,我們應該如何避免寫出難以測試的程式碼,以及要寫出可測試性強的程式碼的一些最佳實踐。
什麼是單元測試(unit test)?
在計算機程式設計中,單元測試(英語:Unit Testing)又稱為模組測試, 是針對程式模組(軟體設計的最小單位)來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在過程化程式設計中,一個單元就是單個程式、函式、過程等;對於物件導向程式設計,最小單元就是方法,包括基類(超類)、抽象類、或者派生類(子類)中的方法。
通常一個單元測試主要有三個行為:
- 初始化需要測試的模組或方法。
- 呼叫方法。
- 觀察結果(斷言)。
這三個行為分別被稱為Arrange, Act and Assert。以java為例,一般測試程式碼如下:
@Test
public void isPalindrome() {
//初始化:初始化需要被測試的模組,這裡就是一個物件。
//也可能沒有初始化模組,例如測試一個靜態方法。
PalindromeDetector detector = new PalindromeDetector();
//呼叫方法:記錄返回值,以便後續驗證。
//如果方法無返回值,那麼我們需要驗證它在執行過程中是否對系統的其他部分造成了影響,或產生了副作用。
boolean isPalindrome = detector.isPalindrome("kayak");
//斷言:驗證返回結果是否和預期一致。
Assert.assertTrue(isPalindrome);
}
複製程式碼
單元測試和整合測試的區別
單元測試的目的是為了驗證顆粒度最小的、獨立單元的行為,例如一個方法,一個物件。通過單元測試,我們可以確保這個系統中的每個獨立單元都正常工作。單元測試的範圍僅僅在這個獨立單元中,不依賴其他單元。而整合測試的目的是驗證整個系統在真實環境下的功能行為,即將不同模組組合在一起進行測試。整合測試通常需要將專案啟動起來,並且可能會依賴外部資源,例如資料庫,網路,檔案等。
良好的單元測試的特點
-
程式碼簡潔清晰
我們會針對一個單元寫多個測試用例,因此我們希望用盡量簡潔的程式碼覆蓋到所有的測試用例。 -
可讀性強
測試方法的名稱應該直截了當地表明測試內容和意圖,如果測試失敗了,我們可以簡單快速地定位問題。通過良好的單元測試,我們可以無需通過debug,打斷點的方式來修復bug。 -
可靠性強
單元測試只在所測的單元中真的有bug才會不通過,不能依賴任何單元外的東西,例如全域性變數、環境、配置檔案或方法的執行順序等。當這些東西發生變化時,不會影響測試的結果。 -
執行速度快
通常我們每一次打包都會執行單元測試,如果速度非常慢,影響效率,也會導致更多人在本地跳過測試。 -
只測試獨立單元
單元測試和整合測試的目的不同,單元測試應該排除外部因素的影響。
如何寫出可測試的程式碼
我們從一個簡單的例子開始探討這個問題。我們正在編寫一個智慧家居控制器的程式,其中一個需求是在夜晚觸控到檯燈時自動開燈。我們通過以下方法來判斷當前時間:
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),速度也可能比較慢。其次,這個方法違反了幾個原則:
-
方法和資料來源緊耦合在了一起
時間這個輸入無法通過其他的資料來源得到,例如從檔案或者資料庫中獲取時間。 -
違反了單一職責原則(Single Responsibility Principle)
SRP是指每一個類或者方法應該有一個單一的功能。而這個方法具有多個職責:1. 從某個資料來源獲取時間。 2. 判斷時間是早上還是晚上。SRP的一個重要特點是:一個類或者一個模組應該有且只有一個改變的原因,在上述程式碼中,卻有兩個原因會導致方法的修改:1. 獲取時間的方式改變了(例如改成從資料庫獲取時間)。 2. 判斷時間的邏輯改變了(例如把從6點開始算晚上改成從7點開始)。 -
方法的職責不清晰
方法簽名 String getTimeOfDay() 對方法職責的描述不清晰,使用者如果不進入這個api檢視原始碼,很難了解這個api的功能。 -
難以預測和維護
這個方法依賴了一個可變的全域性狀態(系統時間),如果方法中含有多個類似的依賴,那在讀這個方法時,就需要檢視它依賴的這些環境變數的值,導致我們很難預測方法的行為。
簡單改進
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() 也變成了一個有副作用的函式,最終,副作用可能會遍佈整個系統。