iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 21
0
Software Development

Android Architecture系列 第 21

Test part 5:View

  • 分享至 

  • xImage
  •  

今天是測試的最後一天,要對架構最上層的Activity/Fragment這些View做測試。

不同於前幾天的unit test只針對邏輯做測試,View會用intergration test的方式在Android裝置上測試邏輯以及元件的互動,例如:ViewModel發送讀取中的資料時,除了驗證LiveData收到資料之外,還要驗證元件ProgressBar是顯示的。不同類型的test差異可以參考官方文件

今天主要會提到的有兩個部分:

  1. Disable Dagger2:Google在integration test sample中將Dagger2關閉了,改成手動增加幾個輔助的util來完成測試,我們會把這種作法從頭做一次。
  2. Espresso:Google官方的UI test library,提供元件click()或檢查isDisplayed()等多種輔助方法。

Disable Dagger2

不同於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

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 with Espresso

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?


上一篇
Test part 4:ViewModel
下一篇
小結
系列文
Android Architecture30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言