本文主要介绍 Android 中构建快速可靠的 UI 测试, 这里整理了相关资料及相关代码,有兴趣的小伙伴可以参考下
Android 是一种基于 Linux 的自由及开放源代码的操作系统,主要使用于移动设备,如智能手机和平板电脑,由 Google 公司和开放手机联盟领导及开发。尚未有统一中文名称,中国大陆地区较多人使用 "安卓" 或 "安致"。
前言
让我一起来看看 Iván Carballo 和他的团队是如何使用 Espresso, Mockito 和 Dagger 2 编写 250 个 UI 测试,并且只花了三分钟就运行成功的。
在这篇文章中,我们会探索如何使用 Mockito(译者注:Mockito 是 java 编写的一个单元测试框架),Dagger 2 去创建快速可靠的 Android UI 测试。如果你正在开始编写 Android 中的 UI 测试或者希望改善已有测试性能的开发者,那么这篇文章值得一读。
我第一次在安卓应用中使用 UI 自动化测试是在几年前使用 Robotium(译者注:Robotium 是 android 中的一个自动化测试框架)。我认为测试环境越逼真越好。在最终测试中应当表现得如同超人一般能够迅速的点击任意一个位置而且并不会报错,对吧?我认为 mocking 测试很糟糕。为什么我们需要在测试的时候改变应用的行为? 那不是欺骗吗?几个月后我们有了大概 100 个测试用例要花费 40 分钟去运行起来。它们是如此的不稳定,即使应用的功能上并没有任何错误,通常有一大半的几率会运行失败。我们花了大量的时间去编写它们,但是这些测试用例却没有帮我们找到任何问题。
但正如 John Dewey 所说,失败是具有启发意义的。
失败是有启发意义的。智者总能从失败和成功中学到同样多的东西。
我们确实学到。我们认识到在测试中依赖于真实的 API 接口是一个糟糕的做法。因为你失去了对返回的数据结果的控制,你也就不能对你的测试做预先处理。也就是说网络错误和外部 API 接口错误都会导致你的测试出错。如果你的 wifi 出错了,你肯定不希望你的测试也会跟着出错。你当然希望这时 UI 测试能够成功运行。如果你还依赖外部的 API 接口那么你完全是在做集成测试(integration tests),也就得不到我们期望的结果。
Mock 测试正式解决之道
(Mocking is the solution)
Mock 测试也就是通过一个模拟(mock)的对象去替换一个真实的对象以便于测试。它主要应用于编写单元测试,但在 UI 测试中也会非常有用。你可以参照不同的方法去模拟 java 对象但使用 Mockito 确实是一个简单有效的解决方案。在下面的例子中你可以看到一个模拟的 UserApi 类并且 stub(译者注:stub,也即 "桩",主要出现在集成测试的过程中,从上往下的集成时,作为下方程序的替代。可以理解为对方法进行预先的处理,达到修改的效果。下文中不做翻译)了其中的一个方法,因此它总会返回一个用户名 username 的静态数组。
- class UsersApi {
- String[] getUserNames() {}
- }
- // Create the mock version of a UsersApi class
- UsersApi mockApi = Mockito.mock(UsersApi.class);
- // Stub the getUserNames() method
- when(mockApi.getUserNames()).thenReturn(new String[] {
- "User1",
- "User2",
- "User3"
- });
- // The call below will always return an array containing the
- // three users named above
- mockApi.getUserNames();
一旦你创建了一个 mock 对象你需要确保应用测试的时候使用的是这个模拟的对象,并且在运行的时候使用的是真实对象。这也是一个难点所在,如果你的代码构建得并不是易于测试(test-friendly)的,替换真实对象的过程会变得异常艰难甚至是说不可能完成。还要注意的是,你想要模拟的代码必须独立到一个单独的类里面。比如说,如果你直接从你的 activity 中使用 HttpURLConnection 调用 REST API 进行数据访问(我希望你不要这么做), 这个操作过程模拟起来也就会非常困难。
在测试之前考虑一下系统架构,糟糕的系统架构往往会导致测试用例和 mock 测试难于编写,mock 测试也会变得不稳定。
一个易于测试的架构
A test friendly architecture
构建一个易于测试的架构有许多种方式。在这里我将使用 ribot 中使用的架构 (译者注:也就是在开篇提到的 Android 应用架构)作为范例,你也可以应用这样的架构方式到任何架构中。我们的架构是基于 MVP 模式,我们决定在 UI 测试中去模拟(mock)整个 Model 层,因此我们可以对数据由更多的操作性,也就能够写出更有价值和可靠的测试。
MVP 架构
DataManager 是 Model 层中唯一暴露给 Presenter 层的数据的类,因此为了测试 Model 层我们只需要替换为一个模拟
的 DataManger 即可。
使用 Dagger 注入模拟的 DataManager
Using Dagger to inject a mock DataManager
一旦我们明确了需要模拟什么对象,那么接下来就该考虑在测试中如何替换真实的对象。我们通过 Dagger2 解决这个问题(一个 Android 中的依赖注入框架),如果你还没有接触过 Dagger ,在继续阅读下去之前我建议你阅读使用 Dagger2 进行依赖注入【英】 。我们的应用至少包含一个 Dagger 的 Module 和 Component。通常被叫做 ApplicationComponent 和 ApplicationModule。你可以在下面看到一个简化版的只提供了 DataManger 实例的类。当然你也可以采用第二种方法,在 DataManager 的构造函数上使用 @inject 注解。这里我直接提供一个方法便于理解。(译者注:这里将两个类 ApplicationComponent 和 ApplicationModule 写在一起,便于直观理解)
- @Module
- public class ApplicationModule {
- @Provides
- @Singleton
- public DataManager provideDataManager() {
- return mDataManager;
- }
- }
- @Singleton
- @Component(modules = ApplicationModule.class)
- public interface ApplicationComponent {
- DataManager dataManager();
- }
应用的 ApplicationComponent 在 Application 类中初始化:
- public class MyApplication extends Application {
- ApplicationComponent mApplicationComponent;
- public ApplicationComponent getComponent() {
- if (mApplicationComponent == null) {
- mApplicationComponent = DaggerApplicationComponent.builder()
- .applicationModule(new ApplicationModule(this))
- .build();
- }
- return mApplicationComponent;
- }
- // Needed to replace the component with a test specific one
- public void setComponent(ApplicationComponent applicationComponent) {
- mApplicationComponent = applicationComponent;
- }
- }
如果你使用过 Dagger2,你可能有同样的配置步骤,现在的做法是创建一个 test 的时候需要用到的 Module 和 Component
- @Module
- public class TestApplicationModule {
- // We provide a mock version of the DataManager using Mockito
- @Provides
- @Singleton
- public DataManager provideDataManager() {
- return Mockito.mock(DataManager.class);
- }
- }
- @Singleton
- @Component(modules = TestApplicationModule.class)
- public interface TestComponent extends ApplicationComponent {
- // Empty because extends ApplicationComponent
- }
上面的 TestApplicationModule 使用 Mockito 提供了模拟的 DataManger 对象,TestComponent 是 ApplicationComponent 的继承类,使用了 TestApplicationModule 作为 module, 而不是 ApplicationModule。这也就意味着如果我们在我们的 Application 类中初始化 TestComponent 会使用模拟的 DataManager 对象。
创建 JUnit,并且设定 TestComponent
Creating a JUnit rule that sets the TestComponent
为了确保在每次测试前 TestComponent 被设置到 Application 类中,我们可以创建 JUnit 4 的 TestRule
- public class TestComponentRule implements TestRule {
- private final TestComponent mTestComponent;
- private final Context mContext;
- public TestComponentRule(Context context) {
- mContext = context;
- MyApplication application = (MyApplication) context.getApplicationContext();
- mTestComponent = DaggerTestComponent.builder()
- .applicationTestModule(new ApplicationTestModule(application))
- .build();
- }
- public DataManager getMockDataManager() {
- return mTestComponent.dataManager();
- }
- @Override
- public Statement apply(final Statement base, Description description) {
- return new Statement() {
- @Override
- public void evaluate() throws Throwable {
- MyApplication application = (MyApplication) context.getApplicationContext();
- // Set the TestComponent before the test runs
- application.setComponent(mTestComponent);
- base.evaluate();
- // Clears the component once the tets finishes so it would use the default one.
- application.setComponent(null);
- }
- };
- }
- }
TestComponentRule 将会创建 TestComponent 的实例对象,这也就会覆写 apply 方法并返回一个新的 Statement,新的 Statement 会:
1 设定 TestComponent 给 Application 类的 component 对象。
2 调用基类的 Statement 的 evaluate() 方法(这是在 test 的时候执行)
3 设置 Application 的 component 字段为空,也就让其恢复到初始状态。我们能够通过这种方式预防测试用例之间的相互影响
通过上面的代码我们可以通过 getMockDataManager() 方法获取模拟的 DataManager 对象。这也就允许我们能够给得到 DataManager 对象并且 stub 它的方法。需要注意的是,这只有 TestApplicationComponent 的 provideDataManger 方法使用 @Singleton 注解的时候有效。如果它没有被指定为单例的,那么我们通过 getMockDataManager 方法得到的实例对象将会不同于应用使用的实例对象。因此,我们也不可能 stub 它。
编写测试用例
Writing the tests
现在我们有 Dagger 正确的配置,并且 TestComponentRule 也可以使用了,我们还有一件事要做,那就是编写测试用例。我们使用 Espresso 编写 UI 测试。它并不是完美的但是它是一个快速可靠的 Android 测试框架。在编写测试用例之前我们需要一个 app 去测试。假如我们有一个非常简单的 app,从 REST API 中加载用户名,并且展示到 RecyclerView 上面。那么 DataManger 将会是下面这个样子:
- public DataManager {
- // Loads usernames from a REST API using a Retrofit
- public Single<List<String>> loadUsernames() {
- return mUsersService.getUsernames();
- }
- }
loadUsername() 方法使用 Retrofit 和 Rxjava 去加载 REST API 的数据。它返回的是 Single 对象,并且发送一串字符串。 我们也需要一个 Activity 展示用户名 usernames 到 RecyclerView 上面,我们假设这个 Activity 叫做 UsernamesActivity。如果你遵循 MVP 模式你也会有相应的 presenter 但为了直观理解,这里不做 presenter 操作。
现在我们想要测试这个简单的 Activity 有至少三个情况需要测试:
1 如果 API 返回一个有效的用户名列表数据,那么它们会被展示到列表上面。
2 如果 API 返回空的数据,那么界面会显示 "空的列表"
3 如果 API 请求失败,那么界面会显示 "加载用户名失败"
下面依次展示三个测试:
- @Test
- public void usernamesDisplay() {
- // Stub the DataManager with a list of three usernames
- List<String> expectedUsernames = Arrays.asList("Joe", "Jemma", "Matt");
- when(component.getMockDataManager().loadUsernames())
- .thenReturn(Single.just(expectedUsernames));
- // Start the Activity
- main.launchActivity(null);
- // Check that the three usernames are displayed
- for (Sting username:expectedUsernames) {
- onView(withText(username))
- .check(matches(isDisplayed()));
- }
- }
- @Test
- public void emptyMessageDisplays() {
- // Stub an empty list
- when(component.getMockDataManager().loadUsernames())
- .thenReturn(Single.just(Collections.emptyList()));
- // Start the Activity
- main.launchActivity(null);
- // Check the empty list message displays
- onView(withText("Empty list"))
- .check(matches(isDisplayed()));
- }
- @Test
- public void errorMessageDisplays() {
- // Stub with a Single that emits and error
- when(component.getMockDataManager().loadUsernames())
- .thenReturn(Single.error(new RuntimeException()));
- // Start the Activity
- main.launchActivity(null);
- // Check the error message displays
- onView(withText("Error loading usernames"))
- .check(matches(isDisplayed()));
- }
- }
通过上面的代码,我们使用 TestComponentRule 和 android 官方测试框架提供的 ActivityTestRule。ActivityTestRule 会让我们从测试中启动 UsernamesActivity 。注意我们使用 RuleChain 来确保 TestComponentRule 总是在 ActivityTestRule 前运行。这也是确保 TestComponent 在任何 Activity 运行之前在 Application 类中设定好。
你可能注意到了三个测试用例遵循同样的构建方式:
1 通过 when (xxx).thenReturn(yyy) 设置前置条件。这是通过 stub loadUsernames() 方法实现的。例如,第一个测试的前置条件是有一个有效的用户名列表。
2 通过 main.launchActivity(null) 运行 activity。
3 通过 check(matches(isDisplayed())); 检查视图的展示,并且展示相应前置条件期望的值。
这是一个非常有效的解决方案,它允许你测试不同的场景,因为你对整个 application 的初始状态拥有绝对的控制权。如果你不使用 mock 来编写上面的三个用例,几乎不可能达到这样的效果因为真实的 API 接口总会返回同样的数据。
如果你想要查看使用这个测试方法的完整实例,你可以在 github 查看项目 ribot Android boilerplate 或者 ribot app.
当然这个解决方案也有一些瑕疵。首先在每个 test 之前都会 stub 显得非常繁琐。复杂的界面可能需要在每个测试之前有 5-10 个 stub。将一些 stub 移到初始化 setup() 方法中是有用的但经常不同的测试需要不同的 stub。第二个问题是 UI 测试和潜在的实现存在着耦合,也就意味着如果你重构 DataManager,那么你也需要修改 stub。
虽然这样,我们也在 ribot 的几个应用中应用了这个 UI 测试方法,事实证明这中方法也是有好处的。例如,我们最近的一个 Android 应用中有 250 个 UI 测试能够在三分钟之内运行成功。其中也有 380 个 Model 层和 Presenter 层的单元测试。
好了,我希望这篇文章让你对 UI 测试的认知以及编写更好的测试代码有一个很好的帮助。
来源: http://www.phperz.com/article/17/0317/290850.html