前言
測試驅動開發(TDD)是我一直想要嘗試和使用開發方法,但是直至今天才有機會第一次将其應用到正式開發階段。
從開始的模糊,到慢慢了解如何使用,再到借助它将邏輯捋的越來越清楚,再到之後每次跑完所有測試帶給我的信心,我知道這就是我想要的,開發過程再也不是碰運氣,我擁有了使用代碼測試代碼的能力。
因為是不完全從測試驅動開發,本片文章有所不準确的地方也請大家指正。
感謝我的團隊~
導讀
在所有開始之前,需要給大家介紹一些簡要的關于TDD的知識,大家可以從如下位址了解到什麼是TDD以及為什麼需要TDD:
- 維基百科 - 測試驅動開發
- 維基百科 - Test-driven development
- 讀《推行TDD的思考》有感
- 你今天寫了自動化測試嗎
本片文章以一個假設的需求為切入點 - 資料倉庫設計(DataRepository),從如下角度來踐行TDD:
- 介紹JUnit、Mock與PowerMock
- 配置環境
- 資料倉庫設計思路
- 資料倉庫測試開發思路
- 帶來的好處與缺陷
該篇文章僅設計邏輯測試部分,并不涉及UI測試,請提前知曉。
介紹JUnit、Mock與PowerMock
在Java的世界中,TDD的基礎是單元測試,而Junit就是一個非常強大的單元測試庫。
當我們初建一個Android項目時,Android Studio就已經幫我們準備了一些專門用于單元測試的目錄,一個空項目如下所示:
UnitTestDemo
- app
- src
- androidTest
- main
- test
其中,
test
與
main
兩個目錄是我們這次主要關心的:
-
:目錄專門用于測試UI邏輯androidTest
-
目錄專門用于編寫項目源碼main
-
目錄用門用于測試業務邏輯代碼test
在此處Junit就不詳細介紹了,更具體的可以參看這裡:
https://zh.wikipedia.org/wiki/JUnit
https://junit.org/junit5/
在這個部分,把關注點放在Mock與PowerMock上,之是以這麼說是因為Junit為我們提供了測試代碼的可能性,但是當項目依賴于其他子產品時,我們可以借助Mock來模拟依賴的類,來控制我們的測試流程。
當我們處于Android環境時更是如此,當我們需要依賴
Handler
、
Broadcast
或者其他與環境相關的代碼時,如果不去Mock它,别說測試了,連運作恐怕都運作不起來。
如果說Junit給了我們汽車,那麼Mock與PowerMock就是給了我們飛機。
上文中經常提到的Mock實際上指的是Mockito架構,在首頁中他是這麼介紹自己的:
Tasty mocking framework for unit tests in Java (美味可口的模拟測試架構 - Java)
Mocktio為我們提供了這樣一種能力:模拟一個類,不真實的執行它,而是模拟執行并傳回我們想要的資料,且可以去驗證類的行為。
下面是它官網的一段執行個體,展示了上面我們說到的能力:模拟、執行、傳回、驗證。
import static org.mockito.Mockito.*;
// mock creation
// 模拟一個清單
List mockedList = mock(List.class);
// using mock object - it does not throw any "unexpected interaction" exception
// 執行某些行為
mockedList.add("one");
mockedList.clear();
// stubbing appears before the actual execution
// 模拟傳回值
when(mockedList.get(0)).thenReturn("first");
// selective, explicit, highly readable verification
// 驗證行為是否被執行到
verify(mockedList).add("one");
verify(mockedList).clear();
// the following prints "first"
// 下面會列印出first,與上面模拟傳回的一緻
System.out.println(mockedList.get(0));
切換到Android場景中,我們可以做到下面做種樣子,驗證
Handler
的
post
是否被執行;模拟
Handler
的
post
方法執行,驗證
Runnable
的
run
方法是否被調用等。
@Test
public void testHandler() {
Handler handler = mock(Handler.class);
// 驗證Handler的post方法是否被執行了
handler.post(new Runnable() {
@Override
public void run() {
}
});
verify(handler).post(any(Runnable.class));
// 模拟post方法執行,并驗證run方法有沒有被執行
when(handler.post(any(Runnable.class))).thenAnswer(new Answer() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
Runnable runnable = invocation.getArgument(0);
runnable.run();
return null;
}
});
Runnable spy = spy(new Runnable() {
@Override
public void run() {
}
});
handler.post(spy);
// 驗證run方法是否被執行
verify(spy).run();
}
雖然Mockito已經很強大了,但是它還是有不能做到的事情,它不能模拟類中的靜态方法、私有方法、final方法,于是就有了衍生架構PowerMockito。
雖然PowerMockito僅有2000不到的Star,但是它确實還挺好用。
// 被測試的類與靜态方法
public class Static {
public static boolean isPass() {
return false;
}
}
// 使用PowerMockito測試靜态方法
@Test
public void testStaticMethod() {
PowerMockito.mockStatic(Static.class);
when(Static.isPass()).thenReturn(true);
assertThat(true, is(Static.isPass()));
}
雖然PowerMockito很強大,但是還是不要過多使用,尤其是針對私有方法與final方法。
PowerMockito更多使用方法請檢視這裡與這裡。
配置環境
Mockito與PowerMockito的環境配置也很簡單,在Android Studio工程中加入如下依賴:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
testImplementation 'junit:junit:4.12'
testImplementation 'org.mockito:mockito-core:2.8.9'
testImplementation 'org.hamcrest:hamcrest-all:1.3'
testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
}
注意:mockito-core的最近版本是2.18.3,但是請不要随意更新,因為目前powermock還未相容最新版本。
在編寫測試類時,還需要在類頭部加上
@Runwith(PowerMockRunner.class)
與
@PrepareForTest({xxx.class})
等注解,告訴Junit與PowerMock目前的運作環境與想要模拟的含有靜态方法的類。
更詳細的關于配置
PowerMockito
的文檔請檢視這裡。
開始之前
在開始TDD之前,讓我們再看一下要良好的踐行TDD都需要注意哪些? (可參閱這裡)
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNvwVZ2x2bzNXak9CX90TQNNkRrFlQKBTSvwFbslmZvwFMwQzLcVmepNHdu9mZvwFVywUNMZTY18CX052bm9CX90TUZFDaXF2bwhlWwpkMMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2LcRHelR3LcJzLctmch1mclRXY39zMwIjMwgDM1ETMxUDM4EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
紅
首要需求分析,将需求分解為任務清單,再從清單中挑選一個任務,轉換成一組測試用例,然後不斷循環去實作。
綠
快速的讓測試用例變綠。
重構
識别壞味道,進行重構。
接下來着手學習TDD吧。
資料倉庫(DataRepository)設計
核心的需求點如下:
- 能夠多級緩存複用資料(記憶體、檔案、網絡)
- 能夠同步、異步擷取資料
對外API設計:
- 同步全量擷取視訊資訊
List<VideoInfo> getAllVideoSync();
- 異步全量擷取視訊資訊
List<VideoInfo> getAllVideoAsync();
邏輯層中 :
- 擷取資料時,需要按照先記憶體、再檔案緩存、最後網絡資料的順序來擷取資料。
在本篇文章中,我們從任務清單中選取同步全量擷取視訊資訊這個任務,并轉化成為一組測試用例:
- 擷取SDK執行個體
- 調用
方法getAllVideoSync()
- 從記憶體中擷取資料
- 先從記憶體拿資料,為空再從檔案緩存中擷取資料
- 先從記憶體拿資料,為空再從檔案緩存中拿資料,為空再從網絡中擷取資料
- 校驗網絡資料是否被存儲到檔案緩存中
- 校驗資料是否正确
做完用例分解後,就讓我們一步一步按照“紅 - 綠 - 藍”的節奏來編寫用例與邏輯吧。
資料倉庫(DataRepository) - 用例編寫與實作
在Demo中,每完成一個Case的編寫與實作都會Commit一次,我會盡量做到完善。
讓我們踏上征途吧。
CI - 擷取SDK執行個體
先給我們的SDK起個好聽的名字
DataRepository
,再把它做成一個單例。
再考慮下如何驗證有效性!隻需要斷言判定
getInstance()
擷取的資料不為空就好了。
Commit記錄
CI - 調用 getAllVideoSync()
方法
getAllVideoSync()
在
DataRepository
中建立了
getAllVideoSync()
方法并建立了
VideoInfo
類。
由于本次的目的隻是驗證
getAllVideoSync()
方法是否能夠正确調用,是以我們不關心傳回結果。
Commit記錄
CI - 從記憶體中擷取資料
本次想要從記憶體中擷取緩存資料,是以關心傳回結果。
可以直接在TestCase中直接給
DataRepository
中
mAllVideoInfo
指派,來達到模拟記憶體緩存值存在的情況。
Commit記錄
CI - 先從記憶體拿資料,為空再從檔案緩存中擷取資料
從這一個Case開始就變得複雜一些了。雖然Commit記錄中詳細的寫明了我都做了什麼,但是還是簡要介紹一下做這些操作的思路。
步驟一:重構了擷取
DataRepository
的方法,原因是每次都寫一下擷取邏輯很麻煩,還不如封裝一下。
步驟二: 由于驗證的行為是記憶體資料為空,但是緩存值存在,那麼就需要修改驗證結果。
// 驗證檔案緩存值存在,擷取結果不為null
List<VideoInfo> userInfoList = instance.getAllVideoSync();
assertThat(userInfoList, is(nullValue()));
步驟三:運作單測之後自然是紅色錯誤,我們轉而進入
DataRepository
的
getAllVideoSync
内部去實作擷取緩存的邏輯。 自然而然,我們期望有一個幫助類能夠直接拿到緩存的結果,從那個檔案拿我們并不關心,于是我們有了
FileCacheHelper.getAllVideoCache()
,在編寫邏輯之後,代碼如下:
public List<VideoInfo> getAllVideoSync() {
// 記憶體緩存存在時直接傳回
if (mAllVideoInfo != null) {
return mAllVideoInfo;
}
// 擷取檔案緩存,檔案緩存存在時指派給記憶體緩存并傳回資料
mAllVideoInfo = mFileCacheHelper.getAllVideoCache();
if (mAllVideoInfo != null) {
return mAllVideoInfo;
}
return mAllVideoInfo;
}
此時是連編譯都無法通過的,原因是還沒有
mFileCacheHelper
這個變量,而且連
FileCacheHelper
類也沒有建立,此外,
FileCacheHelper
也不能直接在
UserInfoManager
中建構出來,如果要建構,那麼注意力就轉移到
FileCacheHelper
的實作上了。
當建立完
FileCacheHelper
以及
getAllVideoCache
方法,再回到測試用例中。 由于本階段并不關心
FileCacheHelper
類的真是邏輯如何,是以
mock
來模拟一個
FileCacheHelper
的執行個體對象,并且希望它的
getAllVideoCache()
方法預設傳回
null
:
// 模拟一個FileCacheHelper執行個體
@Mock
FileCacheHelper mFileCacheHelper;
// Refactor Get Instance Method
private DataRepository getNewInstance() {
...
// 讓FileCacheHelper.getAllUserInfoCache預設傳回null
when(mFileCacheHelper.getAllVideoCache()).thenReturn(null);
return instance;
}
再回到
getAllVideoSync_memoryCacheNull_diskCacheExist
單元測試中,由于是驗證檔案緩存存在的情況,是以期望
mFileCacheHelper.getAllVideoCache()
傳回一個有效值:
// 假設邏輯調用mFileCacheHelper.getAllVideoCache()時,傳回一個空清單
when(mFileCacheHelper.getAllVideoCache()).thenReturn(new ArrayList<UserInfo>());
完成這一步,單元測試就能跑通了,而
getAllVideoSync()
中關于檔案緩存擷取的邏輯也完成了。
Commit記錄 - 重構擷取
DataRepository
的方法
Commit記錄 - 實作檔案緩存邏輯
CI - 先從記憶體拿資料,為空再從檔案緩存中拿資料,為空再從網絡中擷取資料
這裡的步驟和上面一個非常類似,是以就不重複表述了。
從這兩個Case中可以知道,完全可以在僅關心
DataRepository
的情況下,把邏輯補充完整,暫時不關心的
FileCacheHelper
與
NetHelper
可以暫放一邊,通過模拟它們來跑通邏輯。
相信不用我說,你也一定知道這多麼有用處。
Commit記錄
CI - 校驗網絡資料是否被存儲到檔案緩存中
為了確定模拟的
FileCacheHelper
類中的方法被調用,可以使用如下的方法:
// 驗證行為,儲存到緩存中是否被執行了
verify(mFileCacheHelper).saveDataToCache(list);
Commit記錄
好處與缺陷
如果仔細看完上面資料倉庫的例子,相信你對測試驅動開發一定有一些認識了,下面就談談它的好處與難點。
好處:
- 給予開發者信心,讓你知道自己寫的代碼是可靠的
- 提升對項目需求分析、分解任務、安排優先級的能力
- 提高重構的能力
- 将焦點聚集在目前關注的地方
難點:
1. 思維方式的轉變 - “很多人不懂“意圖式程式設計”,總是習慣先實作一個東西,再去調用它。而測試先行就要求先使用,再實作。這樣能少走很多彎路,減少返工。”
2. 測試架構的選型與使用,雖然Mockito與PowerMockito已經很簡單了,但是還是有一些學習成本的。
最後引用一段話,也是我想說的:
最後我想說:
TDD不是銀彈,不可能适合所有的場景,但這不應該成為我們拒絕它的理由。
也不要輕易否定TDD,如果要否定,起碼要在認真實踐過之後。
最後,祝好~