当前位置: 首页 > 开发杂谈 >

ETA screen testing (1)

上一篇我们完成了车站列表页的 ViewModel 和 Presenter 的 unit test。现在转过去写班次页的 unit test。

EtaPresenter

首先我们写 EtaPresenter 的 test。这次我们来点新意思:使用 JUnit 4 的 parameterized test,写法跟之前 LineStationPresenterTest 很不同。Parameterized test 的基本格式是:

  1. 提供一堆输入和预期输出值的 Collection(例如 List
  2. Constructor 的 parameter 会接收那些参数
  3. 在 test method 可以拿 constructor 的 parameter 来做测试的输入和预期输出值

因为这次要用 Robolectric 取得 Android 的 string resource,我们要先在 build.gradle 加入以下的东西才能令 Robolectric 取得 Android resource:

android {
    // 略……
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}

但这次我们不需要刻意用 @Config 改变语言,因为 mapErrorMessage 只需要回传 string resource,我们的 code 没有 logic 决定输出甚麽语言的文字。接下来就是完整的 EtaPresenterTest

@RunWith(ParameterizedRobolectricTestRunner::class)
class EtaPresenterTest(
    private val result: EtaFailResult,
    private val expectedString: String?,
    @StringRes private val expectedResourceId: Int,
) {

    private lateinit var presenter: EtaPresenter
    private lateinit var res: Resources

    @Before
    fun setUp() {
        presenter = EtaPresenter(ApplicationProvider.getApplicationContext())
        res = ApplicationProvider.getApplicationContext<Context>().resources
    }

    @Test
    fun mapErrorMessage() {
        if (expectedString == null) {
            expectThat(presenter.mapErrorMessage(result)).isEqualTo(res.getString(expectedResourceId))
        } else {
            expectThat(presenter.mapErrorMessage(result)).isEqualTo(expectedString)
        }
    }

    companion object {
        @JvmStatic
        @get:ParameterizedRobolectricTestRunner.Parameters
        val data = listOf(
            arrayOf(EtaResult.Delay, null, R.string.delay),
            arrayOf(EtaResult.Incident("Incident", "https://example.com"), "Incident", 0),
            arrayOf(EtaResult.TooManyRequests, null, R.string.error),
            arrayOf(EtaResult.InternalServerError, null, R.string.error),
            arrayOf(EtaResult.Error(RuntimeException("Testing")), null, R.string.error),
        )
    }
}

先看 class 的 @RunWith,如果是单纯的 JUnit 4 parameterized test 是应该用 JUnit 的 Parameterized runner。但因为我们要用 Robolectric 所以要改用 ParameterizedRobolectricTestRunner。之前用的那个 AndroidJUnit4 runner 它有封装 Robolectric runner,但不支援 parameterized test,惟有直接用 Robolectric 提供的 runner。

之後看看最底的 companion object,同样因为要用 ParameterizedRobolectricTestRunner,所以要用对应的 @get:ParameterizedRobolectricTestRunner.Parameters 来标注提供予 test method 的参数。前面加了 @get 是因为我们要针对 property getter 来 annotation(因为 JUnit 是 Java 的东西,不会看懂 val,JUnit 本身的 @Parameters 是用来标注在 static method,所以要放在 companion object 内另加 @JvmStatic)。由於 EtaFailResult 有五款,我们在 listOf 就针对这五款情况提供了五组参数,每组都用 arrayOf 包住,它们分别对应 constructor 的三个参数。

之後跳到 mapErrorMessage 这个 test method。我们会在 test method 用到 constructor 的三个参数来完成 assertion。由於我不想日後改了 string resource 的文字後 test 会报错,所以在使用 string resource 的情景就在参数交了 string resource ID,但缺点是 assertion 部分就像誊文般抄一次它背後的 code 一次。你可以视乎情况决定在测试时即场用 string resource ID 取得文字来做做比对还是在 test case 写死它输出的文字做比对。

顺带一提,如果想令 test case 的名不是 mapErrorMessage[0]mapErrorMessage[1] 之类的话,可以在 @get:ParameterizedRobolectricTestRunner.Parameters 或者 @get:Parameters 加上 name 参数。例如 @get:Parameters(name = "{0}") 就是第一个参数的值 toString 後的文字,换做 {1} 就是第二个参数,如此类推。预设是用 {index} 即是参数序号。

EtaViewModel

现在来到最後一个 ViewModel,最重要的部分当然是 etaList。由於 EtaViewModel 的 constructor 有 Java Time 的 Clock,为方便之後的测试,我们会用 ThreeTen-Extra 提供的 MutableClock

testImplementation "org.threeten:threeten-extra:$threeTenExtraVersion"

如果不需要在测试中途改变 Clock 输出的时间,可以直接用 Java Time 的 Clock.fixed(),毋须另外安装 ThreeTen-Extra。

但在写跟 etaList 相关的 test case 之前我们先来测试一些简单的东西。首先准备好 test class 的基本通用部分:

private val DEFAULT_LOCAL_DATE = LocalDate.of(2021, 9, 1)
private val DEFAULT_LOCAL_TIME = LocalTime.of(13, 0, 0)
private val DEFAULT_INSTANT =
    ZonedDateTime.of(DEFAULT_LOCAL_DATE, DEFAULT_LOCAL_TIME, DEFAULT_TIMEZONE).toInstant()

@RunWith(AndroidJUnit4::class)
class EtaViewModelTest {

    @get:Rule
    val coroutineScope = MainCoroutineScopeRule()

    @MockK
    private lateinit var getEtaUseCase: GetEtaUseCase

    private lateinit var clock: MutableClock

    @Before
    fun setUp() {
        MockKAnnotations.init(this)
        clock = MutableClock.of(DEFAULT_INSTANT, DEFAULT_TIMEZONE)
    }
}

虽然 EtaViewModel 没有直接用到 Android SDK 的 class,但因为 constructor 的 SavedStateHandle 背後有用到 Bundle 所以还是要加上 @RunWith(AndroidJUnit4::class)。在 setUp 我们先设定 MutableClock 的时间做 2021 年 9 月 1 日下午 1 时正。这次特意用 MutableClock 是因为我们那个定时更新功能需要用 Clock 取得当前时间来决定下次 call API 的时间,如果时间是在 call EtaViewModel constructor 那时写死的话就不能测试那个位置。这亦都是我们用 dependency injection library inject Clock 而不是用 System.currentTimeMillis 之类的方式取得当前时间的原因。


我们先来写一些简单的 test case:linestation,这两个 StateFlow 就是供 data binding 的 MaterialToolbar 显示班次所属於的路綫和车站。这两个 StateFlow 的值其实是来自 SavedStateHandle(即是 Fragmentarguments),我们需要在 EtaViewModel 的 constructor 提供带有这两个参数的 SavedStateHandle 然後检查这两个 StateFlow 的值是不是跟我们放在 SavedStateHandle 的一样。

@Test
fun line() = coroutineScope.runBlockingTest {
    val viewModel = EtaViewModel(
        savedStateHandle = SavedStateHandle(
            mapOf(
                "line" to Line.TCL,
                "station" to Station.TUC,
            )
        ),
        clock = clock,
        getEta = getEtaUseCase,
    )

    viewModel.line.test {
        expectThat(awaitItem()).isEqualTo(Line.TCL)
        expectNoEvents()
    }
}

@Test
fun station() = coroutineScope.runBlockingTest {
     val viewModel = EtaViewModel(
        savedStateHandle = SavedStateHandle(
            mapOf(
                "line" to Line.TCL,
                "station" to Station.TUC,
            )
        ),
        clock = clock,
        getEta = getEtaUseCase,
    )

    viewModel.station.test {
        expectThat(awaitItem()).isEqualTo(Station.TUC)
        expectNoEvents()
    }
}

两个 test case 的内容基本上是一样,首先是要建构 EtaViewModel。跟以前的 test case 写法不同,我们不会在 @Before 预先建构 EtaViewModel,这是因为不同的 test case 需要传入不同的 constructor 参数(其实是 SavedStateHandle 会因应 test case 不同)。之後就是用 Turbine collect 我们要检查的 Flow。由於路綫和车站在整页的 lifecycle 都不会再改变,所以当初我们写的时候就直接把 MutableStateFlow cast 成 StateFlow,并且在 MutableStateFlowSavedStateHandle 取得的 argument 值作为它的初始值(下面就是它们的定义)。所以这两个 StateFlow 只会发射一个值出去。

val line: StateFlow<Line> = MutableStateFlow(args.line)
val station: StateFlow<Station> = MutableStateFlow(args.station)

我们知道这两个 StateFlow 只会发射一个值,那我们在测试时只需要 call 一次 awaitItem() 就可以了,当 assert 完第一个值後就可以 call expectNoEvents() 告诉 Turbine 之後应该不会再有新的值出现。

之後我们看看另一个 test case navigateBack

@Test
fun navigateBack() = coroutineScope.runBlockingTest {
    val viewModel = EtaViewModel(
        savedStateHandle = SavedStateHandle(
            mapOf(
                "line" to Line.TCL,
                "station" to Station.TUC,
            )
        ),
        clock = clock,
        getEta = getEtaUseCase,
    )

    viewModel.navigateBack.test {
        viewModel.goBack()
        awaitEvent()
        expectNoEvents()
    }
}

这个又是比较简单的,就是测试 call 了 viewModel.goBack()viewModel.navigateBack 这个 Flow 有没有发射讯号提示 EtaFragment 转页。由於这个 Flow 的 type 是 Unit,我们就不用 assert 它的值,只需要让它消耗掉就可以了。

接下来我们会写 viewIncidentDetail 的测试。由於它是看结果是不是 EtaResult.Incident 才向 viewIncidentDetail 发射要浏览的网址,所以我们先试试当 getEtaUseCase 输出 EtaResult.Incident 的情况:

@Test
fun `viewIncidentDetail incident`() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TCL,
            Station.TUC,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    } returns EtaResult.Incident("Message", "https://example.com")

    val viewModel = EtaViewModel(
        savedStateHandle = SavedStateHandle(
            mapOf(
                "line" to Line.TCL,
                "station" to Station.TUC,
            )
        ),
        clock = clock,
        getEta = getEtaUseCase,
    )

    viewModel.etaList.test {
        viewModel.viewIncidentDetail.test {
            viewModel.startAutoRefresh()
            viewModel.viewIncidentDetail()
            expectThat(awaitItem()).isEqualTo("https://example.com")
            expectNoEvents()
        }
        cancelAndIgnoreRemainingEvents()
    }
}

因为在 call 了 EtaViewModel.startAutoRefresh 才会 call use case,我们在 viewModel.viewIncidentDetail.test 内第一句就是 viewModel.startAutoRefresh()。在写的时候我发现 viewModel.viewIncidentDetail.test 会有怪问题出现。原来是因为我们之前写 etaResultStateFlow<CachedResult>。如果外围再包多一层 viewModel.etaList.test 的话才会正常,这是因为 StateFlow 是 cold flow。意思是如果没有其他人 collect 这个 flow 的话,那在 etaResult 写的一大串 flatMapLatestscan 是不会执行。但 etaResult 是 private,所以我找了 etaList 来 collect(因为它的上游是 etaResult),这样才不会卡死在 Loading。在实际执行其实看不到这个问题,因为 layout XML 的 data binding 会 collect 那一大堆跟 etaResult 相关的 StateFlow,所以 etaResult 内的东西一定会被执行。但感觉上还是不好吧,所以我们应该把 etaResult 改成即使没有人 collect 仍会执行(即是 hot flow)。SharedFlow 就是 hot flow 的一种,以下是节录自 SharedFlow 的 KDoc:

A hot Flow that shares emitted values among all its collectors in a broadcast fashion, so that all collectors get all emitted values. A shared flow is called hot because its active instance exists independently of the presence of collectors. This is opposed to a regular Flow, such as defined by the flow { ... } function, which is cold and is started separately for each collector.

有一样东西要留意是:如果连续有两个相同的值发送到 SharedFlow 的话,那麽两个值都能交到下游;但 StateFlow 就会吃掉第二个值,直至下一个值跟先前的值不相同才会交到下游。不过在我们这个情况因为下游都是 StateFlow,即使上游发射重覆的值对那些 StateFlow 的下游都没有分别。

要改成 SharedFlow,只需把原先的 stateIn 换成 shareInreplay 设定 1 是为了其他人一开始订阅时就能马上收到 SharedFlow 在订阅前所发射的最後一个值,这样就不用让下游在订阅时乾等到下一次更新才能收到 CachedResult

改了 etaResult 後还是要改其他地方,因为 SharedFlow 是没有 value 这个 property,要取新最新的值就要用 first()。所以我们需要一并修改 startAutoRefreshviewIncidentDetail。我们亦顺带修正 viewIncidentDetail 只看 currentResult 的问题:如果当前是载入中但画面仍是显示事故画面的话那按下「View detail」没有反应。

private val etaResult: SharedFlow<CachedResult> = triggerRefresh
    .consumeAsFlow()
    .flatMapLatest { /* 略 */ }
    .scan(CachedResult()) { acc, currentValue -> /* 略 */ }
    .shareIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(STATE_FLOW_STOP_TIMEOUT_MILLIS),
        replay = 1,
    )

fun startAutoRefresh() {
    autoRefreshScope.launch {
        val delayDuration =
            JavaDuration.between(etaResult.first().currentResult.updatedAt, clock.instant())
        // 略
    }
}

fun viewIncidentDetail() {
    viewModelScope.launch {
        val result = etaResult.first().lastFailResult
        if (result !is EtaResult.Incident) return@launch
        _viewIncidentDetail.send(result.url)
    }
}

那刚才的 viewIncidentDetail incident test case 我们就可以拿掉 viewModel.etaList.test 的部分。

我们再试试当 lastFailResult 不是 EtaResult.Incident 的情况,为了令 test case 写得短,我用了 EtaResult.Delay 来试。

@Test
fun `viewIncidentDetail delay`() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TCL,
            Station.TUC,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    } returns EtaResult.Delay

    val viewModel = EtaViewModel(
        savedStateHandle = SavedStateHandle(
            mapOf(
                "line" to Line.TCL,
                "station" to Station.TUC,
            )
        ),
        clock = clock,
        getEta = getEtaUseCase,
    )

    viewModel.viewIncidentDetail.test {
        viewModel.startAutoRefresh()
        viewModel.viewIncidentDetail()
        expectNoEvents()
    }
}

这次我们期望 viewIncidentDetail 这个 Flow 不会发射讯号,所以用了 expectNoEvents()

在本篇结束前我们再写多一个 test case showLoading,它就是用来控制是否显示 CircularProgressIndicatorCircularProgressIndicator 会在载入完成後消失,我们会检查它是不是首先显示然後转为不显示。

@Test
fun showLoading() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TCL,
            Station.TUC,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    } returns EtaResult.InternalServerError

    val viewModel = EtaViewModel(
        savedStateHandle = SavedStateHandle(
            mapOf(
                "line" to Line.TCL,
                "station" to Station.TUC,
            )
        ),
        clock = clock,
        getEta = getEtaUseCase,
    )

    viewModel.showLoading.test {
        viewModel.startAutoRefresh()
        expectThat(awaitItem()).isEqualTo(true)
        expectThat(awaitItem()).isEqualTo(false)
        expectNoEvents()
    }
}

小结

现在我们已经写了几个 test case,了解到如何测试 Flow,顺带介绍了 SharedFlow。另外亦在写测试时发现先前写的 code 有 bug,这其实是正常的,因为靠实机人手体验可能会看不到一些问题,换了另一个角度又会看得到之前不为意的问题。下一篇我们会写一些跟时间相关的 test case,完整的 code 可以在 GitHub repo 找到。

参考


相关文章:

  • Day 29. Hi-Fi Prototype-以 Figma 制作高精度原型 (下)
  • 亚马逊广告投放的方式有什么?
  • 15. HTTP request methods ( 上 )--- GET vs. POST
  • 什么是速卖通秒抢优惠券活动?
  • 外贸人开发客户的一些技巧分享
  • [Day 30] 人脸表情辨识App成果发表与完赛感想
  • 30天学会C语言: Day 16-你可能会用到的函式
  • Git 综合笔记
  • 跨境电商怎样重新构想跨境电商与护肤行业?
  • 09 程序除错技巧指南
  • 速卖通如何查看流量结构来源及用途?
  • GPU程序设计(1) -- Hello CUDA !
  • 外贸人必知的一些关于欧洲市场的情况
  • 人的管理 - 危机感 vs. 安全感
  • Material UI in React [Day 24] Utils 工具组
  • 瑞士银行开户指南:0门槛开户欧洲银行卡教程【Dukascopy开户教程】
  • 搬瓦工VPS开通使用教程大全:教你如何购买、切换机房、更换IP、续费、升级套餐、退款
  • Google SEO入门:如何做好谷歌网站排名SEO
  • 外贸电商网络营销之心理营销如何做?让客户更信任你的产品和网站
  • 微信小程序搭建教程:怎么用CentOS搭建小程序服务器
  • 海外营销周报:Facebook应用下载量下降30%,TikTok在欧洲测试应用内购买…
  • Google Play Store报错DF-DFERH-01怎么办
  • Python安装教程:怎么安装Python
  • VPS优惠信息:阿里云/限时活动/新用户1C2G1M/69元/年起
  • 国外代发货教程:教你如何一件代发做跨境电商国外市场
  • 国内出海企业用哪家公司的短信比较多?
  • 国外虚拟主机大全:便宜好用靠谱的国外网站空间推荐大全
  • 站点迁移问题:流量下降的 11 个潜在原因
  • WordPress主题开发基础:Body 类指南
  • 软件分享:xshell6/xftp6个人版下载,无需破解,永久免费使用