【译】使用Kotlin和RxJava测试MVP架构的完整示例 - 第3部分

553 阅读3分钟

原文链接:android.jlelse.eu/complete-ex…

简书译文地址:www.jianshu.com/p/f56d8d1ce…

使用假数据和Espresso来创建UI测试

这是Android测试系列的最后一部分。 如果你错过了前2个部分,不用担心,即使你没有阅读过,也可以理解这一点。 如果你真的想看看,你可以从下面的链接找到它们。

Complete example of testing MVP architecture with Kotlin and RxJava — Part 1

Complete example of testing MVP architecture with Kotlin and RxJava — Part 2

在这部分中,您将学习如何使用假数据在Espresso中创建UI测试,如何模拟Mockito-Kotlin的依赖关系,以及如何模拟Android测试中的final 类。

用假数据编写Espresso测试

如果我们想编写始终产生相同的结果的UI测试,我们最需要做的事情就是使我们的测试独立于来自网络或本地数据库的任何数据。

在其他层面,我们可以通过模拟测试类的依赖来轻松实现这一点(正如你在前两部分中看到的)。 这在UI测试中有所不同。 在前面的例子中,我们的类是从构造函数中得到了它们的依赖,所以我们可以很容易地将模拟对象传递给构造函数。 而Android组件是由系统实例化的,通常是通过字段注入获得它们的依赖。

使用假数据创建UI测试有多种方法。 首先让我们看看如何在我们的测试中用FakeUserRepository替换UserRepository

实现FakeUserRepository

FakeUserRepository是一个简单的类,它为我们提供了假数据。 它实现了UserRepository接口。 DefaultUserRepository也实现了它,但它为我们提供应用程序中的真实数据。

class FakeUserRepository : UserRepository {

    override fun getUsers(page: Int, forced: Boolean): Single<UserListModel> {
        val users = (1..10L).map {
            val number = (page - 1) * 10 + it
            User(it, "User $number", number * 100, "")
        }

        return Single.create<UserListModel> { emitter: SingleEmitter<UserListModel> ->
            val userListModel = UserListModel(users)
            emitter.onSuccess(userListModel)
        }
    }
}

我认为这个代码不需要太多的解释。 我们创建了一个Single来发送一串假的users数据。 虽然值得一提的是这部分代码:

val users = (1..10L).map

我们可以使用map函数从一个范围里创建列表。 这在这种情况下可能非常有用。

将FakeUserRepository注入我们的测试

现在我们有了假的UserRepository实现,但我们如何在我们的测试中使用它呢? 当使用Dagger时,我们通常有一个ApplicationComponent和一个ApplicationModule来提供应用程序级的依赖关系。 我们在自定义Application类中初始化component。

class CustomApplication : Application() {

    lateinit var component: ApplicationComponent

    override fun onCreate() {
        super.onCreate()

        initAppComponent()

        Stetho.initializeWithDefaults(this);
        component.inject(this)
    }

    private fun initAppComponent() {
        component = DaggerApplicationComponent
                .builder()
                .applicationModule(ApplicationModule(this))
                .build()
    }
}

现在我们将创建一个FakeApplicationModule和一个FakeApplicationComponent,这将为我们提供FakeUserRepository。 在我们的UI测试中,我们将component字段设置为FakeApplicationComponent

来看一下这个例子:

@Singleton
@Component(modules = arrayOf(FakeApplicationModule::class))
interface FakeApplicationComponent : ApplicationComponent

由于该component继承自ApplicationComponent,所以我们可以使用它来替代。

@Module
class FakeApplicationModule {

    @Provides
    @Singleton
    fun provideUserRepository() : UserRepository {
        return FakeUserRepository()
    }

    @Provides
    @Singleton
    fun provideSchedulerProvider() : SchedulerProvider = AppSchedulerProvider()
}

我们不需要在这里提供任何其他东西,因为大多数提供的依赖关系用于真正的UserRepository实现。

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    @Rule @JvmField
    var activityRule = ActivityTestRule(MainActivity::class.java, true, false)

    @Before
    fun setUp() {
        val instrumentation = InstrumentationRegistry.getInstrumentation()
        val app = instrumentation.targetContext.applicationContext as CustomApplication

        val testComponent = DaggerFakeApplicationComponent.builder()
                .fakeApplicationModule(FakeApplicationModule())
                .build()
        app.component = testComponent

        activityRule.launchActivity(Intent())
    }

    @Test
    fun testRecyclerViewShowingCorrectItems() {
        // TODO
    }
}
view raw

前两个片段已经在上面解释过了。 这里有趣的部分是MainActivityTest类。来看看这里发生了什么。

setUp方法中,我们得到了一个CustomApplication类的实例,创建了我们的FakeApplicationComponent,接着启动了MainActivity

在设置component后,启动Activity很重要。 可以通过将另一个构造函数参数传递给ActivityTestRule的构造函数来实现。 第三个参数是一个布尔值,它决定了测试运行程序是否应立即启动该Activity。

Espresso示例

现在我们可以开始写一些测试。 我不想过多描述如何用Espresso来编写测试用例的细节,已经有了很多教程,但是我们先来看一个简单的例子。

首先我们需要添加依赖关系到build.gradle。 如果我们使用了RecyclerView,在普通espresso-core之外,我们还需要添加espresso-contrib依赖。

androidTestImplementation ('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })

    androidTestImplementation('com.android.support.test.espresso:espresso-contrib:2.2') {
        // Necessary to avoid version conflicts
        exclude group: 'com.android.support', module: 'appcompat'
        exclude group: 'com.android.support', module: 'support-v4'
        exclude group: 'com.android.support', module: 'support-annotations'
        exclude module: 'recyclerview-v7'
    }

现在我们的测试看起来是这样:

@Test
fun testOpenDetailsOnItemClick() {
    Espresso.onView(ViewMatchers.withId(R.id.recyclerView))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, ViewActions.click()))

    val expectedText = "User 1: 100 pts"

    Espresso.onView(Matchers.allOf(ViewMatchers.withId(android.support.design.R.id.snackbar_text), ViewMatchers.withText(expectedText)))
            .check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
}

发生了什么?

首先,我们找到RecyclerView然后在RecyclerViewActions的帮助下,点击它的第一个(0索引)项。

在我们作出断言之后,一个Snackbar显示出了User 1: 100 pts的文本。

这是一个非常简单的测试用例。 您可以在Github仓库中找到更多测试用例的示例。 该部分的代码更改可以在此提交中找到:

github.com/kozmi55/Kot…

在UI测试中模拟UserRepository

如果我们想测试以下情景,该怎么办?

  • 加载第一页数据成功
  • 加载第二页错误
  • 验证当我们尝试加载第二页时是否在屏幕上显示了Toast

我们不能在这里使用我们的假实现,因为它总是成功返回一个user list。 我们可以修改实现,对于第二个页面,让它返回一个会发送错误的Single,但这并不好。 如果我们要添加另一个测试用例,我们需要一次又一次地进行修改。

这种情况我们可以模拟getUsers方法的行为。 为此,我们需要对FakeApplicationModule进行一些修改。

@Module
class FakeApplicationModule(val userRepository: UserRepository) {

    @Provides
    @Singleton
    fun provideUserRepository() : UserRepository {
        return userRepository
    }

  ...
}

现在我们在构造函数中传递UserRepository,所以在测试中,我们可以创建一个mock对象,并使用它来构建我们的component。

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    ...

    private lateinit var mockUserRepository: UserRepository

    @Before
    fun setUp() {
        mockUserRepository = mock()

        val instrumentation = InstrumentationRegistry.getInstrumentation()
        val app = instrumentation.targetContext.applicationContext as CustomApplication

        val testComponent = DaggerFakeApplicationComponent.builder()
                .fakeApplicationModule(FakeApplicationModule(mockUserRepository))
                .build()
        app.component = testComponent
    }

  ...
}

这是我们修改后的测试类。 使用了我在第一部分中提到过的用来模拟UserRepositorymockito-kotlin库。 我们需要添加以下依赖关系到build.gradle,然后使用它。

androidTestImplementation "com.nhaarman:mockito-kotlin-kt1.1:1.5.0"
androidTestImplementation 'com.linkedin.dexmaker:dexmaker-mockito:2.2.0'

现在我们可以修改模拟的行为了。 我为此创建了两个私有的工具方法,可以在测试用例中重用它们。

private fun mockRepoUsers(page: Int) {
    val users = (1..20L).map {
        val number = (page - 1) * 20 + it
        User(it, "User $number", number * 100, "")
    }

    val mockSingle = Single.create<UserListModel> { emitter: SingleEmitter<UserListModel> ->
        val userListModel = UserListModel(users)
        emitter.onSuccess(userListModel)
    }

    whenever(mockUserRepository.getUsers(page, false)).thenReturn(mockSingle)
}

private fun mockRepoError(page: Int) {
    val mockSingle = Single.create<UserListModel> { emitter: SingleEmitter<UserListModel> ->
        emitter.onError(Throwable("Error"))
    }

    whenever(mockUserRepository.getUsers(page, false)).thenReturn(mockSingle)
}

我们需要做的另一个改变是在建立模拟对象之后,在测试用例中启动Activity,而不是在setUp方法中去启动。

有了这个变化,我们前面的测试用例如下所示:

@Test
fun testOpenDetailsOnItemClick() {
    mockRepoUsers(1)

    activityRule.launchActivity(Intent())

    Espresso.onView(ViewMatchers.withId(R.id.recyclerView))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, ViewActions.click()))

    val expectedText = "User 1: 100 pts"

    Espresso.onView(Matchers.allOf(ViewMatchers.withId(android.support.design.R.id.snackbar_text), ViewMatchers.withText(expectedText)))
            .check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
}

GitHub仓库中还有一些其它的测试用例,包括错误时的情况。 此部分中的更改可以在此提交中看到:

github.com/kozmi55/Kot…

附赠:在Android测试中模拟final类

在Kotlin里,默认情况下每个class都是final的,这使得mock变得复杂。 在第一部分中,我们看到了如何用Mockito模拟final类。

不幸的是,这种方法在Android真机测试中不起作用。 在这种情况下,我们有几种解决方案, 其中之一是使用Kotlin all-open 插件

这是一个编译器插件,它允许我们创建一个注解,如果使用它,将会打开该类。

要使用它,我们需要添加以下依赖关系到我们项目(project)的build.gradle文件:

classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlin_version"

然后添加以下的内容到app模块的build.gradle文件中:

apply plugin: 'kotlin-allopen'
allOpen {
    annotation("com.myapp.OpenClass")
}

现在我们只需要在我们指定的包中创建我们的注解:

@Target(AnnotationTarget.CLASS)
annotation class OpenClass

all-open插件的示例可以在此提交中找到:

github.com/kozmi55/Kot…

——————

我们到达了漫长的旅程的尽头,覆盖了我们应用程序中的每一个代码,并附带了测试。 感谢您阅读这篇文章,希望您能发现这些文章是有用的。

Thanks for reading my article.