原文:Reliable functional tests with Espresso and Dagger
作者:Egor Andreevici
譯者:lovexiaov
可靠性是自動化測試的一個核心要素松靡,這意味著無論執(zhí)行多少次,無論在什么情況下執(zhí)行,它的結(jié)果應(yīng)該一致布近,都通過或都失敗举塔。有些測試在某些時候會由于未知原因?qū)е陆Y(jié)果失敗僚稿,這類測試被稱為不可靠的矿咕,這是一個真實存在的問題沪伙。有時開發(fā)團隊會直接放棄一遍又一遍的修復(fù)此類不可靠問題名挥,他們會跳過執(zhí)行該測試疟羹。這樣,我們將不能免于執(zhí)行回歸測試禀倔。單元測試通常會通過模擬所有依賴避免出現(xiàn)此類情況榄融,而功能測試有自己的實現(xiàn)方式。一個經(jīng)典的例子是在屏幕上加載從網(wǎng)絡(luò)上獲取的數(shù)據(jù)——在離線狀態(tài)下救湖,每次執(zhí)行測試都會失斃⒈!那么鞋既,我們要如何編寫可靠的功能測試而不受網(wǎng)絡(luò)狀況的影響呢力九?本文我將介紹一種使用 Dagger 創(chuàng)建簡潔且健壯的功能測試的方法耍铜。
什么是 Dagger
Dagger 已經(jīng)成為眾多 Android 開發(fā)者軍火庫中的必備工具,如果你還沒聽說過它——它是一個快速的依賴注入框架跌前,由 Square 開發(fā)棕兼,并針對 Android 做了特別優(yōu)化。不像其他流行的依賴注入器抵乓,Dagger 沒有使用反射伴挚,而是依靠生成代碼提高執(zhí)行速度。我們將在應(yīng)用中使用 Dagger 用一種簡潔的方法替代依賴灾炭,沒有破壞代碼封裝茎芋,也不會寫多余的只用于測試的代碼。還等什么呢咆贬!
天氣應(yīng)用
我們將會開發(fā)一個簡單的只有一個界面的天氣應(yīng)用來作為演示败徊。此應(yīng)用請求用戶提供城市名稱,然后下載該城市當(dāng)前天氣的信息掏缎。如下所示:
完整的源碼托管在 GitHub 上皱蹦。
OpenWeatherMap API
我們將會使用OpenWeatherMap API 來獲取天氣數(shù)據(jù)。此 API 是免費的眷蜈,但是如果你想要在自己機器上下載并編譯應(yīng)用沪哺,你需要注冊來獲取一個 API key。
設(shè)置 REST API client
下面我們來設(shè)置 REST API client 實現(xiàn)獲取數(shù)據(jù)功能酌儒。我們將會使用 Retrofit 配合 RxJava完成實現(xiàn)辜妓,所以需要將以下依賴加入到 build.gradle
中:
dependencies {
// rest of dependencies
compile 'com.squareup.retrofit:retrofit:1.9.0'
compile 'io.reactivex:rxandroid:1.0.1'
}
接下來是一個簡單的名為 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 請求的描述:
public interface WeatherApiClient {
Endpoint ENDPOINT = Endpoints.newFixedEndpoint("http://api.openweathermap.org/data/2.5");
@GET("/weather") Observable<WeatherData> getWeatherForCity(@Query("q") String cityName);
}
以上是針對網(wǎng)絡(luò)的設(shè)置。下面讓我們來配置 Dagger 使它能提供一個 WeatherApiClient
類的實現(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'
}
你可能注意到了我們在 apt
作用域中引入了 dagger-compiler
:因為 dagger-compiler
是一個注解處理器孽惰,我們只希望在編譯時期使用它而不想將它打包到 APK 中(就 dex 方法數(shù)限制而言 dagger-compiler
是十分龐大的)∨赣。可以使用 android-apt 插件來實現(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)在,我們得到了所有需要的依賴库说。下面我們會創(chuàng)建一個 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()
真實的創(chuàng)建了 WeatherApiClient
的實例潜的,并將其返回:每次我們請求它提供一個 WeatherApiClient
實例時骚揍,這段代碼都會被調(diào)用。太爽啦啰挪!現(xiàn)在我們添加一個 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
的實例以及 WeatehrApiClient
的實例纤掸,它還可以向 MainActivity
中注入依賴。
最后浑塞,我們需要實例化 AppComponent
并使它可被其他類使用。我們會將以下代碼加入到自定義的 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
}
);
}
}
請注意看我們?nèi)绾螌嵗?WeatherApiClient
的:我們沒有手動創(chuàng)建酌壕,而是使用注解 @Inject
標(biāo)記,并在 onCreate()
中做如下操作:
((WeatherApp) getApplication()).appComponent().inject(this);
通過訪問我們的 AppComponent
并將它注入到 MainActivity
中歇由, 我們使 Dagger 滿足了所有的依賴需求(通過使用 @Inject
標(biāo)記卵牍,它出色的完成的任務(wù))。接下來我們就可以使用 WeatherApiClient
獲取數(shù)據(jù)了沦泌。
盡管此方式乍看起來啰嗦且不簡明糊昙,它真正強大的地方在于我們不需要硬編碼創(chuàng)建依賴。這種優(yōu)勢將在下一步我們需要替代測試代碼中的依賴時突顯出來谢谦。
配置 Espresso
現(xiàn)在讓我們將 Espresso 集成到工程中释牺,并編寫測試驗證我們能否正常獲取數(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
没咙,因為我們的測試代碼也必須使用注解處理器執(zhí)行。接下來我們添加一個測試類:
@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())));
}
}
測試用例簡潔明了:我們想我為指定城市加載天氣數(shù)據(jù)并驗證數(shù)據(jù)是否正常顯示千劈。這在多數(shù)情況下應(yīng)該都是正常的祭刚,但想象以下如果在飛行模式下執(zhí)行呢?很可能會失斍脚啤涡驮!由于我們設(shè)計的測試用例時用來驗證應(yīng)用是否能正常顯示數(shù)據(jù),而不能聯(lián)網(wǎng)導(dǎo)致的數(shù)據(jù)缺失不是有效場景喜滨,該場景會使我們的測試失敗捉捅。另外,我們可能會編寫另一個測試用例來檢查在飛行模式下應(yīng)用的行為是否正澈枋校——如何使這兩個測試用例同時執(zhí)行通過呢锯梁?Dagger 可以搞定!讓我們利用依賴注入的力量焰情,提供一個可配置我們期望接收數(shù)據(jù)的 WeatherApiClient
的實現(xiàn)陌凳。
MockWeatherApiClient
我們的一個解決方案是一個返回硬編碼數(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
验游,我們不在需要依賴任何的外部狀況充岛,我們可以配置它來返回任何我們想要測試的數(shù)據(jù)保檐。接下來,我們將找出使 MockWeatherApiClient
可用的方法崔梗。
配置 Dagger 測試
我們需要模仿在我們應(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
的實現(xiàn)蒜魄,而是簡單的實例化了 MockWeatherApiClient
扔亥。接下來添加 TestAppComponent
:
@AppScope
@Component(modules = TestAppModule.class)
public interface TestAppComponent extends AppComponent {
void inject(MainActivityTest test);
}
TestAppComponent
繼承了 AppComonent
并添加了我們測試類需要的 inject()
方法。接下來修改測試類的 setUp()
方法:
@Before
public void setUp() {
((TestWeatherApp) activityTestRule.getActivity().getApplication()).appComponent().inject(this);
}
最后谈为,我們使用測試替身替換 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
旅挤。 類的接口保持不變,這意味著使用測試替身對應(yīng)用代碼毫無影響伞鲫。
我們現(xiàn)在配置完了 Dagger粘茄,但還遺漏了關(guān)鍵的一點:如何讓我們的測試使用 TestWeatherApp
而不是 WeatherApp
?答案是使用自定義測試執(zhí)行器秕脓!
實現(xiàn)自定義測試執(zhí)行器
用來執(zhí)行 Espresso 測試的 AndroidJUnitRunner
有一個便捷的方法 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í)行測試:
./gradlew connectedAndroidTest
至此撒会,我們已經(jīng)完成不受網(wǎng)絡(luò)影響執(zhí)行功能測試的配置嘹朗,并保證了測試結(jié)果正常執(zhí)行。請到 GitHub` 上查看本文用到的完整源碼诵肛。
結(jié)論
正如我在使用 Espresso 測試一個有序列表(中譯版)中所說屹培,有一套驗收測試是一種 catch regressions 的很好方式,并且保證了絕大多數(shù)的 bug 會被開發(fā)團隊發(fā)現(xiàn)怔檩,而不是終端用戶褪秀。那么保證你測試的可靠性就變得十分重要:不可靠的測試只會浪費團隊的時間去一遍一遍的修復(fù)它們,直到所有人都決定不去執(zhí)行這些測試薛训。
通過使用 Dagger 我們可以使代碼與依賴注入邏輯解耦媒吗,這將允許我們使用測試替身并且控制待測應(yīng)用的某些方面。本文描述了使用此技術(shù)允許在離線模式下執(zhí)行網(wǎng)絡(luò)相關(guān)的測試乙埃,并保證它們正常執(zhí)行闸英。值得一提的是,此方法不適用于端對端的測試介袜,因為我們沒有像用戶一樣在真實環(huán)境中測試應(yīng)用甫何。然而,這仍然是一個非常有效的執(zhí)行功能測試的方法遇伞,也使你能很靈活的測試應(yīng)用各方面邏輯辙喂。
你有在自己的 Espresso 中使用 Dagger 嗎?希望能與你交流。如果你有任何反饋或發(fā)現(xiàn)文中錯誤巍耗,歡迎留言或直接與我聯(lián)系秋麸,祝好!