今天是測試的最後一天,要對架構最上層的Activity/Fragment這些View做測試。
不同於前幾天的unit test只針對邏輯做測試,View會用intergration test的方式在Android裝置上測試邏輯以及元件的互動,例如:ViewModel發送讀取中的資料時,除了驗證LiveData收到資料之外,還要驗證元件ProgressBar是顯示的。不同類型的test差異可以參考官方文件。
今天主要會提到的有兩個部分:
click()
或檢查isDisplayed()
等多種輔助方法。不同於unit test,執行integration test時會在裝置上開啟Activity跑測試,而Activity不是由constructor建立的所以我們無法將mock的物件以constructor parameter傳入。一般處理這種情況的方式是寫另外的Module和Component來模擬DI,可參考官方文件Testing with Dagger。
Google sample則用了不一樣的方式,在integration test中直接關閉Dagger。初見時感到相當神奇,自己實作之後覺得還蠻有趣,那就開始吧。
首先在androidTest中建立TestApp,用意是取代GithubApp將其中Dagger初始化的段落移除。
public class TestApp extends Application {
@Override
public void onCreate() {
super.onCreate();
}
}
接著建立TestRunner,在其中設置TestApp作為執行androidTest時的App。
public class GithubTestRunner extends AndroidJUnitRunner {
@Override
public Application newApplication(ClassLoader cl, String className, Context context)
throws InstantiationException, IllegalAccessException, ClassNotFoundException {
return super.newApplication(cl, TestApp.class.getName(), context);
}
}
將build.gradle
中的testInstrumentationRunner換成剛建立的TestRunner,就完成設置以TestApp做為測試時使用的App了。
android {
defaultConfig {
...
testInstrumentationRunner "ivankuo.com.itbon2018.util.GithubTestRunner"
}
}
已經取消了Dagger的初始化,再來要處理兩個地方:Activity和ViewModelFacory。
建立一個未使用Dagger的測試用Activity讓其作為Fragment的容器,我們要將初始化好的Fragment置入因此要有setFragment(Fragment fragment)
的功能。使用置入的方式,Fragment的生命週期onActivityCreated
會在置入後才執行,而屆時我們已經mock好其所需的物件了,否則物件會是null導致crash。
在src路徑中新增debug資料夾,建立測試用Activity。
/**
* Used for testing fragments inside a fake activity.
*/
public class SingleFragmentActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
FrameLayout content = new FrameLayout(this);
content.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
content.setId(R.id.container);
setContentView(content);
}
public void setFragment(Fragment fragment) {
getSupportFragmentManager().beginTransaction()
.add(R.id.container, fragment, "TEST")
.commit();
}
public void replaceFragment(Fragment fragment) {
getSupportFragmentManager().beginTransaction()
.replace(R.id.container, fragment).commit();
}
}
最後是ViewModelFactory,原本使用Dagger的MultiBindings功能來回傳ViewModel,在關閉Dagger之後當然不能用了,但沒關係,在View的測試中我們會直接mock ViewModel,所以只要建個Util產生Factory就好。
/**
* Creates a one off view model factory for the given view model instance.
*/
public class ViewModelUtil {
private ViewModelUtil() {}
public static <T extends ViewModel> ViewModelProvider.Factory createFor(final T model) {
return new ViewModelProvider.Factory() {
@Override
public <T extends ViewModel> T create(Class<T> modelClass) {
if (modelClass.isAssignableFrom(model.getClass())) {
return (T) model;
}
throw new IllegalArgumentException("unexpected model class " + modelClass);
}
};
}
}
最後的最後,加入dependencies,在integration test使用Mockito要用mockito-android
androidTestImplementation "org.mockito:mockito-android:2.13.0"
OK,這樣就可以對Fragment寫邏輯的測試了,只有邏輯喔,元件要待會用Espresso。不過其實邏輯也沒什麼好測的,因為View本來就不太帶有邏輯,就當成是了解剛剛建立的class們的用法吧。
@RunWith(AndroidJUnit4.class)
public class RepoFragmentTest {
@Rule
public ActivityTestRule<SingleFragmentActivity> activityRule =
new ActivityTestRule<>(SingleFragmentActivity.class, true, true);
private RepoViewModel viewModel;
private MutableLiveData<Resource<List<Repo>>> repos = new MutableLiveData<>();
@Before
public void init() {
RepoFragment repoFragment = new RepoFragment();
viewModel = mock(RepoViewModel.class);
when(viewModel.getRepos()).thenReturn(repos);
repoFragment.factory = ViewModelUtil.createFor(viewModel);
activityRule.getActivity().setFragment(repoFragment);
}
@Test
public void search() {
viewModel.searchRepo("foo");
verify(viewModel).searchRepo("foo");
}
}
ActivityTestRule
讓測試在SingleFragmentActivity上執行,在init()
時實例化fragment,用mock及Util準備好它原本透過Dagger取得的物件,最後用setFragment
加至Activity中。
Espresso的用途是測試view如Button、EditText等元件,一般的用法很容易上手,直接看code比較清楚。
@Test
public void search() {
onView(withId(R.id.progressBar)).check(matches(not(isDisplayed())));
onView(withId(R.id.edtQuery)).perform(typeText("foo"));
onView(withId(R.id.btnSearch)).perform(click());
verify(viewModel).searchRepo("foo");
}
onView(withId(...))
取得目標對象,並用check確認其狀態或用perform執行動作。
那比較一般的用法就先略過,我們特別看幾個須調整的地方,首先是ProgressBar,當我們測試它顯示時可以這樣寫:
@Test
public void search() {
onView(withId(R.id.progressBar)).check(matches(not(isDisplayed())));
onView(withId(R.id.edtQuery)).perform(typeText("foo"));
onView(withId(R.id.btnSearch)).perform(click());
verify(viewModel).searchRepo("foo");
repos.postValue(Resource.loading(null));
onView(withId(R.id.progressBar)).check(matches(isDisplayed()));
}
在loading時用check(matches(isDisplayed()))
確認ProgressBar是顯示的,寫法沒有問題,但執行後你會發現...
它就這樣一直轉,一直轉,測試都沒有完成並在一陣子之後失敗。
這是因為Espresso還無法處理動畫,解決方式一是在開發人員選項將裝置的動畫關閉,二是將ProgressBar的動畫用別的圖片取代,例如:
progressBar.setIndeterminateDrawable(new ColorDrawable(Color.BLUE));
Google sample中有個EspressoUtil,會檢查畫面上所有的view,若為ProgressBar就置換其動畫為圖片。使用Util之後,測試就可以正常執行了。
@RunWith(AndroidJUnit4.class)
public class RepoFragmentTest {
...
@Before
public void init() {
EspressoTestUtil.disableProgressBarAnimations(activityRule);
...
}
@Test
public void search() {
onView(withId(R.id.progressBar)).check(matches(not(isDisplayed())));
onView(withId(R.id.edtQuery)).perform(typeText("foo"));
onView(withId(R.id.btnSearch)).perform(click());
verify(viewModel).searchRepo("foo");
repos.postValue(Resource.<List<Repo>>loading(null));
onView(withId(R.id.progressBar)).check(matches(isDisplayed()));
}
}
若要測試RecyclerView Item有沒有正確顯示,可以用RecyclerViewMatcher,詳細介紹可以看作者的blog post。
使用方式:
@RunWith(AndroidJUnit4.class)
public class RepoFragmentTest {
...
@Test
public void loadResults() {
Repo repo = TestUtil.createRepo("foo", "bar", "desc");
repos.postValue(Resource.success(Arrays.asList(repo)));
onView(listMatcher().atPosition(0)).check(matches(hasDescendant(withText("foo/bar"))));
onView(withId(R.id.progressBar)).check(matches(not(isDisplayed())));
}
@NonNull
private RecyclerViewMatcher listMatcher() {
return new RecyclerViewMatcher(R.id.recyclerView);
}
}
Architecture Components有一些地方是在background thread執行,例如Room和LiveData的搭配,我們可以將這些background thread註冊成Espresso的IdlingResource,這樣Espresso會等thread執行完才往下進行,以免非同步造成的測試失敗。
加入dependencies:
androidTestImplementation "android.arch.core:core-testing:1.0.0"
建立TaskExecutorWithIdlingResourceRule:
/**
* A Junit rule that registers Architecture Components' background threads as an Espresso idling
* resource.
*/
public class TaskExecutorWithIdlingResourceRule extends CountingTaskExecutorRule {
private CopyOnWriteArrayList<IdlingResource.ResourceCallback> callbacks =
new CopyOnWriteArrayList<>();
@Override
protected void starting(Description description) {
Espresso.registerIdlingResources(new IdlingResource() {
@Override
public String getName() {
return "architecture components idling resource";
}
@Override
public boolean isIdleNow() {
return TaskExecutorWithIdlingResourceRule.this.isIdle();
}
@Override
public void registerIdleTransitionCallback(ResourceCallback callback) {
callbacks.add(callback);
}
});
super.starting(description);
}
@Override
protected void onIdle() {
super.onIdle();
for (IdlingResource.ResourceCallback callback : callbacks) {
callback.onTransitionToIdle();
}
}
}
使用方式跟其他@Rule
一樣。
@RunWith(AndroidJUnit4.class)
public class RepoFragmentTest {
@Rule
public TaskExecutorWithIdlingResourceRule executorRule =
new TaskExecutorWithIdlingResourceRule();
...
}
Espresso基本用法不難,但偶爾會卡一些小地方像上面提到的ProgressBar問題,還好身為預設的integration test library它的使用率非常高,網路上幾乎都能找到解法。
個人覺得Integration test一般而言會比Unit test難寫,因為View運作起來會跑完生命週期,不像ViewModel那些只是靜態的等我們呼叫,但寫integration test也是我覺得收穫最多的地方,當發現test很難寫的時候就會思考程式是不是還有重構的空間,進而修正讓程式品質提升,建議大家還沒寫過的話都可以嘗試看看。
GitHub source code:
https://github.com/IvanBean/ITBon2018/tree/day21-test-view
Reference:
Android UI Test: Mocking the ViewModel with or without Dagger?