原文: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)前天氣的信息。如下所示:

完整的源碼托管在 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ì)將以下代碼加入到自定義的 Application 類 WeatherApp 中:
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)系,祝好!