Android单元测试框架Robolectric3.0介绍(二)
文章中的所有代碼在此: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即可,如:
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/";("users/{username}/repos")Call<List<Repository>> publicRepositories(("username") String username);("users/{username}/following")Call<List<User>> followingUser(("username") String username);("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 {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}/reposresponseString = getResponseString("users_repos.json");} else if (path.matches("^(/users/)+[^/]+(/following)$")) {//匹配/users/{username}/followingresponseString = getResponseString("users_following.json");} else if (path.matches("^(/users/)+[^/]*+$")) {//匹配/users/{username}responseString = getResponseString("users.json");}return responseString;} }相應的resonse json的文件可以存放在test/resources/json/下,如下圖
response的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進行隨心所欲的斷言了:
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;protected void onCreate(Bundle savedInstanceState) {//省略一些初始化UI組件的代碼listView.setOnItemClickListener(new AdapterView.OnItemClickListener(){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>>() {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);}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;} }相應的測試代碼如下:
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的單例對象,測試代碼如下:
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,如下:
2. OrmLite測試
使用OrmLite對數據操作的測試與上述方法并無區別,同樣也要注意每次測試完后,要重置OrmLiteSqliteOpenHelper實例。
public void tearDown(){DatabaseHelper.releaseHelper(); } 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的行為,幫助我們進行測試,代碼如下:
(RobolectricGradleTestRunner.class) (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);public void setUp() {ShadowLog.stream = System.out;mProvider = new AccountProvider();mContentResolver = RuntimeEnvironment.application.getContentResolver();//創建ContentResolver的Shadow對象mShadowContentResolver = Shadows.shadowOf(mContentResolver);mProvider.onCreate();//注冊ContentProvider對象和對應的AUTHORITYShadowContentResolver.registerProvider(AUTHORITY, mProvider);}public void tearDown() {AccountUtil.resetSingleton(AccountDBHelper.class, "mAccountDBHelper");}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);}public void queryNoMatch() {Uri noMathchUri = Uri.parse("content://com.geniusmart.loveut.AccountProvider/tabel/");Cursor cursor = mShadowContentResolver.query(noMathchUri, null, null, null, null);assertNull(cursor);}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();}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();}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
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎
總結
以上是生活随笔為你收集整理的Android单元测试框架Robolectric3.0介绍(二)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: tailf、tail -f、tail -
- 下一篇: Android 使用jarsigner给