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. 關于第一個問題,首先我們反問自己幾個問題:
  • (1)我們在學習任何一個技術框架,比如 retofit2Dagger2 時,是不是第一時間先打開官方文檔(或者任意文檔),然后查閱api如何調用的代碼,而官方文檔往往都會在最醒目的地方,用最簡潔的代碼向我們說明了api如何使用?

    其實,當我們在寫單元測試時,為了測試某個功能或某個api,首先得調用相關的代碼,因此我們留下來的便是一段如何調用的代碼。這些代碼的價值在于為以后接手維護/重構/優化功能的人,留下一份程序猿最愿意去閱讀的文檔。

  • (2)當你寫單元測試的時候,是不是發現很多代碼無法測試?撇開對UT測試框架不熟悉的因素之外,是不是因為你的代碼里一個方法做了太多事情,或者代碼的封裝性不夠好,或者一個方法需要有其他很多依賴才能測試(高耦合),而此時,為了讓你的代碼可測試,你是不是會主動去優化一下代碼

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

  • (4)當你在寫Android代碼(比如網絡請求和DB操作)的時候,是如何測試的?跑起來整個App,點了好幾步操作后,終于到達要測試的功能,然后巨慢無比的Debug?如果你寫UT,并使用Robolectric這樣的框架,你不僅可以脫離Android環境對代碼進行調試,還可以很快速的定位和Debug你想要調試的代碼,大大的提升了開發效率。

以上,便是寫好單元測試的意義。

  1. 關于第二個問題,己所不欲勿施于人
    我始終覺得讓QA寫UT,是一種傻叉的行為。單元測試是一種白盒測試,本來就是開發分內之事,難道讓QA去閱讀你惡心的充滿壞味道的代碼,然后硬著頭皮寫出UT?試想一下,你的產品經理讓你畫原型寫需求文檔,你的領導讓你去市場部輔助吹噓產品,促進銷售,你會不會有種吃了翔味巧克力的感覺?所以,己所不欲勿施于人。

  2. 這個問題有點頭疼,總之,盡量提高我們的代碼設計和寫UT的速度,以便應對各種不合理的需求和項目。

  3. 前面三個問題,或多或少是心態的問題,調整好心態,認可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/下,如下圖


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進行隨心所欲的斷言了:

@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

最后,行此文時,悲痛欲絕,越長大越不會表達自己的情感,此文送給肚中遠去的小小猴子,此生無緣。無論你在哪個時空,作為一個技術從業者,將保持純良,求真,但行好事,希望能帶給你幸運。愿此坎之后,此生無坎。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容