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

原文链接:https://android.jlelse.eu/complete-example-of-testing-mvp-architecture-with-kotlin-and-rxjava-part-3-df4cf3838581

使用假数据和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仓库中找到更多测试用例的示例。 该部分的代码更改可以在此提交中找到:

https://github.com/kozmi55/Kotlin-MVP-Testing/commit/8152c2065af2e0871ba1175cadecb92b3fa8417f

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

https://github.com/kozmi55/Kotlin-MVP-Testing/commit/3889286528ad5a88035358894fda9e0be8c145aa

附赠:在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插件的示例可以在此提交中找到:

https://github.com/kozmi55/Kotlin-MVP-Testing/commit/8152c2065af2e0871ba1175cadecb92b3fa8417f

——————

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

Thanks for reading my article.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容