示例代碼太少,以后會逐漸補上。
目錄:
- 綜述
- 單元測試時所面臨的問題
- 依賴隔離
- 依賴隔離的例子
- 交互測試
- 單元測試框架
- 快捷實現用於測試接口的框架(Mockito)
- 做好以上准備后
- 重構與單元測試
- 修復BUG或添加新功能的單元測試
- 獲得接口的幾種方法(基於值和狀態的測試)
- 一些補充
綜述
如果你查過一些關於單元測試的資料,你可能會和我一樣發現一個問題。有一些文章在說到單元測試的時候,提到了要做邊界測試,要考慮各種分支;也有一些文章則說的是修改原有代碼,例如依賴隔離;還有一些文章說的是測試的框架的使用,例如 Junit 。那么它們之間有着什么樣的聯系呢?
最開始,我們可能更關注邊界測試和分支測試。但遺憾的是,這方面的資料相對來說較少。更多的是依賴隔離這類的文章。為什么?
因為有很多代碼是無法被測試的。
能夠被測試的代碼需要滿足某些條件。你可能會覺得很麻煩,做單元測試還要為了滿足這些條件去修改原來的代碼。事實上,滿足這些條件能使你的代碼變得健壯。如果你寫的代碼是無法被測試的,那么你的首要任務就是將它們重構為可測試的單元。要想知道如何寫出可測試的代碼,就得了解 Mock 和 Stub 。這也就是你在看一些加減乘除單元測試例子之后,仍然不知道怎么測試自己的代碼的原因。(每次看到這樣的文章就好氣啊_(:з」∠)_)
但是即使重構了,還有一個問題。你總得寫代碼來執行對其他代碼進行測試吧?這部分的代碼可能很復雜,也可能變得難以維護。於是測試框架就出現了,幫你減輕做測試的負擔。
簡單說,它們三者之間的關系是:先重構已有代碼,使其成為可測試的單元,為接下去的測試做准備。接着寫出對這些單元進行測試的代碼,驗證結果。為了使測試代碼易於編寫和維護,借助測試框架。
單元測試時所面臨的問題
為了使代碼可被測試,需要對其進行重構。在這個過程中會遇到一些問題:
- 一個類的方法里包含了
其他類
的方法,怎么測試? - 如果代碼依賴於 Web 服務,例如請求某個網站的數據,怎么測試?
- 一個類的方法里包含了
該類的其他方法
,要怎么測試? - 一個類的方法有很多個對數據處理的步驟,是要測試最終結果,還是要對每個處理的步驟可能出現的問題進行測試?
- 一個方法沒有返回值(即 void )怎么辦?——交互測試
對於前兩個問題,可以用依賴隔離來解決。《單元測試的藝術》的3.1有個用來理解依賴隔離的例子:
航天飛機在送入太空之前,要先進行測試,否則飛到一半出了問題怎么辦?
而有一部分測試是確保宇航員已經准備好進入太空。但是你又不能讓宇航員真的坐實際的航天飛機去測試。有個辦法就是建造一套仿真系統,模擬航天飛機控制台的環境。輸入某種特定的外部環境,然后看宇航員是否執行了正確的操作。
在這個例子中,通過模擬外部環境來解除了對實際外部環境(航天飛機進入太空)的依賴。同樣的思路可以用到測試中。
依賴隔離
先從寫出能夠測試的代碼開始說起吧。
參考文章:Android單元測試 - 如何開始?
這里的依賴指的是:當前 類A 需要調用 類B 的方法,則說 A 依賴於 B 。
隔離方法:
- 將 B 改成一個 接口C 。
- 將 A 中的 B類 出現的位置替換為 接口C 。
A 和 B 隔離前后對比:
隔離前:A -> B
隔離后:A -> C -> B
在項目實際代碼以及測試代碼中使用不同的B:
- 在項目執行代碼中:傳入 類A 的對象是 接口C 的一個 派生類D (實現了 接口C )。 類D 是 項目中實際運行的代碼,提供了對接口的完整實現。A -> C -> D
- 在單元測試的代碼(獨立於項目執行代碼,發布軟件時要把這部分刪掉)中:傳入 A 的對象也是實現了 接口C 的一個 派生類E 。但是這個類與D不同,它提供的實現可能只是一個return。從而模擬(Mock)了派生類D的特定行為,使得在測試的時候,不需要使用D類。A -> C -> E
這樣做的好處是,一旦隔離完成,以后就不必大幅度修改A。在隔離的時候,要將所有依賴項改為從外部傳入。這就需要給類A添加一個set方法,傳入接口C的實現(implement),即上面的D和E。
依賴隔離的例子
類A:
public class Calculater {
public double divide(int a, int b) {
// 檢測被除數是否為0
if (MathUtils.checkZero(b)) {
throw new RuntimeException("divisor must not be zero");
}
return (double) a / b;
}
}
它調用了類B(MathUtils)的 checkZero 方法。於是我們說類A依賴於類B的 checkZero 方法。需要注意的是這個 MathUtils 不是從外部傳入的 。
類B是一個具體實現的類:
public class MathUtils {
public static boolean checkZero(int num) {
return num == 0;
}
}
在知道產生依賴之后,要將類B改成一個接口(方法名前綴I表示這是一個接口Interface):
public interface IMathUtils {
public boolean checkZero(int num);
}
在類A的代碼中,將B替換成該接口:
public class Calculater {
private IMathUtils mMathUtils = new MathUtils(); // 這里的代碼改動了
// 這里添加了set方法。向該類傳入了mathUtils
public void setMathUtils(IMathUtils mathUtils){
mMathUtils = mathUtils;
}
public double divide(int a, int b) {
if (mMathUtils.checkZero(b)) { // 這里的代碼改動了,將靜態類改成對象
throw new RuntimeException("divisor must not be zero");
}
return (double) a / b;
}
}
之前的B是一個靜態類,不需要聲明,但改成接口后需要聲明。
接口的實現:
-
對於實際運行的代碼,需要一個類去實現 IMathUtils 接口,然后傳入 Calculater 。
修改類B:public class MathUtils implements IMathUtils{ public boolean checkZero(int num) { return num == 0; } }
-
對於用於測試的代碼,也需要一個類實現 IMathUtils 接口,然后傳入 Calculater 。但不同的是,這個類的實現可能只需添加一個 return 語句,不用細致實現。
總是正確的接口:public class FakeMathUtils implements IMathUtils{ public boolean checkZero(int num) { return true; } }
return的時候,可以設一個變量,方便配置不同取值,否則還得創建新的類。
public class FakeMathUtils implements IMathUtils{ public boolean isZero = true; public boolean checkZero(int num) { return isZero; } }
交互測試
如果一個特定的工作單元的最終結果是調用一個第三方對象(而不是返回某個值,即 void ),你就需要進行交互測試。
這個第三方對象不受你的控制。你無法查看它的源代碼,或者這不是你負責測試的部分。因此你只需確保傳給它的參數是正確的就可以了。
那么如何確保傳過去的參數是正確的?
在這之前,要確保已經依賴隔離。
假設接口為:
public interface IPerson {
...
public void doSomethingWithData(String data);
}
待測試類的某個方法:
public class A {
private String data = "";
...
public void methodA(IPerson person) {
...
person.doSomethingWithData(data);
}
public void setData(String data) {
this.data = data;
}
}
真正使用的 Person 類是如何實現的呢?假設我們無從得知。我們的任務是保證傳入的 data 是符合我們預期的。只要傳入的內容符合預期,那么就說明我們要測試的方法是沒問題的。
偽實現:
public class FakePerson implements IPerson {
private String data = "";
...
public void doSomethingWithData(String data) {
this.data = data;
}
public String getData() {
return data;
}
}
在調用 methodA 的時候,傳入 FakePerson 實例。
A test = new A();
test.setData("hahahaha");
IPerson fakePerson = new FakePerson();
test.methodA(fakePerson);
Assert.AssertEquals("hahahaha", fakePerson.getData());
偽對象 FakePerson 在被 測試類A 的 methodA 方法中調用,該方法會給偽對象傳入某個信息。
偽對象 FakePerson 不對該信息進行進一步處理,只是賦值給類成員變量存儲起來。
由於偽對象是從外部傳入的 test.methodA(fakePerson);
,因此可以直接在外部獲取存儲的信息 fakePerson.getData()
。在assert的時候,獲取該信息,查看是否和預期的一致。
參考:
《單元測試的藝術》第四章
單元測試框架
在測試之前,要創建一個專門用於測試的類。這個類的類名以Test
結尾。在類里面添加測試方法,測試方法名前面要加上 test ,接在 test 后面的是被測試的方法名。在該方法內做三件事:
- 測試之前需要准備的數據,例如 new 出要測試的類——
Setup
- 執行要測試的類的方法——
Action
- 最后添加 Assert 以驗證結果——
Verify
測試框架里的 AssertXxx 是什么玩意兒?
我們寫的測試代碼在運行的時候會產生一些結果,驗證這些結果是否符合預期的一個低效方法就是將這些結果輸出到控制台(Console)或者文件,然后你自己用眼睛一個個去對比。
如果你懶得去比呢?又或者說你對比的時候覺得沒錯,但是實際上是因為一個1
和l
的錯誤導致你沒有發現呢?
就讓 Assert 來幫你解決這些煩人的問題吧! Assert (中文為:斷言)就是讓你將預期的結果和程序運行的結果傳入它的方法里面,由它來替你做對比的事情。
例如一個測試結果是否相等的 Assert :
assertEquals(你自己算出的結果, 程序運行的結果);
如果兩個結果不同,即程序運行的結果不符合你的預期,那么它就會提示你這里出現了錯誤。
從此,你就從幾百甚至是幾萬條的測試代碼輸出的對比中解放出來,大大節約了時間。
有些文章標題看着像是介紹單元測試,實際上是介紹單元測試框架。測試框架(Junit,Nunit等)實際上是提供便於測試的方案的框架,學習這些內容是學習框架的結構,以及如何使用框架定義的各種 assert ,而不是學習單元測試的方法。這兩者要區分開來!!!!!
快捷實現用於測試接口的框架(Mockito)
對於剛才那個接口 IMathUtils ,我們可以不用再新建一個類去實現它,而是交給 Mockito 。
IMathUtils mathUtils = mock(IMathUtils.class); // 生成mock對象
when(mathUtils.checkZero(1)).thenReturn(false); // 這里是快捷實現。它告訴 Mockito :如果在下面代碼調用了mathUtils.checkZero()並傳入參數1
,那么就讓調用這個方法的地方返回false
這個值。
做好以上准備后
- 單元測試總體上需要做些什么?
- 只考慮代碼在最正確的操作和條件下能否得出正確的結果
- 在數據的邊界條件下能否得到正確的結果
- 代碼在所有可能的錯誤數據下能否給出錯誤提示或者不至於崩潰
- 如何消除依賴隔離
- 單元測試的任務(摘自:Java單元測試(Junit+Mock+代碼覆蓋率))
- 接口功能測試:用來保證接口功能的正確性。
- 局部數據結構測試(不常用):用來保證接口中的數據結構是正確的
- 比如變量有無初始值
- 變量是否溢出
- 邊界條件測試
- 變量沒有賦值(即為NULL)
- 變量是數值(或字符) 時
- 主要邊界:最小值,最大值,無窮大(對於 double 等)
- 溢出邊界(期望異常或拒絕服務):Min - 1,Max + 1
- 臨近邊界:Min + 1,Max - 1
- 變量是字符串時
- 應用上面提到的
字符變量
的邊界 - 空字符串
- 對字符串長度應用
數值變量
的邊界
- 應用上面提到的
- 變量是集合時
- 空集合(Empty)
- 對集合的大小應用
數值變量
的邊界 - 調整次序:升序、降序
- 變量有規律時
- 比如對於Math.sqrt,給出n^2-1,和n^2+1的邊界
- 所有獨立執行通路測試:保證每一條代碼,每個分支都經過測試
- 代碼覆蓋率
- 語句覆蓋:保證每一個語句都執行到了
- 判定覆蓋(分支覆蓋):保證每一個分支都執行到
- 條件覆蓋:保證每一個條件都覆蓋到 true 和 false (即 if 、 while 中的條件語句)
- 路徑覆蓋:保證每一個路徑都覆蓋到
- 相關軟件
- Cobertura:語句覆蓋
- Emma: Eclipse插件Eclemma
- 代碼覆蓋率
- 各條錯誤處理通路測試:保證每一個異常都經過測試
- Android 單元測試的任務(摘自:Android單元測試在蘑菇街支付金融部門的實踐)
- 所有的Model、Presenter/ViewModel、Api、Utils等類的public方法
- Data類除了getter、setter、toString、hashCode等一般自動生成的方法之外的邏輯部分
- 自定義View的功能:比如set data以后,text有沒有顯示出來等等,簡單的交互,比如click事件,負責的交互一般不測,比如touch、滑動事件等等。
- Activity的主要功能:比如view是不是存在、顯示數據、錯誤信息、簡單的點擊事件等。比較復雜的用戶交互比如onTouch,以及view的樣式、位置等等可以不測。因為不好測。
重構與單元測試
在單元測試前要重構,在重構前要編寫集成測試。
集成測試 ——> 重構 ——> 單元測試
重構的過程中,每次只做少量的改動。盡可能多的運行集成測試,以此了解重構是否使得系統原有的功能被破壞。
要點:關注系統中你需要修復或者添加功能的部分,不要在其他部分浪費精力。其他部分等到需要處理的時候再考慮。
修復 BUG 或添加新功能的單元測試
先編寫一個單元測試,這個測試針對於這個 BUG 。由於它是一個 BUG ,所以顯然這個單元測試一開始給出的結果會是失敗的。此時你修復 BUG ,並運行測試。如果測試成功,則表示你成功修復了這個 BUG ;如果測試失敗,則表示 BUG 仍然存在。
換句話說,這個單元測試暴露了這個 BUG 。 BUG 本來沒看出來,而這個單元測試的失敗表明了 BUG 的存在。
添加新功能也是同樣。寫出一個會失敗的測試,表示缺少這個功能。然后通過修改代碼使得測試通過,就表明你成功添加了新功能。
獲得接口的幾種方法(基於值和狀態的測試)
在本篇的 MathUtils 例子中,通過setMathUtils()傳入 IMathUtils 的實現。這是通過 getter 和 setter 對類的成員變量操作的方法。這種方法稱為依賴/屬性注入。除此之外,還有其他方法。
- 在方法調用點注入偽對象(《單元測試的藝術》3.4.6)
這種方法與屬性注入需要先獲取實例再傳入不同,它通過在構造函數里使用工廠方法獲取實例。- 方案一:工廠類
在被測試類的構造方法里執行了靜態的工廠方法。不過工廠方法執行之前,通過 setter 傳入用於測試的接口實現。這種方法與屬性注入的不同之處在於,將 set 方法移入另外創建的工廠類。在測試的時候你完全不需要管被測試類,只需要對工廠類進行操作就可以。
需要注意什么問題?你需要了解什么代碼會在什么時候調用這個工廠,根據時機 set 進所需的工廠實現。 - 方案二:本地工廠方法
不使用工廠類,而是在被測試類里新建一個工廠方法。將被測試類設置為抽象類,完整地實現了除工廠方法外的所有方法,讓子類繼承並重寫工廠方法。測試時有測試的實現,實際運行時有運行的實現。
什么時候應該使用?模擬給被測試代碼的輸入。
什么時候不應該使用?測試代碼對服務的調用是否正確時。
當被測試代碼已經是依賴隔離或者應用了屬性注入的時候,不考慮。若沒有,則優先考慮。
- 方案一:工廠類
- 構造函數注入,賦值給類的成員變量
創建新的構造函數,或者在原有構造函數上添加參數。如果類需要注入多個依賴,則會降低代碼的可讀性和可維護性(構造函數的參數個數可能變化的通病)。- 優化方案一:參數對象重構。將參數整合為一個對象,傳入該對象。
- 優化方案二:控制反轉。控制反轉的一個例子是 JAVA 的反射機制,根據類名生成對象。控制反轉可以看做是將工廠方法中的生成對象的代碼改到 XML 文件中。
- 什么時候使用?第一:使用控制反轉容器的框架。第二:想告訴 API 使用者這些參數是必須的(如果是可選的,則使用 getter 和 setter )。
- 需要注意什么問題?大多數人不知道什么是控制反轉原則。這意味着你一旦寫出方案二這樣的代碼,就需要在別人不懂的時候教他。
- 把參數放到需要被測試的方法的參數列表里
一些補充
-
應該對哪些代碼編寫單元測試?哪些代碼不太需要編寫單元測試?
不經常改動的代碼,特別是底層核心的代碼需要編寫單元測試。經常改動的代碼就不太需要編寫單元測試。畢竟你剛寫完單元測試不久,整個代碼就被修改了,你得再重新編寫單元測試。 -
Mock/Stub
Mock 和 Stub 是兩種測試代碼功能的方法。 Mock 測重於對功能的模擬。 Stub 測重於對功能的測試重現。比如對於 List 接口, Mock 會直接對 List 進行模擬( assert 寫在調用 List 的 test 方法里面);而 Stub 會新建一個實現了 List 的 TestList ,在其中編寫測試的代碼( assert 寫在這個 TestList 里面)。《單元測試的藝術》4.2Stub 不會使測試失敗,僅僅是用來模擬各種場景。 Mock 類似 Stub ,但它還能使用 assert 。
優先選擇 Mock 方式,因為 Mock 方式下,模擬代碼與測試代碼放在一起,易讀性好,而且擴展性、靈活性都比 Stub 好。但需要注意,一個測試有多個 Stub 是可行的,但有多個 Mock 就會產生麻煩,因為多個 Mock 對象說明你同時測試了多個事情。編寫測試代碼時,不對 Stub 進行 assert ,而是統一到最后由 Mock 進行 assert 。如果你對明顯是用做 Stub 的偽對象進行了斷言,這屬於過度指定。《單元測試的藝術》4.5
如果在一個單元測試中,驗證了多個點,你可能無法知道到底是哪個點出了錯。應該盡可能分離。
-
想做單元測試結果做成集成測試
如果既要請求網絡,又要保存數據,還要顯示界面,那就是集成測試了。 -
在使用斷言確認字符串的時候,應該把整個預期字符串都寫上么?
在《重構:改善既有代碼的設計》里面,有個測試讀取文件的例子。 -
單元測試框架中的setup()
- setup()方法應該初始化所有測試方法都需要的對象。至於只有某個測試方法用到的對象,交給這個測試方法來初始化。
- 防止過度重構setup()方法。在重構時征求同伴的意見。
- 不要在setup中准備偽對象。
-
還需要注意什么?
- 編寫測試時,要時刻考慮到閱讀測試的人。想象一下他們第一次讀到代碼時的情形,確保他們不會生氣。
- 一個單元測試方法不能調用另外一個單元測試方法。如果想刪除重復代碼,那就抽取共同的代碼到另一個方法中。
- 隔離測試的方法:把你當前正在寫的測試當做系統中唯一的一個測試。但是要注意,你必須把單元測試可能修改的狀態恢復到初始值。
- 最安全的做法:每個測試使用一個單獨的對象實例。
- 想要進行流測試,最好使用某種集成測試框架。
- 斷言和操作需要分離。在斷言的參數里面,只傳入最終結果,其方法調用過程需要分離開。
參考:
-
關於 Android 單元測試的一切 <-在頁面里搜索該標題