[譯]利用 Espresso 和 Dagger 編寫可靠的功能測(cè)試

原文:Reliable functional tests with Espresso and Dagger
作者:Egor Andreevici
譯者:lovexiaov

可靠性是自動(dòng)化測(cè)試的一個(gè)核心要素,這意味著無論執(zhí)行多少次,無論在什么情況下執(zhí)行,它的結(jié)果應(yīng)該一致,都通過或都失敗。有些測(cè)試在某些時(shí)候會(huì)由于未知原因?qū)е陆Y(jié)果失敗,這類測(cè)試被稱為不可靠的,這是一個(gè)真實(shí)存在的問題。有時(shí)開發(fā)團(tuán)隊(duì)會(huì)直接放棄一遍又一遍的修復(fù)此類不可靠問題,他們會(huì)跳過執(zhí)行該測(cè)試。這樣,我們將不能免于執(zhí)行回歸測(cè)試。單元測(cè)試通常會(huì)通過模擬所有依賴避免出現(xiàn)此類情況,而功能測(cè)試有自己的實(shí)現(xiàn)方式。一個(gè)經(jīng)典的例子是在屏幕上加載從網(wǎng)絡(luò)上獲取的數(shù)據(jù)——在離線狀態(tài)下,每次執(zhí)行測(cè)試都會(huì)失敗!那么,我們要如何編寫可靠的功能測(cè)試而不受網(wǎng)絡(luò)狀況的影響呢?本文我將介紹一種使用 Dagger 創(chuàng)建簡潔且健壯的功能測(cè)試的方法。

什么是 Dagger

Dagger 已經(jīng)成為眾多 Android 開發(fā)者軍火庫中的必備工具,如果你還沒聽說過它——它是一個(gè)快速的依賴注入框架,由 Square 開發(fā),并針對(duì) Android 做了特別優(yōu)化。不像其他流行的依賴注入器,Dagger 沒有使用反射,而是依靠生成代碼提高執(zhí)行速度。我們將在應(yīng)用中使用 Dagger 用一種簡潔的方法替代依賴,沒有破壞代碼封裝,也不會(huì)寫多余的只用于測(cè)試的代碼。還等什么呢!

天氣應(yīng)用

我們將會(huì)開發(fā)一個(gè)簡單的只有一個(gè)界面的天氣應(yīng)用來作為演示。此應(yīng)用請(qǐng)求用戶提供城市名稱,然后下載該城市當(dāng)前天氣的信息。如下所示:

weather.png

完整的源碼托管在 GitHub 上。

OpenWeatherMap API

我們將會(huì)使用OpenWeatherMap API 來獲取天氣數(shù)據(jù)。此 API 是免費(fèi)的,但是如果你想要在自己機(jī)器上下載并編譯應(yīng)用,你需要注冊(cè)來獲取一個(gè) API key。

設(shè)置 REST API client

下面我們來設(shè)置 REST API client 實(shí)現(xiàn)獲取數(shù)據(jù)功能。我們將會(huì)使用 Retrofit 配合 RxJava完成實(shí)現(xiàn),所以需要將以下依賴加入到 build.gradle 中:

dependencies {  
    // rest of dependencies

    compile 'com.squareup.retrofit:retrofit:1.9.0'
    compile 'io.reactivex:rxandroid:1.0.1'
}

接下來是一個(gè)簡單的名為 WeatherData 的 POJO,該類將代表我們從服務(wù)器上獲取的信息。

public class WeatherData {

    public static final String DATE_FORMAT = "EEEE, d MMM";

    private static final int KELVIN_ZERO = 273;

    private static final String FORMAT_TEMPERATURE_CELSIUS = "%d°";
    private static final String FORMAT_HUMIDITY = "%d%%";

    private String name;
    private Weather[] weather;
    private Main main;

    public String getCityName() {
        return name;
    }

    public String getWeatherDate() {
        return new SimpleDateFormat(DATE_FORMAT, Locale.getDefault()).format(new Date());
    }

    public String getWeatherState() {
        return weather().main;
    }

    public String getWeatherDescription() {
        return weather().description;
    }

    public String getTemperatureCelsius() {
        return String.format(FORMAT_TEMPERATURE_CELSIUS, (int) main.temp - KELVIN_ZERO);
    }

    public String getHumidity() {
        return String.format(FORMAT_HUMIDITY, main.humidity);
    }

    private Weather weather() {
        return weather[0];
    }

    private static class Weather {
        private String main;
        private String description;
    }

    private static class Main {
        private float temp;
        private int humidity;
    }
}

然后是簡單的 Retrofit 接口,該接口包含了我們用來獲取數(shù)據(jù)的 GET 請(qǐng)求的描述:

public interface WeatherApiClient {

    Endpoint ENDPOINT = Endpoints.newFixedEndpoint("http://api.openweathermap.org/data/2.5");

    @GET("/weather") Observable<WeatherData> getWeatherForCity(@Query("q") String cityName);
}

以上是針對(duì)網(wǎng)絡(luò)的設(shè)置。下面讓我們來配置 Dagger 使它能提供一個(gè) WeatherApiClient 類的實(shí)現(xiàn)供需要的類調(diào)用。

配置 Dagger

build.gradle 文件中添加以下幾行將 Dagger 配置到你的工程中:

final DAGGER_VERSION = '2.0.2'

dependencies {  
    // Retrofit dependencies are here

    compile "com.google.dagger:dagger:${DAGGER_VERSION}"
    apt "com.google.dagger:dagger-compiler:${DAGGER_VERSION}"
    provided 'org.glassfish:javax.annotation:10.0-b28'
}

你可能注意到了我們?cè)?apt 作用域中引入了 dagger-compiler:因?yàn)?dagger-compiler 是一個(gè)注解處理器,我們只希望在編譯時(shí)期使用它而不想將它打包到 APK 中(就 dex 方法數(shù)限制而言 dagger-compiler 是十分龐大的)。可以使用 android-apt 插件來實(shí)現(xiàn)此功能。將以下行添加到應(yīng)用要目錄的 build.gradle 文件中:

buildscript {  
    dependencies {
        // other classpath declarations
        classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8'
    }
}

然后在 app 目錄下的 build.gradle 文件的最上方添加一行:

apply plugin: 'com.neenbedankt.android-apt'

現(xiàn)在,我們得到了所有需要的依賴。下面我們會(huì)創(chuàng)建一個(gè) Dagger 模塊,該模塊描述了我們提供依賴的邏輯:

@Module
public class AppModule {

    private final Context context;

    public AppModule(Context context) {
        this.context = context.getApplicationContext();
    }

    @Provides @AppScope public Context provideAppContext() {
        return context;
    }

    @Provides public WeatherApiClient provideWeatherApiClient() {
        return new RestAdapter.Builder()
                .setEndpoint(WeatherApiClient.ENDPOINT)
                .setRequestInterceptor(apiKeyRequestInterceptor())
                .setLogLevel(BuildConfig.DEBUG ? RestAdapter.LogLevel.FULL : RestAdapter.LogLevel.NONE)
                .build()
                .create(WeatherApiClient.class);
    }

    private RequestInterceptor apiKeyRequestInterceptor() {
        return new ApiKeyRequestInterceptor(context.getString(R.string.open_weather_api_key));
    }
}

如你所見,provideWeatherApiClient() 真實(shí)的創(chuàng)建了 WeatherApiClient的實(shí)例,并將其返回:每次我們請(qǐng)求它提供一個(gè) WeatherApiClient實(shí)例時(shí),這段代碼都會(huì)被調(diào)用。太爽啦!現(xiàn)在我們添加一個(gè) Component 接口,該接口描述了 Dagger 創(chuàng)建的我們程序依賴關(guān)系圖的約定:

@AppScope
@Component(modules = AppModule.class)
public interface AppComponent {

    void inject(MainActivity activity);

    @AppScope Context appContext();

    WeatherApiClient weatherApiClient();
}

AppComponent 能夠提供應(yīng)用 Context 的實(shí)例以及 WeatehrApiClient 的實(shí)例,它還可以向 MainActivity 中注入依賴。

最后,我們需要實(shí)例化 AppComponent 并使它可被其他類使用。我們會(huì)將以下代碼加入到自定義的 ApplicationWeatherApp 中:

public class WeatherApp extends Application {

    private AppComponent appComponent;

    @Override
    public void onCreate() {
        super.onCreate();

        appComponent = DaggerAppComponent.builder()
                .appModule(new AppModule(this))
                .build();
    }

    public AppComponent appComponent() {
        return appComponent;
    }
}

現(xiàn)在我們可以打開 MainActivity看一下我們?nèi)绾问褂?WeatherApiClient 獲取 天氣數(shù)據(jù)的。

MainActivity

MainActivity 中相關(guān)代碼(完整代碼):

public class MainActivity extends AppCompatActivity implements SearchView.OnQueryTextListener {

    @Inject WeatherApiClient weatherApiClient;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ((WeatherApp) getApplication()).appComponent().inject(this);
    }

    @Override
    public boolean onQueryTextSubmit(String query) {
        if (!TextUtils.isEmpty(query)) {
            loadWeatherData(query);
        }
        return true;
    }

    private void loadWeatherData(String cityName) {
        subscription = weatherApiClient.getWeatherForCity(cityName)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(
                        // handle result
                        }
                );
    }
}

請(qǐng)注意看我們?nèi)绾螌?shí)例化 WeatherApiClient 的:我們沒有手動(dòng)創(chuàng)建,而是使用注解 @Inject 標(biāo)記,并在 onCreate() 中做如下操作:

((WeatherApp) getApplication()).appComponent().inject(this);

通過訪問我們的 AppComponent 并將它注入到 MainActivity 中, 我們使 Dagger 滿足了所有的依賴需求(通過使用 @Inject 標(biāo)記,它出色的完成的任務(wù))。接下來我們就可以使用 WeatherApiClient 獲取數(shù)據(jù)了。

盡管此方式乍看起來啰嗦且不簡明,它真正強(qiáng)大的地方在于我們不需要硬編碼創(chuàng)建依賴。這種優(yōu)勢(shì)將在下一步我們需要替代測(cè)試代碼中的依賴時(shí)突顯出來。

配置 Espresso

現(xiàn)在讓我們將 Espresso 集成到工程中,并編寫測(cè)試驗(yàn)證我們能否正常獲取數(shù)據(jù)并展示數(shù)據(jù)。首先,添加以下依賴到 build.gradle 中:

final ESPRESSO_VERSION = '2.2.1'  
final ESPRESSO_RUNNER_VERSION = '0.4'

dependencies {  
    // 'compile' dependencies

    androidTestCompile "com.android.support.test:runner:${ESPRESSO_RUNNER_VERSION}"
    androidTestCompile "com.android.support.test:rules:${ESPRESSO_RUNNER_VERSION}"
    androidTestCompile "com.android.support.test.espresso:espresso-core:${ESPRESSO_VERSION}"
    androidTestApt "com.google.dagger:dagger-compiler:${DAGGER_VERSION}"
}

這里我們也用到了 dagger-compiler,因?yàn)槲覀兊臏y(cè)試代碼也必須使用注解處理器執(zhí)行。接下來我們添加一個(gè)測(cè)試類:

@LargeTest
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

    private static final String CITY_NAME = "München";

    @Rule public ActivityTestRule<MainActivity> activityTestRule = new ActivityTestRule<>(MainActivity.class);

    @Inject WeatherApiClient weatherApiClient;

    @Before
    public void setUp() {
        weatherApiClient = ((WeatherApp) activityTestRule.getActivity().getApplication()).appComponent()
                .weatherApiClient();
    }

    @Test
    public void correctWeatherDataDisplayed() {
        WeatherData weatherData = weatherApiClient.getWeatherForCity(CITY_NAME).toBlocking().first();

        onView(withId(R.id.action_search)).perform(click());
        onView(withId(android.support.v7.appcompat.R.id.search_src_text)).perform(replaceText(CITY_NAME));
        onView(withId(android.support.v7.appcompat.R.id.search_src_text)).perform(pressKey(KeyEvent.KEYCODE_ENTER));

        onView(withId(R.id.city_name)).check(matches(withText(weatherData.getCityName())));
        onView(withId(R.id.weather_date)).check(matches(withText(weatherData.getWeatherDate())));
        onView(withId(R.id.weather_state)).check(matches(withText(weatherData.getWeatherState())));
        onView(withId(R.id.weather_description)).check(matches(withText(weatherData.getWeatherDescription())));
        onView(withId(R.id.temperature)).check(matches(withText(weatherData.getTemperatureCelsius())));
        onView(withId(R.id.humidity)).check(matches(withText(weatherData.getHumidity())));
    }
}

測(cè)試用例簡潔明了:我們想我為指定城市加載天氣數(shù)據(jù)并驗(yàn)證數(shù)據(jù)是否正常顯示。這在多數(shù)情況下應(yīng)該都是正常的,但想象以下如果在飛行模式下執(zhí)行呢?很可能會(huì)失?。∮捎谖覀?cè)O(shè)計(jì)的測(cè)試用例時(shí)用來驗(yàn)證應(yīng)用是否能正常顯示數(shù)據(jù),而不能聯(lián)網(wǎng)導(dǎo)致的數(shù)據(jù)缺失不是有效場景,該場景會(huì)使我們的測(cè)試失敗。另外,我們可能會(huì)編寫另一個(gè)測(cè)試用例來檢查在飛行模式下應(yīng)用的行為是否正?!绾问惯@兩個(gè)測(cè)試用例同時(shí)執(zhí)行通過呢?Dagger 可以搞定!讓我們利用依賴注入的力量,提供一個(gè)可配置我們期望接收數(shù)據(jù)的 WeatherApiClient 的實(shí)現(xiàn)。

MockWeatherApiClient

我們的一個(gè)解決方案是一個(gè)返回硬編碼數(shù)據(jù)的 WeatherApiClient。創(chuàng)建 TestData 類,該類中存放了我們期望返回的 JSON 數(shù)據(jù)。

public final class TestData {

    public static final String MUNICH_WEATHER_DATA_JSON = "\n" +
            "{\n" +
            "    \"coord\": {\n" +
            "        \"lon\": 11.58,\n" +
            "        \"lat\": 48.14\n" +
            "    },\n" +
            "    \"weather\": [{\n" +
            "        \"id\": 741,\n" +
            "        \"main\": \"Fog\",\n" +
            "        \"description\": \"fog\",\n" +
            "        \"icon\": \"50n\"\n" +
            "    }],\n" +
            "    \"base\": \"cmc stations\",\n" +
            "    \"main\": {\n" +
            "        \"temp\": 275.68,\n" +
            "        \"pressure\": 1030,\n" +
            "        \"humidity\": 93,\n" +
            "        \"temp_min\": 274.15,\n" +
            "        \"temp_max\": 277.15\n" +
            "    },\n" +
            "    \"wind\": {\n" +
            "        \"speed\": 1.5,\n" +
            "        \"deg\": 240\n" +
            "    },\n" +
            "    \"clouds\": {\n" +
            "        \"all\": 0\n" +
            "    },\n" +
            "    \"dt\": 1449350400,\n" +
            "    \"sys\": {\n" +
            "        \"type\": 1,\n" +
            "        \"id\": 4887,\n" +
            "        \"message\": 0.0134,\n" +
            "        \"country\": \"DE\",\n" +
            "        \"sunrise\": 1449298092,\n" +
            "        \"sunset\": 1449328836\n" +
            "    },\n" +
            "    \"id\": 6940463,\n" +
            "    \"name\": \"Altstadt\",\n" +
            "    \"cod\": 200\n" +
            "}";

    private TestData() {
        // no instances
    }
}

MockWeatherApiClient 只需要解析返回的 JSON 數(shù)據(jù)。我們還可以加入延遲以模仿網(wǎng)絡(luò)延遲:

public class MockWeatherApiClient implements WeatherApiClient {

    @Override public Observable<WeatherData> getWeatherForCity(String cityName) {
        WeatherData weatherData = new Gson().fromJson(TestData.MUNICH_WEATHER_DATA_JSON, WeatherData.class);
        return Observable.just(weatherData).delay(1, TimeUnit.SECONDS);
    }
}

有了可配置的 WeatherApiClient,我們不在需要依賴任何的外部狀況,我們可以配置它來返回任何我們想要測(cè)試的數(shù)據(jù)。接下來,我們將找出使 MockWeatherApiClient 可用的方法。

配置 Dagger 測(cè)試

我們需要模仿在我們應(yīng)用代碼中的配置步驟,從創(chuàng)建 TestAppModule 類開始:

@Module
public class TestAppModule {

    private final Context context;

    public TestAppModule(Context context) {
        this.context = context.getApplicationContext();
    }

    @Provides @AppScope public Context provideAppContext() {
        return context;
    }

    @Provides public WeatherApiClient provideWeatherApiClient() {
        return new MockWeatherApiClient();
    }
}

該類與 AppMoudle 十分相似,但是我們沒有使用 Retrofit 創(chuàng)建真是的 WeatherApiClient 的實(shí)現(xiàn),而是簡單的實(shí)例化了 MockWeatherApiClient。接下來添加 TestAppComponent

@AppScope
@Component(modules = TestAppModule.class)
public interface TestAppComponent extends AppComponent {

    void inject(MainActivityTest test);
}

TestAppComponent 繼承了 AppComonent 并添加了我們測(cè)試類需要的 inject() 方法。接下來修改測(cè)試類的 setUp() 方法:

@Before
public void setUp() {  
    ((TestWeatherApp) activityTestRule.getActivity().getApplication()).appComponent().inject(this);
}

最后,我們使用測(cè)試替身替換 WeatherApp

public class TestWeatherApp extends WeatherApp {

    private TestAppComponent testAppComponent;

    @Override
    public void onCreate() {
        super.onCreate();

        testAppComponent = DaggerTestAppComponent.builder()
                .testAppModule(new TestAppModule(this))
                .build();
    }

    @Override
    public TestAppComponent appComponent() {
        return testAppComponent;
    }
}

注意我們這里返回的是 TestAppComponent 而不是 AppComponent。 類的接口保持不變,這意味著使用測(cè)試替身對(duì)應(yīng)用代碼毫無影響。

我們現(xiàn)在配置完了 Dagger,但還遺漏了關(guān)鍵的一點(diǎn):如何讓我們的測(cè)試使用 TestWeatherApp 而不是 WeatherApp?答案是使用自定義測(cè)試執(zhí)行器!

實(shí)現(xiàn)自定義測(cè)試執(zhí)行器

用來執(zhí)行 Espresso 測(cè)試的 AndroidJUnitRunner 有一個(gè)便捷的方法 newApplication(),我們可以覆寫該方法來使用 TestWeatherApp 替換 WeatherApp

public class WeatherTestRunner extends AndroidJUnitRunner {

    @Override
    public Application newApplication(ClassLoader cl, String className, Context context) throws InstantiationException,
            IllegalAccessException, ClassNotFoundException {
        String testApplicationClassName = TestWeatherApp.class.getCanonicalName();
        return super.newApplication(cl, testApplicationClassName, context);
    }
}

不要忘記在 build.gradle 中聲明它喲:

defaultConfig {  
    // rest of configuration

    testInstrumentationRunner "me.egorand.weather.runner.WeatherTestRunner"
}

這樣就可以了!我們可以使用以下命令執(zhí)行測(cè)試:

./gradlew connectedAndroidTest

至此,我們已經(jīng)完成不受網(wǎng)絡(luò)影響執(zhí)行功能測(cè)試的配置,并保證了測(cè)試結(jié)果正常執(zhí)行。請(qǐng)到 GitHub` 上查看本文用到的完整源碼。

結(jié)論

正如我在使用 Espresso 測(cè)試一個(gè)有序列表(中譯版)中所說,有一套驗(yàn)收測(cè)試是一種 catch regressions 的很好方式,并且保證了絕大多數(shù)的 bug 會(huì)被開發(fā)團(tuán)隊(duì)發(fā)現(xiàn),而不是終端用戶。那么保證你測(cè)試的可靠性就變得十分重要:不可靠的測(cè)試只會(huì)浪費(fèi)團(tuán)隊(duì)的時(shí)間去一遍一遍的修復(fù)它們,直到所有人都決定不去執(zhí)行這些測(cè)試。

通過使用 Dagger 我們可以使代碼與依賴注入邏輯解耦,這將允許我們使用測(cè)試替身并且控制待測(cè)應(yīng)用的某些方面。本文描述了使用此技術(shù)允許在離線模式下執(zhí)行網(wǎng)絡(luò)相關(guān)的測(cè)試,并保證它們正常執(zhí)行。值得一提的是,此方法不適用于端對(duì)端的測(cè)試,因?yàn)槲覀儧]有像用戶一樣在真實(shí)環(huán)境中測(cè)試應(yīng)用。然而,這仍然是一個(gè)非常有效的執(zhí)行功能測(cè)試的方法,也使你能很靈活的測(cè)試應(yīng)用各方面邏輯。

你有在自己的 Espresso 中使用 Dagger 嗎?希望能與你交流。如果你有任何反饋或發(fā)現(xiàn)文中錯(cuò)誤,歡迎留言或直接與我聯(lián)系,祝好!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容