文章中的所有代碼在此:https://github.com/geniusmart/LoveUT ,由于 Robolectric 3.0 和 3.1 版本(包括后續3.x版本)差異不小,該工程中包含這兩個版本對應的測試用例 Demo 。
一 閑話單元測試
我們經常講“前人種樹,后人乘涼”,然而在軟件開發中,往往呈現出來的卻是截然相反的景象,我們在績效和指標的驅使下,主動或被動的留下來大量壞味道的代碼,在短時間內順利的完成項目,此后卻花了數倍于開發的時間來維護此項目,可謂“前人砍樹,后人遭殃”,諷刺的是,砍樹的人往往因為優秀的績效,此時已經步步高升,而遭殃的往往是意氣風發,步入職場的年輕人,如此不斷輪回。所以,為了打破輪回,從一點一滴做起吧,“樹”的種類眾多,作為任意一名普通的軟件工程師,種好單元測試這棵樹,便是撒下一片蔭涼。
關于單元測試,很多人心中會有以下幾個疑問:
(1)為什么要寫?
(2)這不是QA人員該做的嗎?
(3)需求天天變,功能都來不及完成了,還要同時維護代碼和UT,四不四傻啊?
(4)我要怎么寫UT(特別是Android單元測試)?
- 關于第一個問題,首先我們反問自己幾個問題:
-
(1)我們在學習任何一個技術框架,比如 retofit2 、 Dagger2 時,是不是第一時間先打開官方文檔(或者任意文檔),然后查閱api如何調用的代碼,而官方文檔往往都會在最醒目的地方,用最簡潔的代碼向我們說明了api如何使用?
其實,當我們在寫單元測試時,為了測試某個功能或某個api,首先得調用相關的代碼,因此我們留下來的便是一段如何調用的代碼。這些代碼的價值在于為以后接手維護/重構/優化功能的人,留下一份程序猿最愿意去閱讀的文檔。
(2)當你寫單元測試的時候,是不是發現很多代碼無法測試?撇開對UT測試框架不熟悉的因素之外,是不是因為你的代碼里一個方法做了太多事情,或者代碼的封裝性不夠好,或者一個方法需要有其他很多依賴才能測試(高耦合),而此時,為了讓你的代碼可測試,你是不是會主動去優化一下代碼?
(3)是不是對重構沒信心?這個話題太老生常談了,配備有價值的、高覆蓋率的單元測試可解決此問題。
(4)當你在寫Android代碼(比如網絡請求和DB操作)的時候,是如何測試的?跑起來整個App,點了好幾步操作后,終于到達要測試的功能,然后巨慢無比的Debug?如果你寫UT,并使用Robolectric這樣的框架,你不僅可以脫離Android環境對代碼進行調試,還可以很快速的定位和Debug你想要調試的代碼,大大的提升了開發效率。
以上,便是寫好單元測試的意義。
關于第二個問題,己所不欲勿施于人
我始終覺得讓QA寫UT,是一種傻叉的行為。單元測試是一種白盒測試,本來就是開發分內之事,難道讓QA去閱讀你惡心的充滿壞味道的代碼,然后硬著頭皮寫出UT?試想一下,你的產品經理讓你畫原型寫需求文檔,你的領導讓你去市場部輔助吹噓產品,促進銷售,你會不會有種吃了翔味巧克力的感覺?所以,己所不欲勿施于人。這個問題有點頭疼,總之,盡量提高我們的代碼設計和寫UT的速度,以便應對各種不合理的需求和項目。
前面三個問題,或多或少是心態的問題,調整好心態,認可UT的優點,嘗試走第一步看看。而第四個問題,如何寫?則是筆者這系列文章的核心內容,在我的第一篇《Robolectric3.0(一)》中已經介紹了這個框架的特點,環境搭建,三大組件(Activity、Bordercast、Service)的測試,以及Shadow的使用,這篇文章,主要介紹網絡請求和數據庫相關的功能如何測試。
二 日志輸出
Robolectric對日志輸出的支持其實非常簡單,為什么把它單獨列一個條目來講解?因為往往我們在寫UT的過程,其實也是在調試代碼,而日志輸出對于代碼調試起到極大的作用。我們只需要在每個TestCase的setUp()里執行ShadowLog.stream = System.out即可,如:
@Before
public void setUp() throws URISyntaxException {
//輸出日志
ShadowLog.stream = System.out;
}
此時,無論是功能代碼還是測試代碼中的 Log.i()之類的相關日志都將輸出在控制面板中,調試起功能來,簡直爽得不要不要的。
三 網絡請求篇
關于網絡請求,筆者采用的是retrofit2的2.0.0-beta4版本,api調用有很大的變化,詳情請參考官方文檔。Robolectic支持發送真實的網絡請求,通過對響應結果進行測試,可大大的提升我們與服務端的聯調效率。
以github api為例,網絡請求的代碼如下:
public interface GithubService {
String BASE_URL = "https://api.github.com/";
@GET("users/{username}/repos")
Call<List<Repository>> publicRepositories(@Path("username") String username);
@GET("users/{username}/following")
Call<List<User>> followingUser(@Path("username") String username);
@GET("users/{username}")
Call<User> user(@Path("username") String username);
class Factory {
public static GithubService create() {
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build();
return retrofit.create(GithubService.class);
}
}
}
1. 測試真實的網絡請求
@Test
public void publicRepositories() throws IOException {
Call<List<Repository>> call = githubService.publicRepositories("geniusmart");
Response<List<Repository>> execute = call.execute();
List<Repository> list = execute.body();
//可輸出完整的響應結果,幫助我們調試代碼
Log.i(TAG,new Gson().toJson(list));
assertTrue(list.size()>0);
assertNotNull(list.get(0).name);
}
這類測試的意義在于:
- (1)檢驗網絡接口的穩定性
- (2)檢驗部分響應結果數據的完整性(如非空驗證)
- (3)方便開發階段的聯調(通過UT聯調的效率遠高于run app后聯調)
2. 模擬網絡請求
對于網絡請求的測試,我們需要知道確切的響應結果值,才可進行一系列相關的業務功能的斷言(比如請求成功/失敗后的異步回調函數里的邏輯),而發送真實的網絡請求時,其返回結果往往是不可控的,因此對網絡請求和響應結果進行模擬顯得特別必要。
那么如何模擬?其原理很簡單,okhttp提供了攔截器 Interceptors ,通過該api,我們可以攔截網絡請求,根據請求路徑,不進行請求的發送,而直接返回我們自定義好的相應的response json字符串。
首先,自定義Interceptors的代碼如下:
public class MockInterceptor implements Interceptor {
@Override
public Response intercept(Interceptor.Chain chain) throws IOException {
String responseString = createResponseBody(chain);
Response response = new Response.Builder()
.code(200)
.message(responseString)
.request(chain.request())
.protocol(Protocol.HTTP_1_0)
.body(ResponseBody.create(MediaType.parse("application/json"), responseString.getBytes()))
.addHeader("content-type", "application/json")
.build();
return response;
}
/**
* 讀文件獲取json字符串,生成ResponseBody
*
* @param chain
* @return
*/
private String createResponseBody(Chain chain) {
String responseString = null;
HttpUrl uri = chain.request().url();
String path = uri.url().getPath();
if (path.matches("^(/users/)+[^/]*+(/repos)$")) {//匹配/users/{username}/repos
responseString = getResponseString("users_repos.json");
} else if (path.matches("^(/users/)+[^/]+(/following)$")) {//匹配/users/{username}/following
responseString = getResponseString("users_following.json");
} else if (path.matches("^(/users/)+[^/]*+$")) {//匹配/users/{username}
responseString = getResponseString("users.json");
}
return responseString;
}
}
相應的resonse json的文件可以存放在test/resources/json/下,如下圖
再次,定義Http Client,并添加攔截器:
//獲取測試json文件地址
jsonFullPath = getClass().getResource(JSON_ROOT_PATH).toURI().getPath();
//定義Http Client,并添加攔截器
OkHttpClient okHttpClient = new OkHttpClient.Builder()
.addInterceptor(new MockInterceptor(jsonFullPath))
.build();
//設置Http Client
Retrofit retrofit = new Retrofit.Builder()
.baseUrl(GithubService.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(okHttpClient)
.build();
mockGithubService = retrofit.create(GithubService.class);
最后,就可以使用mockGithubService進行隨心所欲的斷言了:
@Test
public void mockPublicRepositories() throws Exception {
Response<List<Repository>> repositoryResponse = mockGithubService.publicRepositories("geniusmart").execute();
assertEquals(repositoryResponse.body().get(5).name, "LoveUT");
}
這種做法不僅僅可以在寫UT的過程中使用,在開發過程中也可以使用,當服務端的接口開發滯后于客戶端的進度時,可以先約定好數據格式,客戶端采用模擬網絡請求的方式進行開發,此時兩個端可以做到不互相依賴。
3. 網絡請求的異步回調如何進行測試
關于網絡請求之后的回調函數如何測試,筆者暫時也沒有什么自己覺得滿意的解決方案,這里提供一種做法,權當拋磚引玉,希望有此經驗的人提供更多的思路。
由于網絡請求和回調函數是在子線程和UI主線程兩個線程中進行的,且后者要等待前者執行完畢,這種情況要在一個TestCase中測試并不容易。因此我們要做的就是想辦法讓兩件事情同步的在一個TestCase中執行,類似于這樣的代碼:
//此為Retrofit2的新api,代表同步執行
//異步執行的api為githubService.followingUser("geniusmart").enqueue(callback);
githubService.publicRepositories("geniusmart").execute();
callback.onResponse(call,response);
//對執行回調后影響的數據做斷言
some assert...
這里我列舉一個場景,并進行相應的單元測試:一個Activity中有個ListView,經過網絡請求后,在異步回調函數里加載ListView的數據,點擊每一個item后,吐司其對應的標題。
public class CallbackActivity extends Activity {
//省略一些全局變量聲明的代碼
/**
* 定義一個全局的callback對象,并暴露出get方法供UT調用
*/
private Callback<List<User>> callback;
@Override
protected void onCreate(Bundle savedInstanceState) {
//省略一些初始化UI組件的代碼
listView.setOnItemClickListener(new AdapterView.OnItemClickListener(){
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Toast.makeText(CallbackActivity.this,datas.get(position),Toast.LENGTH_SHORT).show();
}
});
//加載數據
loadData();
}
public void loadData() {
progressBar.setVisibility(View.VISIBLE);
datas = new ArrayList<>();
//初始化回調函數對象
callback = new Callback<List<User>>() {
@Override
public void onResponse(Call<List<User>> call, Response<List<User>> response) {
for(User user : response.body()){
datas.add(user.login);
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(CallbackActivity.this,
android.R.layout.simple_list_item_1, datas);
listView.setAdapter(adapter);
progressBar.setVisibility(View.GONE);
}
@Override
public void onFailure(Call<List<User>> call, Throwable t) {
progressBar.setVisibility(View.GONE);
}
};
GithubService githubService = GithubService.Factory.create();
githubService.followingUser("geniusmart").enqueue(callback);
}
public Callback<List<User>> getCallback(){
return callback;
}
}
相應的測試代碼如下:
@Test
public void callback() throws IOException {
CallbackActivity callbackActivity = Robolectric.setupActivity(CallbackActivity.class);
ListView listView = (ListView) callbackActivity.findViewById(R.id.listView);
Response<List<User>> users = mockGithubService.followingUser("geniusmart").execute();
//結合模擬的響應數據,執行回調函數
callbackActivity.getCallback().onResponse(null, users);
ListAdapter listAdapter = listView.getAdapter();
//對ListView的item進行斷言
assertEquals(listAdapter.getItem(0).toString(), "JakeWharton");
assertEquals(listAdapter.getItem(1).toString(), "Trinea");
ShadowListView shadowListView = Shadows.shadowOf(listView);
//測試點擊ListView的第3~5個Item后,吐司的文本
shadowListView.performItemClick(2);
assertEquals(ShadowToast.getTextOfLatestToast(), "daimajia");
shadowListView.performItemClick(3);
assertEquals(ShadowToast.getTextOfLatestToast(), "liaohuqiu");
shadowListView.performItemClick(4);
assertEquals(ShadowToast.getTextOfLatestToast(), "stormzhang");
}
這樣做的話要改變一些編碼習慣,比如回調函數不能寫成匿名內部類對象,需要定義一個全局變量,并破壞其封裝性,即提供一個get方法,供UT調用。
注:經過后續研究,使用Mockito的Capture才是解決異步測試的最佳方案,后面考慮出專門文章來說明。
四 數據庫篇
Robolectric從2.2開始,就已經可以對真正的DB進行測試,從3.0開始測試DB變得更加便利,通過UT來調試DB簡直不能更爽。這一節將介紹不使用任何框架的DB測試,ORMLite測試以及ContentProvider測試。
1. 不使用任何框架的DB測試(SQLiteOpenHelper)
如果沒有使用框架,采用Android的SQLiteOpenHelper對數據庫進行操作,通常我們會封裝好各個Dao,并實例化一個SQLiteOpenHelper的單例對象,測試代碼如下:
@Test
public void query(){
AccountDao.save(AccountUtil.createAccount("3"));
AccountDao.save(AccountUtil.createAccount("4"));
AccountDao.save(AccountUtil.createAccount("5"));
AccountDao.save(AccountUtil.createAccount("5"));
List<Account> accountList = AccountDao.query();
assertEquals(accountList.size(), 3);
}
另外有一點要注意的是,當我們測試多個test時,會拋出一個類似于這樣的異常:
java.lang.RuntimeException: java.lang.IllegalStateException: Illegal connection pointer 37. Current pointers for thread Thread[pool-1-thread-1,5,main] []
解決方式便是每次執行一個test之后,就將SQLiteOpenHelper實例對象重置為null,如下:
@After
public void tearDown(){
AccountUtil.resetSingleton(AccountDBHelper.class, "mAccountDBHelper");
}
public static void resetSingleton(Class clazz, String fieldName) {
Field instance;
try {
instance = clazz.getDeclaredField(fieldName);
instance.setAccessible(true);
instance.set(null, null);
} catch (Exception e) {
throw new RuntimeException();
}
}
2. OrmLite測試
使用OrmLite對數據操作的測試與上述方法并無區別,同樣也要注意每次測試完后,要重置OrmLiteSqliteOpenHelper實例。
@After
public void tearDown(){
DatabaseHelper.releaseHelper();
}
@Test
public void save() throws SQLException {
long millis = System.currentTimeMillis();
dao.create(new SimpleData(millis));
dao.create(new SimpleData(millis + 1));
dao.create(new SimpleData(millis + 2));
assertEquals(dao.countOf(), 3);
List<SimpleData> simpleDatas = dao.queryForAll();
assertEquals(simpleDatas.get(0).millis, millis);
assertEquals(simpleDatas.get(1).string, ((millis + 1) % 1000) + "ms");
assertEquals(simpleDatas.get(2).millis, millis + 2);
}
3. ContentProvider測試
一旦你的App里有ContentProvider,此時配備完善和嚴謹的單元測試用例是非常有必要的,畢竟你的ContentProvider是對外提供使用的,一定要保證代碼的質量和穩定性。
對ContentProvider的測試,需要借助影子對象ShadowContentResolver,關于Shadow,我在上文中已經有介紹過,此處的Shadow可以豐富ContentResolver的行為,幫助我們進行測試,代碼如下:
@RunWith(RobolectricGradleTestRunner.class)
@Config(constants = BuildConfig.class)
public class AccountProviderTest {
private ContentResolver mContentResolver;
private ShadowContentResolver mShadowContentResolver;
private AccountProvider mProvider;
private String AUTHORITY = "com.geniusmart.loveut.AccountProvider";
private Uri URI_PERSONAL_INFO = Uri.parse("content://" + AUTHORITY + "/" + AccountTable.TABLE_NAME);
@Before
public void setUp() {
ShadowLog.stream = System.out;
mProvider = new AccountProvider();
mContentResolver = RuntimeEnvironment.application.getContentResolver();
//創建ContentResolver的Shadow對象
mShadowContentResolver = Shadows.shadowOf(mContentResolver);
mProvider.onCreate();
//注冊ContentProvider對象和對應的AUTHORITY
ShadowContentResolver.registerProvider(AUTHORITY, mProvider);
}
@After
public void tearDown() {
AccountUtil.resetSingleton(AccountDBHelper.class, "mAccountDBHelper");
}
@Test
public void query() {
ContentValues contentValues1 = AccountUtil.getContentValues("1");
ContentValues contentValues2 = AccountUtil.getContentValues("2");
mShadowContentResolver.insert(URI_PERSONAL_INFO, contentValues1);
mShadowContentResolver.insert(URI_PERSONAL_INFO, contentValues2);
//查詢所有數據
Cursor cursor1 = mShadowContentResolver.query(URI_PERSONAL_INFO, null, null, null, null);
assertEquals(cursor1.getCount(), 2);
//查詢id為2的數據
Uri uri = ContentUris.withAppendedId(URI_PERSONAL_INFO, 2);
Cursor cursor2 = mShadowContentResolver.query(uri, null, null, null, null);
assertEquals(cursor2.getCount(), 1);
}
@Test
public void queryNoMatch() {
Uri noMathchUri = Uri.parse("content://com.geniusmart.loveut.AccountProvider/tabel/");
Cursor cursor = mShadowContentResolver.query(noMathchUri, null, null, null, null);
assertNull(cursor);
}
@Test
public void insert() {
ContentValues contentValues1 = AccountUtil.getContentValues("1");
mShadowContentResolver.insert(URI_PERSONAL_INFO, contentValues1);
Cursor cursor = mShadowContentResolver.query(URI_PERSONAL_INFO, null, AccountTable.ACCOUNT_ID + "=?", new String[]{"1"}, null);
assertEquals(cursor.getCount(), 1);
cursor.close();
}
@Test
public void update() {
ContentValues contentValues = AccountUtil.getContentValues("2");
Uri uri = mShadowContentResolver.insert(URI_PERSONAL_INFO, contentValues);
contentValues.put(AccountTable.ACCOUNT_NAME, "geniusmart_update");
int update = mShadowContentResolver.update(uri, contentValues, null, null);
assertEquals(update, 1);
Cursor cursor = mShadowContentResolver.query(URI_PERSONAL_INFO, null, AccountTable.ACCOUNT_ID + "=?", new String[]{"2"}, null);
cursor.moveToFirst();
String accountName = cursor.getString(cursor.getColumnIndex(AccountTable.ACCOUNT_NAME));
assertEquals(accountName, "geniusmart_update");
cursor.close();
}
@Test
public void delete() {
try {
mShadowContentResolver.delete(URI_PERSONAL_INFO, null, null);
fail("Exception not thrown");
} catch (Exception e) {
assertEquals(e.getMessage(), "Delete not supported");
}
}
}
五 Love UT
寫UT是一種非常好的編程習慣,但是UT雖好,切忌貪杯,作為一名技術領導者,切忌拿測試覆蓋率作為指標,如此一來會滋生開發者的抵觸心理,導致亂寫一通。作為開發者,應該時刻思考什么才是有價值的UT,什么邏輯沒必要寫(比如set和get),這樣才不會疲于奔命且覺得乏味。其實很多事情都是因果關系,開發人員不寫,所以leader強制寫,而leader強制寫,開發人員會抵觸而亂寫。所以,讓各自做好,一起來享受UT帶來的高質量的代碼以及為了可測試而去思考代碼設計的編程樂趣。
本文的所有代碼仍然放在LoveUT這個工程里:
https://github.com/geniusmart/LoveUT
參考文章
http://square.github.io/retrofit/
https://github.com/square/okhttp/wiki/Interceptors
http://stackoverflow.com/questions/17544751/square-retrofit-server-mock-for-testing
https://github.com/robolectric/robolectric/issues/1890
最后,行此文時,悲痛欲絕,越長大越不會表達自己的情感,此文送給肚中遠去的小小猴子,此生無緣。無論你在哪個時空,作為一個技術從業者,將保持純良,求真,但行好事,希望能帶給你幸運。愿此坎之后,此生無坎。