AndroidX Test with Robolectric

2,585 阅读8分钟

Shadow

Robolectric 的本质是在 Java 运行环境下,采用 Shadow 的方式对 Android 中的组件进行模拟测试,从而实现Android 单元测试。对于一些 Robolectirc 暂不支持的组件,可以采用自定义 Shadow 的方式扩展 Robolectric 的功能。

Shadow是通过对真实的 Android 对象进行函数重载、初始化等方式对 Android 对象进行扩展,Shadow 出来的对象的功能接近 Android 对象,可以看成是对 Android 对象一种修复。自定义的 Shadow 需要在 config 中声明,声明写法是 @Config(shadows=ShadowPoint.class)。

@Implements(Point.class)
public class ShadowPoint {
  @RealObject private Point realPoint;
  ...
  public void __constructor__(int x, int y) {
    realPoint.x = x;
    realPoint.y = y;
  }
}

上述实例中,@Implements 是声明 Shadow 的对象,@RealObject 是获取一个 Android 对象,**constructor **则是该 Shadow 的构造函数,Shadow 还可以修改一些函数的功能,只需要在重载该函数的时候添加@Implementation,这种方式可以有效扩展 Robolectric 的功能。

Robolectric 官网对 shadow 的介绍

Mock写法介绍

对于一些依赖关系复杂的测试对象,可以采用Mock框架解除依赖,常用的有Mockito

例如Mock一个List类型的对象实例,可以采用如下方式:

List list = mock(List.class);   //mock得到一个对象,也可以用 @mock 注入一个对象

所得到的 list 对象实例便是 List 类型的实例,如果不采用 mock,List 其实只是个接口,我们需要构造或者借助ArrayList 才能进行实例化。与 Shadow 不同,Mock 构造的是一个虚拟的对象,用于解耦真实对象所需要的依赖。Mock得到的对象仅仅是具备测试对象的类型,并不是真实的对象,也就是并没有执行过真实对象的逻辑。

List list = mock(List.class); //  也可以使用 @Mock 注解注入一个对象
list.add(1);      
verify(list).add(1);  //验证这个函数的执行 
verify(list,time(3)).add(1); //验证这个函数的执行次数
when(list.get(1)).thenReturn(10);//指定当执行了这个方法的时候,返回 thenReturn 的值
assertEquals(list[1], 10)

常用 Rule

  • ActivityTestRule

    使用 ActivityTestRule,测试框架会在带有 @Test 注解的每个测试方法运行之前以及带有 @Before 注解的所有方法运行之前启动被测 Activity,并在测试完成并且带有 @After 注解的所有方法都运行后关闭该 Activity;也可以手动调用 ActivityTestRule.launchActivity() 和 ActivityTestRule.finishActivity()。

  • ActivityScenarioRule

    作为 ActivityTestRule 的替代,在测试方法之前启动指定的 Activity,并在测试方法之后结束该 Activity。同时可以在测试方法中获得 ActivityScenario,可切换 Activity 的生命周期;ActivityScenario 是一种跨平台 API,可用于本地单元测试和设备端集成测试等;在真实或虚拟设备上,ActivityScenario 可提供线程安全,在测试的插桩线程和运行被测 Activity 的线程之间同步事件。

  • IntentsTestRule

    是 ActivityTestRule 的子类,IntentsTestRule 类会在每次测试前初始化 Espresso Intents。

  • ServiceTestRule

    调用 ServiceTestRule.startService() 或者 ServiceTestRule.bindService() 在测试方法中建立Service连接,在测试结束后会自动关闭 Service。不适用于 IntentService,可以对其他 Service 进行测试。

  • GrantPermissionRule

    帮助在 Android API 23及以上的环境申请运行时权限。申请权限时可以避免用户交互弹窗占用UI测试焦点。最终会调用 PermissionRequester.requestPermissions() 方法,通过执行UiAutomationShellCommand 直接在 shell 中为当前 target 申请权限。

Intent

  1. 启动 Espresso Intents

    1. 使用 ActivityTestRule 启动需要手动在测试方法运行先初始化 Espresso Intents;

      @Rule
      public ActivityTestRule activityTestRule = new ActivityTestRule<>(MainActivity.class);
          
      @Before
      public void setUp() {
          Intents.init();
      }
          
      @After
      public void release() {
          Intents.release();
      }
      
    2. 使用 IntentsTestRule 启动,框架会自动初始化 Espresso Intents

      @Rule
      public IntentsTestRule intentsTestRule = new IntentsTestRule<>(MainActivity.class);
      
  2. 测试 Intent 逻辑

    • 主动跳转

      通过测试代码触发点击并判断跳转目标

      onView(withId(R.id.btn)).perform(click());
      intended(hasComponent(SecondActivity.class.getName()));
      
    • 自动跳转

      如果是在 Activity 的 onCreate 或 onResume 中自动跳转,直接通过 intended 判断会失败,需要使用 ActivityTestRule 并且在 intended 判断之前手动调用 activityTestRule.launchActivity(Intent()),不能使用 IntentsTestRule。

      activityTestRule.launchActivity(new Intent());
      intended(hasComponent(SecondActivity.class.getName()));
      

多线程

Handler、AsyncTask 和 runOnUiThread 等

使用 Handler、AsyncTask 和 runOnUiThread 执行的异步操作,在运行测试时通过 ShadowLooper 的 runUiThreadTasks() 或 runUiThreadTasksIncludingDelayedTasks() 方法让在排队的任务立即执行。

// 业务代码
new Handler().postDelayed(new Runnable() {
    @Override
    public void run() {
        ...
    }
}, 1000);

// 测试代码
ShadowLooper.runUiThreadTasksIncludingDelayedTasks()
intended(hasComponent(MainActivity::class.java.name))

RxJava

使用 RxJava 完成异步的代码,测试时需要利用 RxJava 的 TestScheduler 来调度异步任务,可通过自定义 TestRule 方式来实现。

public class RxJavaTestSchedulerRule implements TestRule {
    private final TestScheduler mTestScheduler = new TestScheduler();

    public TestScheduler getTestScheduler() {
        return mTestScheduler;
    }

    @Override
    public Statement apply(final Statement base, Description description) {
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                RxJavaPlugins.setIoSchedulerHandler(scheduler -> mTestScheduler);

                RxJavaPlugins.setNewThreadSchedulerHandler(scheduler -> mTestScheduler);

                RxJavaPlugins.setComputationSchedulerHandler(scheduler -> mTestScheduler);

                RxAndroidPlugins.setMainThreadSchedulerHandler(scheduler -> mTestScheduler);

                try {
                    base.evaluate();
                } finally {
                    RxJavaPlugins.reset();
                    RxAndroidPlugins.reset();
                }
            }
        };
    }
}

通过自定义 TestRule,在运行测试方法之前将 RxJava 或 RxAndroid 提供的 Scheduler 全部设置为 TestScheduler,并对外提供该 TestScheduler,在测试方法中获取该 TestScheduler 并通过 advanceTimeTo() 或 advanceTimeBy() 完成调度。

  • advanceTimeTo(): 将 Scheduler 的时钟移到特定时间
  • advanceTimeBy(): 将 Scheduler 的时钟在当前的基础上向前移动指定的时间量
@Rule
public RxJavaTestSchedulerRule rxJavaTestSchedulerRule = new RxJavaTestSchedulerRule();

rxJavaTestSchedulerRule.getTestScheduler().advanceTimeTo(10, TimeUnit.SECONDS);

Coroutine

在 gradle 中添加依赖

 testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.7'

参考Kotlin Coroutine 测试官方wiki

网络

在对具有网络请求的模块编写测试代码时,我们需要能够自定义网络响应内容,不能依赖真实网络请求。

HttpURLConnection

需要在 gradle 中添加依赖

testCompile 'org.robolectric:shadows-httpclient:4.3.1' 

使用 HttpURLConnection 进行网络请求的项目,可以使用 Robolectric 框架自带的 FakeHttp 来完成模拟网络请求

//设置拦截真实请求
FakeHttp.getFakeHttpLayer().interceptHttpRequests(true);
//模拟返回
ProtocolVersion version = new ProtocolVersion("HTTP", 1, 1);
HttpResponse httpResponse = new BasicHttpResponse(version, 400, "OK");
//设置默认返回,所有请求返回这个
FakeHttp.setDefaultHttpResponse(httpResponse);
//添加一个返回规则,指定一个请求的期望返回
FakeHttp.addHttpResponseRule("http://www.baidu.com", httpResponse);

接下来当发起网络请求 www.baidu.com 时,就会返回我们制定的响应内容。

OkHttp

使用 OkHttp 完成网络请求,可以使用 OkHttp 提供的 MockWebServer 来搭建本地的模拟服务器,自定义网络请求响应内容。

private MockWebServer mockWebServer = new MockWebServer();

@Before
public void setUp() throws IOException {
    mockWebServer.start(55555);
}
@After
public void release() throws IOException {
    mockWebServer.shutdown();
}

@Test
public void test() {
    mockWebServer.enqueue(
            new MockResponse()
                    .addHeader("key", "value")
                    .setBody("body")
    );
}

使用 MockWebServer 的 start() 方法启动模拟服务器,可以指定 host 或 port,host 默认为:localhost,port 默认自动生成;在构建 Retrofit 的时候将 baseUrl 设置为模拟服务器的地址。

new Retrofit.Builder()
    .baseUrl("http://localhost:55555/")
    .build()
    .create();
}

通过 MockWebServer 的 enqueue() 方法往响应队列里添加自定义响应,先添加的先响应。添加完成后,最近的一次请求将会返回我们自定义的响应内容。

Fragment Test

添加依赖 【这里有个坑,debugImlementation 不能替换为 testImplementation】

debugImlementation 'androidx.fragment:fragment-testing:1.2.3'

AndroidX 提供了一个 FragmentScenario 库,便于您创建 Fragment 并更改其状态。

这些方法还支持以下类型的 Fragment:

  • 包含界面的图形 Fragment。要启动此类 Fragment,请调用 launchFragmentInContainer()FragmentScenario 将 Fragment 附加到 Activity 的根视图控制器。此托管 Activity 原本为空。
  • 非图形 Fragment(有时称为“无头 Fragment”),用于存储若干 Activity 中包含的信息或对这些信息进行短期处理。要启动此类 Fragment,请调用 launchFragment()FragmentScenario 将此类 Fragment 附加到一个完全为空的 Activity,即没有根视图的 Activity。

启动Fragment 后,FragmentScenario 将被测 Fragment 推动到 RESUMED 状态。此状态表示 Fragment 正在运行。

在更精细的单元测试中,您还可以评估 Fragment 从一个生命周期状态转换到另一个生命周期状态时的行为。要将 Fragment 推动到其他生命周期状态,请调用 moveToState()。此方法支持以下状态作为参数:CREATEDSTARTEDRESUMEDDESTROYED

    @RunWith(AndroidJUnit4::class)
    class MyTestSuite {
        @Test fun testEventFragment() {
            val scenario = launchFragmentInContainer<MyFragment>()
            scenario.recreate()
        }
    }

更多详情可查看官方文档测试应用的 Fragment

ViewModel Test

ViewModel 的测试以验证逻辑为主,通过 mock 出依赖的环境和返回内容,模拟业务逻辑。一般情况下,ViewModel 是与 LiveData 协同作业的,这时候我们重点要验证 ViewModel 中方法被调用后,与之相关的 LiveData 的执行情况。

public class NewsViewModel extends ViewModel {

    private CompositeDisposable disposable;
    private final NewsApiClient apiClient;
    private final RxSingleSchedulers rxSingleSchedulers;
    private final MutableLiveData<NewsListViewState> newsListState = new MutableLiveData<>();

    public MutableLiveData<NewsListViewState> getNewsListState() {
        return newsListState;
    }

    @Inject
    public NewsViewModel(NewsApiClient apiClient, RxSingleSchedulers rxSingleSchedulers) {
        this.apiClient = apiClient;
        this.rxSingleSchedulers = rxSingleSchedulers;
        disposable = new CompositeDisposable();
    }

    public void fetchNews() {
        disposable.add(apiClient.fetchNews()
                .doOnEvent((newsList, throwable) -> onLoading())
                .compose(rxSingleSchedulers.applySchedulers())
                .subscribe(this::onSuccess,
                        this::onError));
    }

    private void onSuccess(NewsList newsList) {
        NewsListViewState.SUCCESS_STATE.setData(newsList);
        newsListState.postValue(NewsListViewState.SUCCESS_STATE);
    }

    private void onError(Throwable error) {
        NewsListViewState.ERROR_STATE.setError(error);
        newsListState.postValue(NewsListViewState.ERROR_STATE);
    }

    private void onLoading() {
        newsListState.postValue(NewsListViewState.LOADING_STATE);
    }

    @Override
    protected void onCleared() {
        super.onCleared();
        if (disposable != null) {
            disposable.clear();
            disposable = null;
        }
    }
}

Test 如下:

@RunWith(JUnit4.class)
public class NewsViewModelTest {
    @Rule
    public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule();

    @Mock
    ApiEndPoint apiEndPoint;
    @Mock
    NewsApiClient apiClient;
    private NewsViewModel viewModel;
    @Mock
    Observer<NewsListViewState> observer;


    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        viewModel = new NewsViewModel(apiClient, RxSingleSchedulers.TEST_SCHEDULER);
        viewModel.getNewsListState().observeForever(observer);
    }

    @Test
    public void testNull() {
        when(apiClient.fetchNews()).thenReturn(null);
        assertNotNull(viewModel.getNewsListState());
        assertTrue(viewModel.getNewsListState().hasObservers());
    }

    @Test
    public void testApiFetchDataSuccess() {
        // Mock API response
        when(apiClient.fetchNews()).thenReturn(Single.just(new NewsList()));
        viewModel.fetchNews();
        verify(observer).onChanged(NewsListViewState.LOADING_STATE);
        verify(observer).onChanged(NewsListViewState.SUCCESS_STATE);
    }

    @Test
    public void testApiFetchDataError() {
        when(apiClient.fetchNews()).thenReturn(Single.error(new Throwable("Api error")));
        viewModel.fetchNews();
        verify(observer).onChanged(NewsListViewState.LOADING_STATE);
        verify(observer).onChanged(NewsListViewState.ERROR_STATE);
    }

    @After
    public void tearDown() throws Exception {
        apiClient = null;
        viewModel = null;
    }

ListView & RecyclerView 测试

ListView 使用Espresso.onData(Matcher dataMatcher),您将提供一个Matcher,它将尝试匹配Adapter中的一行。 如果成功匹配,则Espresso会将该行带到屏幕上并进入视图层次结构,以便您可以照常执行操作并检查其视图上的断言:

onData(Matcher dataMatcher)
   .perform(ViewAction action)
   .check(ViewAssertion assert)

RecyclerView 通过 RecyclerViewActions 滑动到指定 item

onView(withId(R.id.myRecyclerView))
    .perform(
        RecyclerViewActions.actionOnItemAtPosition(0, click())
    );

更多信息可以参考AdapterViews and Espresso

获取 Context 的几种姿势
ApplicationProvider.getApplicationContext()

InstrumentationRegistry.getInstrumentation().targetContext

RuntimeEnvironment.application
Gradle 依赖集合
def espresso = [:]
espresso.core = "androidx.test.espresso:espresso-core:$versions.espresso"
espresso.contrib = "androidx.test.espresso:espresso-contrib:$versions.espresso"
espresso.intents = "androidx.test.espresso:espresso-intents:$versions.espresso"
deps.espresso = espresso

def robolectric = [:]
robolectric.multidex = "org.robolectric:shadows-multidex:4.0.1"
robolectric.robolectric = "org.robolectric:robolectric:4.3.1"
robolectric.shadows_supportv4 = "org.robolectric:shadows-supportv4:3.8"
robolectric.httpclient = 'org.robolectric:shadows-httpclient:4.3.1' 
deps.robolectric = robolectric

def powermock = [:]
powermock.junit4 = "org.powermock:powermock-module-junit4:$versions.powermock"
powermock.junit4rule = "org.powermock:powermock-module-junit4-rule:$versions.powermock"
powermock.mockito = "org.powermock:powermock-api-mockito:$versions.powermock"
powermock.xstream = "org.powermock:powermock-classloading-xstream:$versions.powermock"
deps.powermock = powermock

def coroutines = [:]
coroutines.core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines"
coroutines.coroutinestest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$versions.coroutines"
deps.coroutines = coroutines

def squareup = [:]
squareup.mockwebserver="com.squareup.okhttp3:mockwebserver:4.7.2"
deps.squareup=squareup

deps.fragmenttest = "androidx.fragment:fragment-testing:$versions.fragment"
deps.assertj = "org.assertj:assertj-core:3.15.0"

testImplementation deps.espresso.core
testImplementation deps.espresso.contrib
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation deps.espresso.core
testImplementation deps.espresso.intents
testImplementation deps.assertj
testImplementation deps.powermock.junit4
testImplementation deps.powermock.junit4rule
testImplementation deps.powermock.mockito
testImplementation deps.powermock.xstream
testImplementation deps.robolectric.robolectric
testImplementation deps.robolectric.multidex
testImplementation deps.robolectric.shadows_supportv4
testImplementation deps.robolectric.httpclient
debugImplementation deps.fragmenttest
testImplementation coroutines.coroutinestest
testImplementation deps.squareup.mockwebserver

参考内容:

robolectric官网

测试 Singleton

Unit Testing for ViewModel

Robolectric 踩坑指南

Android单元测试研究与实践

官方文档