Android單元測試框架Robolectric3.0介紹(二)

此生無緣,愿你在另一個時空永遠(yuǎn)幸運(yùn)

文章中的所有代碼在此:https://github.com/geniusmart/LoveUT 鲁纠,由于 Robolectric 3.0 和 3.1 版本(包括后續(xù)3.x版本)差異不小总棵,該工程中包含這兩個版本對應(yīng)的測試用例 Demo 。

一 閑話單元測試

我們經(jīng)常講“前人種樹改含,后人乘涼”情龄,然而在軟件開發(fā)中,往往呈現(xiàn)出來的卻是截然相反的景象捍壤,我們在績效和指標(biāo)的驅(qū)使下骤视,主動或被動的留下來大量壞味道的代碼,在短時間內(nèi)順利的完成項(xiàng)目鹃觉,此后卻花了數(shù)倍于開發(fā)的時間來維護(hù)此項(xiàng)目专酗,可謂“前人砍樹,后人遭殃”盗扇,諷刺的是祷肯,砍樹的人往往因?yàn)閮?yōu)秀的績效,此時已經(jīng)步步高升粱玲,而遭殃的往往是意氣風(fēng)發(fā)躬柬,步入職場的年輕人,如此不斷輪回抽减。所以允青,為了打破輪回,從一點(diǎn)一滴做起吧卵沉,“樹”的種類眾多颠锉,作為任意一名普通的軟件工程師,種好單元測試這棵樹史汗,便是撒下一片蔭涼琼掠。

關(guān)于單元測試,很多人心中會有以下幾個疑問:
(1)為什么要寫停撞?
(2)這不是QA人員該做的嗎瓷蛙?
(3)需求天天變悼瓮,功能都來不及完成了,還要同時維護(hù)代碼和UT艰猬,四不四傻昂岜ぁ?
(4)我要怎么寫UT(特別是Android單元測試)冠桃?

  1. 關(guān)于第一個問題命贴,首先我們反問自己幾個問題:
  • (1)我們在學(xué)習(xí)任何一個技術(shù)框架,比如 retofit2 食听、 Dagger2 時胸蛛,是不是第一時間先打開官方文檔(或者任意文檔),然后查閱api如何調(diào)用的代碼樱报,而官方文檔往往都會在最醒目的地方葬项,用最簡潔的代碼向我們說明了api如何使用?

    其實(shí),當(dāng)我們在寫單元測試時迹蛤,為了測試某個功能或某個api玷室,首先得調(diào)用相關(guān)的代碼,因此我們留下來的便是一段如何調(diào)用的代碼笤受。這些代碼的價值在于為以后接手維護(hù)/重構(gòu)/優(yōu)化功能的人,留下一份程序猿最愿意去閱讀的文檔敌蜂。

  • (2)當(dāng)你寫單元測試的時候箩兽,是不是發(fā)現(xiàn)很多代碼無法測試?撇開對UT測試框架不熟悉的因素之外章喉,是不是因?yàn)槟愕拇a里一個方法做了太多事情汗贫,或者代碼的封裝性不夠好,或者一個方法需要有其他很多依賴才能測試(高耦合)秸脱,而此時落包,為了讓你的代碼可測試,你是不是會主動去優(yōu)化一下代碼摊唇?

  • (3)是不是對重構(gòu)沒信心咐蝇?這個話題太老生常談了,配備有價值的巷查、高覆蓋率的單元測試可解決此問題有序。

  • (4)當(dāng)你在寫Android代碼(比如網(wǎng)絡(luò)請求和DB操作)的時候,是如何測試的岛请?跑起來整個App旭寿,點(diǎn)了好幾步操作后,終于到達(dá)要測試的功能崇败,然后巨慢無比的Debug盅称?如果你寫UT,并使用Robolectric這樣的框架,你不僅可以脫離Android環(huán)境對代碼進(jìn)行調(diào)試缩膝,還可以很快速的定位和Debug你想要調(diào)試的代碼混狠,大大的提升了開發(fā)效率。

以上逞盆,便是寫好單元測試的意義檀蹋。

  1. 關(guān)于第二個問題,己所不欲勿施于人
    我始終覺得讓QA寫UT云芦,是一種傻叉的行為俯逾。單元測試是一種白盒測試,本來就是開發(fā)分內(nèi)之事舅逸,難道讓QA去閱讀你惡心的充滿壞味道的代碼桌肴,然后硬著頭皮寫出UT?試想一下琉历,你的產(chǎn)品經(jīng)理讓你畫原型寫需求文檔坠七,你的領(lǐng)導(dǎo)讓你去市場部輔助吹噓產(chǎn)品,促進(jìn)銷售旗笔,你會不會有種吃了翔味巧克力的感覺彪置?所以,己所不欲勿施于人蝇恶。

  2. 這個問題有點(diǎn)頭疼拳魁,總之,盡量提高我們的代碼設(shè)計(jì)和寫UT的速度撮弧,以便應(yīng)對各種不合理的需求和項(xiàng)目潘懊。

  3. 前面三個問題,或多或少是心態(tài)的問題贿衍,調(diào)整好心態(tài)授舟,認(rèn)可UT的優(yōu)點(diǎn),嘗試走第一步看看贸辈。而第四個問題释树,如何寫?則是筆者這系列文章的核心內(nèi)容裙椭,在我的第一篇《Robolectric3.0(一)》中已經(jīng)介紹了這個框架的特點(diǎn)躏哩,環(huán)境搭建,三大組件(Activity揉燃、Bordercast扫尺、Service)的測試,以及Shadow的使用炊汤,這篇文章正驻,主要介紹網(wǎng)絡(luò)請求和數(shù)據(jù)庫相關(guān)的功能如何測試弊攘。

二 日志輸出

Robolectric對日志輸出的支持其實(shí)非常簡單,為什么把它單獨(dú)列一個條目來講解姑曙?因?yàn)橥覀冊趯慤T的過程襟交,其實(shí)也是在調(diào)試代碼,而日志輸出對于代碼調(diào)試起到極大的作用伤靠。我們只需要在每個TestCase的setUp()里執(zhí)行ShadowLog.stream = System.out即可捣域,如:

@Before
public void setUp() throws URISyntaxException {
    //輸出日志
    ShadowLog.stream = System.out;
}

此時,無論是功能代碼還是測試代碼中的 Log.i()之類的相關(guān)日志都將輸出在控制面板中宴合,調(diào)試起功能來焕梅,簡直爽得不要不要的。

三 網(wǎng)絡(luò)請求篇

關(guān)于網(wǎng)絡(luò)請求卦洽,筆者采用的是retrofit2的2.0.0-beta4版本贞言,api調(diào)用有很大的變化,詳情請參考官方文檔阀蒂。Robolectic支持發(fā)送真實(shí)的網(wǎng)絡(luò)請求该窗,通過對響應(yīng)結(jié)果進(jìn)行測試,可大大的提升我們與服務(wù)端的聯(lián)調(diào)效率蚤霞。

以github api為例酗失,網(wǎng)絡(luò)請求的代碼如下:

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. 測試真實(shí)的網(wǎng)絡(luò)請求

@Test
public void publicRepositories() throws IOException {
    Call<List<Repository>> call = githubService.publicRepositories("geniusmart");
    Response<List<Repository>> execute = call.execute();

    List<Repository> list = execute.body();
    //可輸出完整的響應(yīng)結(jié)果,幫助我們調(diào)試代碼
    Log.i(TAG,new Gson().toJson(list));
    assertTrue(list.size()>0);
    assertNotNull(list.get(0).name);
}

這類測試的意義在于:

  • (1)檢驗(yàn)網(wǎng)絡(luò)接口的穩(wěn)定性
  • (2)檢驗(yàn)部分響應(yīng)結(jié)果數(shù)據(jù)的完整性(如非空驗(yàn)證)
  • (3)方便開發(fā)階段的聯(lián)調(diào)(通過UT聯(lián)調(diào)的效率遠(yuǎn)高于run app后聯(lián)調(diào))

2. 模擬網(wǎng)絡(luò)請求

對于網(wǎng)絡(luò)請求的測試昧绣,我們需要知道確切的響應(yīng)結(jié)果值级零,才可進(jìn)行一系列相關(guān)的業(yè)務(wù)功能的斷言(比如請求成功/失敗后的異步回調(diào)函數(shù)里的邏輯),而發(fā)送真實(shí)的網(wǎng)絡(luò)請求時滞乙,其返回結(jié)果往往是不可控的,因此對網(wǎng)絡(luò)請求和響應(yīng)結(jié)果進(jìn)行模擬顯得特別必要鉴嗤。

那么如何模擬斩启?其原理很簡單,okhttp提供了攔截器 Interceptors ,通過該api醉锅,我們可以攔截網(wǎng)絡(luò)請求兔簇,根據(jù)請求路徑,不進(jìn)行請求的發(fā)送硬耍,而直接返回我們自定義好的相應(yīng)的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;
    }
}

相應(yīng)的resonse json的文件可以存放在test/resources/json/下狸窘,如下圖


response的json數(shù)據(jù)文件

再次,定義Http Client,并添加攔截器:

//獲取測試json文件地址
jsonFullPath = getClass().getResource(JSON_ROOT_PATH).toURI().getPath();
//定義Http Client,并添加攔截器
OkHttpClient okHttpClient = new OkHttpClient.Builder()
        .addInterceptor(new MockInterceptor(jsonFullPath))
        .build();
//設(shè)置Http Client
Retrofit retrofit = new Retrofit.Builder()
        .baseUrl(GithubService.BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(okHttpClient)
        .build();
mockGithubService = retrofit.create(GithubService.class);

最后坯认,就可以使用mockGithubService進(jìn)行隨心所欲的斷言了:

@Test
public void mockPublicRepositories() throws Exception {
    Response<List<Repository>> repositoryResponse = mockGithubService.publicRepositories("geniusmart").execute();
    assertEquals(repositoryResponse.body().get(5).name, "LoveUT");
}

這種做法不僅僅可以在寫UT的過程中使用翻擒,在開發(fā)過程中也可以使用氓涣,當(dāng)服務(wù)端的接口開發(fā)滯后于客戶端的進(jìn)度時,可以先約定好數(shù)據(jù)格式陋气,客戶端采用模擬網(wǎng)絡(luò)請求的方式進(jìn)行開發(fā)劳吠,此時兩個端可以做到不互相依賴。

3. 網(wǎng)絡(luò)請求的異步回調(diào)如何進(jìn)行測試

關(guān)于網(wǎng)絡(luò)請求之后的回調(diào)函數(shù)如何測試巩趁,筆者暫時也沒有什么自己覺得滿意的解決方案痒玩,這里提供一種做法,權(quán)當(dāng)拋磚引玉议慰,希望有此經(jīng)驗(yàn)的人提供更多的思路蠢古。

由于網(wǎng)絡(luò)請求和回調(diào)函數(shù)是在子線程和UI主線程兩個線程中進(jìn)行的,且后者要等待前者執(zhí)行完畢褒脯,這種情況要在一個TestCase中測試并不容易便瑟。因此我們要做的就是想辦法讓兩件事情同步的在一個TestCase中執(zhí)行,類似于這樣的代碼:

//此為Retrofit2的新api番川,代表同步執(zhí)行
//異步執(zhí)行的api為githubService.followingUser("geniusmart").enqueue(callback);
githubService.publicRepositories("geniusmart").execute();
callback.onResponse(call,response);
//對執(zhí)行回調(diào)后影響的數(shù)據(jù)做斷言
some assert...

這里我列舉一個場景到涂,并進(jìn)行相應(yīng)的單元測試:一個Activity中有個ListView,經(jīng)過網(wǎng)絡(luò)請求后颁督,在異步回調(diào)函數(shù)里加載ListView的數(shù)據(jù)践啄,點(diǎn)擊每一個item后,吐司其對應(yīng)的標(biāo)題沉御。

public class CallbackActivity extends Activity {

    //省略一些全局變量聲明的代碼
    /**
     * 定義一個全局的callback對象屿讽,并暴露出get方法供UT調(diào)用
     */
    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();
            }
        });
        //加載數(shù)據(jù)
        loadData();
    }

    public void loadData() {
        progressBar.setVisibility(View.VISIBLE);
        datas = new ArrayList<>();
        //初始化回調(diào)函數(shù)對象
        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;
    }
}

相應(yīng)的測試代碼如下:

@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();
    //結(jié)合模擬的響應(yīng)數(shù)據(jù),執(zhí)行回調(diào)函數(shù)
    callbackActivity.getCallback().onResponse(null, users);
    ListAdapter listAdapter = listView.getAdapter();
    //對ListView的item進(jìn)行斷言
    assertEquals(listAdapter.getItem(0).toString(), "JakeWharton");
    assertEquals(listAdapter.getItem(1).toString(), "Trinea");

    ShadowListView shadowListView = Shadows.shadowOf(listView);

    //測試點(diǎn)擊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");
}

這樣做的話要改變一些編碼習(xí)慣伐谈,比如回調(diào)函數(shù)不能寫成匿名內(nèi)部類對象,需要定義一個全局變量试疙,并破壞其封裝性诵棵,即提供一個get方法,供UT調(diào)用祝旷。

注:經(jīng)過后續(xù)研究履澳,使用Mockito的Capture才是解決異步測試的最佳方案,后面考慮出專門文章來說明怀跛。

四 數(shù)據(jù)庫篇

Robolectric從2.2開始距贷,就已經(jīng)可以對真正的DB進(jìn)行測試,從3.0開始測試DB變得更加便利吻谋,通過UT來調(diào)試DB簡直不能更爽忠蝗。這一節(jié)將介紹不使用任何框架的DB測試,ORMLite測試以及ContentProvider測試漓拾。

1. 不使用任何框架的DB測試(SQLiteOpenHelper)

如果沒有使用框架什湘,采用Android的SQLiteOpenHelper對數(shù)據(jù)庫進(jìn)行操作长赞,通常我們會封裝好各個Dao,并實(shí)例化一個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);
}

另外有一點(diǎn)要注意的是得哆,當(dāng)我們測試多個test時,會拋出一個類似于這樣的異常:
java.lang.RuntimeException: java.lang.IllegalStateException: Illegal connection pointer 37. Current pointers for thread Thread[pool-1-thread-1,5,main] []
解決方式便是每次執(zhí)行一個test之后哟旗,就將SQLiteOpenHelper實(shí)例對象重置為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對數(shù)據(jù)操作的測試與上述方法并無區(qū)別,同樣也要注意每次測試完后闸餐,要重置OrmLiteSqliteOpenHelper實(shí)例饱亮。

@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,此時配備完善和嚴(yán)謹(jǐn)?shù)膯卧獪y試用例是非常有必要的舍沙,畢竟你的ContentProvider是對外提供使用的近上,一定要保證代碼的質(zhì)量和穩(wěn)定性。

對ContentProvider的測試拂铡,需要借助影子對象ShadowContentResolver壹无,關(guān)于Shadow,我在上文中已經(jīng)有介紹過感帅,此處的Shadow可以豐富ContentResolver的行為斗锭,幫助我們進(jìn)行測試,代碼如下:

@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();
        //創(chuàng)建ContentResolver的Shadow對象
        mShadowContentResolver = Shadows.shadowOf(mContentResolver);

        mProvider.onCreate();
        //注冊ContentProvider對象和對應(yīng)的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);

        //查詢所有數(shù)據(jù)
        Cursor cursor1 = mShadowContentResolver.query(URI_PERSONAL_INFO, null, null, null, null);
        assertEquals(cursor1.getCount(), 2);

        //查詢id為2的數(shù)據(jù)
        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是一種非常好的編程習(xí)慣失球,但是UT雖好岖是,切忌貪杯,作為一名技術(shù)領(lǐng)導(dǎo)者实苞,切忌拿測試覆蓋率作為指標(biāo)豺撑,如此一來會滋生開發(fā)者的抵觸心理,導(dǎo)致亂寫一通黔牵。作為開發(fā)者前硫,應(yīng)該時刻思考什么才是有價值的UT,什么邏輯沒必要寫(比如set和get)荧止,這樣才不會疲于奔命且覺得乏味。其實(shí)很多事情都是因果關(guān)系阶剑,開發(fā)人員不寫跃巡,所以leader強(qiáng)制寫,而leader強(qiáng)制寫牧愁,開發(fā)人員會抵觸而亂寫素邪。所以,讓各自做好猪半,一起來享受UT帶來的高質(zhì)量的代碼以及為了可測試而去思考代碼設(shè)計(jì)的編程樂趣兔朦。

本文的所有代碼仍然放在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

最后偷线,行此文時,悲痛欲絕沽甥,越長大越不會表達(dá)自己的情感声邦,此文送給肚中遠(yuǎn)去的小小猴子,此生無緣摆舟。無論你在哪個時空亥曹,作為一個技術(shù)從業(yè)者,將保持純良恨诱,求真媳瞪,但行好事,希望能帶給你幸運(yùn)照宝。愿此坎之后蛇受,此生無坎。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末厕鹃,一起剝皮案震驚了整個濱河市兢仰,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌熊响,老刑警劉巖旨别,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異汗茄,居然都是意外死亡秸弛,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門洪碳,熙熙樓的掌柜王于貴愁眉苦臉地迎上來递览,“玉大人,你說我怎么就攤上這事瞳腌〗柿澹” “怎么了?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵嫂侍,是天一觀的道長儿捧。 經(jīng)常有香客問我,道長挑宠,這世上最難降的妖魔是什么菲盾? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮各淀,結(jié)果婚禮上懒鉴,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好临谱,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布璃俗。 她就那樣靜靜地躺著,像睡著了一般悉默。 火紅的嫁衣襯著肌膚如雪城豁。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天麦牺,我揣著相機(jī)與錄音钮蛛,去河邊找鬼。 笑死剖膳,一個胖子當(dāng)著我的面吹牛魏颓,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播吱晒,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼甸饱,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了仑濒?” 一聲冷哼從身側(cè)響起叹话,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎墩瞳,沒想到半個月后驼壶,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡喉酌,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年热凹,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片泪电。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡般妙,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出相速,到底是詐尸還是另有隱情碟渺,我是刑警寧澤,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布突诬,位于F島的核電站苫拍,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏旺隙。R本人自食惡果不足惜绒极,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望催束。 院中可真熱鬧,春花似錦伏社、人聲如沸抠刺。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽速妖。三九已至高蜂,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間罕容,已是汗流浹背备恤。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留锦秒,地道東北人露泊。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像旅择,于是被迫代替她去往敵國和親惭笑。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容