ETA screen testing (2)

上一篇我们写了一些 EtaViewModel 的测试,这一篇会集中写跟时间相关的测试。

之前在 EtaViewModel 我们定义了更新一次的间距常数 AUTO_REFRESH_INTERVAL,现在我们要在 EtaViewModelTest 用到它,所以要把它改成 public:

import kotlin.time.Duration as KotlinDuration

val AUTO_REFRESH_INTERVAL = KotlinDuration.seconds(10)

在看 test case 的 code 之前我们先了解 Kotlin Coroutine 如何处理时间相关的测试。如果有接触过 RxJava 的话,要测试跟时间相关的 operator 就要用到 TestScheduleradvanceTimeBy method 把时间快进。Kotlin Coroutine 的作法都是差不多,写法是在 runBlockingTest 内 call advanceTimeBy。之前我们在写 Ktor client 的测试时因为找不到 Ktor client 如何自订 Executor 或者 Dispatcher,所以惟有用了 runBlocking 而不是 runBlockingTestrunBlockingrunBlockingTest 的分别是 runBlocking 内如果加了 delay(Duration.seconds(10)) 的话那个 test case 就真的会在等十秒才执行 delay 的下一句;但 runBlockingTest 就会自动把这些 delay 快进,直到它发现已经进入闲置状态。这样就可以令 test case 执行速度加快,不用再乾等十秒。

回到我们的 EtaViewModel,我们在每次收到 getEtaUseCase 的回传值就会在 onEach 内执行一句 delaydelay 之後就向 triggerRefresh Channel 发讯号触发整串 etaResult 执行一遍,那之後又会再执行多次 onEach 的东西。整串 etaResult 就是一个无限循环,不会有闲置状态。所以我们不能简单地靠 runBlockingTest 自动快进功能来写跟自动更新相关的 test case。那我们要做的就是手动把时间快进,然後在快进後检查 Flow 的值。另一样东西要留意的是我们在 startAutoRefresh 会比对上次 getEtaUseCase 回传的时间和现在时间来决定要 delay 多久才 call 另一次 getEtaUseCase。这个部分牵涉到 EtaViewModel constructor 的 Clock。所以除了用 Coroutine test 的 advanceTimeBy 外,我们亦需要把 Clock 的时间同时快进,这样才能正确地模拟现实情景。这亦都是我在上一篇特意引入 ThreeTen Extra 的原因。

为了更简单地快进两边的时间,我们先准备一个 extension function:

import kotlin.time.Duration as KotlinDuration

private fun DelayController.advanceTimeBy(amount: KotlinDuration) {
    clock.add(amount.toJavaDuration())
    advanceTimeBy(amount.inWholeMilliseconds)
}

接下来我们先来看看 showFullScreenError 的 test case,showFullScreenError 就是控制是否显示全页式的错误画面:

@Test
fun showFullScreenError() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TCL,
            Station.TUC,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    }.returnsMany(
        EtaResult.InternalServerError,
        EtaResult.Success(),
        EtaResult.TooManyRequests,
        EtaResult.Delay,
    )

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

    viewModel.showFullScreenError.test {
        viewModel.startAutoRefresh()
        expectThat(awaitItem()).isEqualTo(false) // Loading/ScreenState.LOADING
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        expectThat(awaitItem()).isEqualTo(true) // InternalServerError/ScreenState.FULL_SCREEN_ERROR
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        expectThat(awaitItem()).isEqualTo(false) // Success/ScreenState.ETA, TooManyRequests/ScreenState.ETA_WITH_ERROR_BANNER
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        expectThat(awaitItem()).isEqualTo(true) // Delay/ScreenState.FULL_SCREEN_ERROR
        expectNoEvents()
    }
}

看到我们用了 returnsMany 来一次过定义好几个 getEtaUseCase 会回传的值,它会顺序地回传。例如第一次 call 会回传 EtaResult.InternalServerError、第二次 call 会回传 EtaResult.Success()……到最底的部分,我们检查当 getEtaUseCase 回传了不同的结果时 showFullScreenError 会发射的 Boolean 值。留意是由 EtaResult.Success() 转到 EtaResult.TooManyRequests 时介面并不需要显示全页错误画面,只需要显示错误 banner。因为 showFullScreenErrorStateFlow,所以并没有连续发射出两个 false 出来而是一个 false

我们再看另一个类似的 test case showEtaList

@Test
fun showEtaList() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TCL,
            Station.TUC,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    }.returnsMany(
        EtaResult.InternalServerError,
        EtaResult.Success(),
        EtaResult.TooManyRequests,
        EtaResult.Delay,
    )

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

    viewModel.showEtaList.test {
        viewModel.startAutoRefresh()
        expectThat(awaitItem()).isEqualTo(false)  // ScreenState.LOADING
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        expectThat(awaitItem()).isEqualTo(true) // ScreenState.ETA, ScreenState.ETA_WITH_ERROR_BANNER
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        expectNoEvents()
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        expectThat(awaitItem()).isEqualTo(false) // ScreenState.FULL_SCREEN_ERROR
        expectNoEvents()
    }
}

现在掌握到如何操纵时间後,我们就可以写针对 etaList 的 test case。但首先要准备一下 custom assertion:

private fun Assertion.Builder<EtaListItem>.assertHeader(
    direction: EtaResult.Success.Eta.Direction,
) = isA<EtaListItem.Header>().and {
    get(EtaListItem.Header::direction).isEqualTo(direction)
}

private fun Assertion.Builder<EtaListItem>.assertEta(
    direction: EtaResult.Success.Eta.Direction,
    destination: Station,
    platform: String,
    minuteCountdown: Int,
) = isA<EtaListItem.Eta>().and {
    get(EtaListItem.Eta::direction).isEqualTo(direction)
    get(EtaListItem.Eta::destination).isEqualTo(destination)
    get(EtaListItem.Eta::platform).isEqualTo(platform)
    get(EtaListItem.Eta::minuteCountdown).isEqualTo(minuteCountdown)
}

现在先检查排序切换,看看 etaList 写的 header 加插是否正确。

@Test
fun `etaList sorting`() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TML,
            Station.KSR,
            any(),
        )
    } returns EtaResult.Success(
        schedule = listOf(
            EtaResult.Success.Eta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.TUM,
                platform = "1",
                time = ZonedDateTime.of(
                    DEFAULT_LOCAL_DATE,
                    LocalTime.of(13, 1, 1),
                    DEFAULT_TIMEZONE
                ).toInstant()
            ),
            EtaResult.Success.Eta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.SIH,
                platform = "1",
                time = ZonedDateTime.of(
                    DEFAULT_LOCAL_DATE,
                    LocalTime.of(13, 7, 59),
                    DEFAULT_TIMEZONE
                ).toInstant()
            ),
            EtaResult.Success.Eta(
                direction = EtaResult.Success.Eta.Direction.DOWN,
                destination = Station.HUH,
                platform = "2",
                time = ZonedDateTime.of(
                    DEFAULT_LOCAL_DATE,
                    LocalTime.of(13, 2, 2),
                    DEFAULT_TIMEZONE
                ).toInstant()
            ),
        ),
    )

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

    viewModel.etaList.test {
        expectThat(awaitItem()).isEmpty()
        viewModel.startAutoRefresh()
        expectThat(awaitItem()).hasSize(5).and {
            get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
            get(1).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.TUM,
                platform = "1",
                minuteCountdown = 1,
            )
            get(2).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.SIH,
                platform = "1",
                minuteCountdown = 7,
            )
            get(3).assertHeader(EtaResult.Success.Eta.Direction.DOWN)
            get(4).assertEta(
                direction = EtaResult.Success.Eta.Direction.DOWN,
                destination = Station.HUH,
                platform = "2",
                minuteCountdown = 2,
            )
        }
        viewModel.toggleSorting()
        expectThat(awaitItem()).hasSize(3).and {
            get(0).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.TUM,
                platform = "1",
                minuteCountdown = 1,
            )
            get(1).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.SIH,
                platform = "1",
                minuteCountdown = 7,
            )
            get(2).assertEta(
                direction = EtaResult.Success.Eta.Direction.DOWN,
                destination = Station.HUH,
                platform = "2",
                minuteCountdown = 2,
            )
        }
        viewModel.toggleSorting()
        expectThat(awaitItem()).hasSize(5).and {
            get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
            get(1).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.TUM,
                platform = "1",
                minuteCountdown = 1,
            )
            get(2).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.SIH,
                platform = "1",
                minuteCountdown = 7,
            )
            get(3).assertHeader(EtaResult.Success.Eta.Direction.DOWN)
            get(4).assertEta(
                direction = EtaResult.Success.Eta.Direction.DOWN,
                destination = Station.HUH,
                platform = "2",
                minuteCountdown = 2,
            )
        }
        expectNoEvents()
    }
    coVerify(exactly = 2) { getEtaUseCase(any(), any(), any(), GetEtaUseCase.SortBy.DIRECTION) }
    coVerify(exactly = 1) { getEtaUseCase(any(), any(), any(), GetEtaUseCase.SortBy.TIME) }
}

看起来很长,但其实很简单。我们这次没有快进时间,主要是看它发射出来的班次是否正确。首先在 startAutoRefresh 之前我们先检查 StateFlow 的初始值 empty list。然後当第一次载入时预设是按方向排序,所以会有 header。之後我们改变排序,於事就变了按时间排序。但因为实际的排序是在 GetEtaUseCaseImpl 做,我们又没特别 mock 第二次 call getEtaUseCase 的 return value,所以实际结果的排序看起来不合理,但 header 就正如我们的期望被拿走。最後试试切换排序一次,看看是不是跟第一次的结果一样。最尾的 coVerify 是用来检查 getEtaUseCase 是不是被执行了两次按方向排序和一次按时间排序,那些 any() 就是说我们不在乎那些参数的值是甚麽。当然你可以写明参数的值来确保我们写的 code 的确合符预期。

其实如果不用 Turbine 的话,viewModel.etaList.test 的部分可以写成这样:

val results = mutableListOf<List<EtaListItem>>()
val job = launch {
    viewModel.etaList.toList(results)
}
viewModel.startAutoRefresh()
viewModel.toggleSorting()
viewModel.toggleSorting()
job.cancel()
expectThat(results).hasSize(4).and { /* 针对每个元素做检查 */ }

有时候在写 test case 时发觉结果不似预期,或许可以用这个写法看看它的结果是甚麽然後才想想那里出现问题。

接下来是另一个 test,这是为了测试当首次载入後能否在十秒後自动 call getEtaUseCase 一次取得最新班次。

@Test
fun `etaList auto refresh automatically after loaded`() = coroutineScope.runBlockingTest {
    coEvery {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TML,
            Station.KSR,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    }.returnsMany(
        EtaResult.Success(
            schedule = listOf(
                EtaResult.Success.Eta(
                    direction = EtaResult.Success.Eta.Direction.UP,
                    destination = Station.TUM,
                    platform = "1",
                    time = ZonedDateTime.of(
                        DEFAULT_LOCAL_DATE,
                        LocalTime.of(13, 1, 1),
                        DEFAULT_TIMEZONE
                    ).toInstant()
                ),
            ),
        ),
        EtaResult.Success(
            schedule = listOf(
                EtaResult.Success.Eta(
                    direction = EtaResult.Success.Eta.Direction.DOWN,
                    destination = Station.TIS,
                    platform = "8",
                    time = ZonedDateTime.of(
                        DEFAULT_LOCAL_DATE,
                        LocalTime.of(13, 14, 0),
                        DEFAULT_TIMEZONE
                    ).toInstant()
                ),
            ),
        )
    )

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

    viewModel.etaList.test {
        viewModel.startAutoRefresh()
        // StateFlow 初始值
        expectThat(awaitItem()).isEmpty()
        // 第一次 getEtaUseCase
        expectThat(awaitItem()).hasSize(2).and {
            get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
            get(1).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.TUM,
                platform = "1",
                minuteCountdown = 1,
            )
        }
        // 快进到下一次更新时间
        advanceTimeBy(AUTO_REFRESH_INTERVAL)
        // 第二次 getEtaUseCase 执行中,目前仍然是用第一次 getEtaUseCase 的结果,
        // 但因为重新执行 etaList 内的 combine 所以会重新计算倒数分钟
        expectThat(awaitItem()).hasSize(2).and {
            get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
            get(1).assertEta(
                direction = EtaResult.Success.Eta.Direction.UP,
                destination = Station.TUM,
                platform = "1",
                minuteCountdown = 0,
            )
        }
        // 第二次 getEtaUseCase
        expectThat(awaitItem()).hasSize(2).and {
            get(0).assertHeader(EtaResult.Success.Eta.Direction.DOWN)
            get(1).assertEta(
                direction = EtaResult.Success.Eta.Direction.DOWN,
                destination = Station.TIS,
                platform = "8",
                minuteCountdown = 13,
            )
        }
        expectNoEvents()
    }
    coVerify(exactly = 2) {
        getEtaUseCase(
            Language.ENGLISH,
            Line.TML,
            Station.KSR,
            GetEtaUseCase.SortBy.DIRECTION,
        )
    }
}

基本上写法都是大同小异,只是要留意我们之前写的 logic 是有载入中这个过程,当 etaResult 发射载入中的时候 etaList 还是会沿用上一次的结果来输出,然後当新的 getEtaUseCase 结果来到後就用新的结果转化出供 RecyclerView 显示的内容。

最後来多一个 test case 测试当 Fragment onPauseonResume 的情景。这个重新返回班次页的情景可以分为两个:在十秒内返回和过十秒後返回。

@Test
fun `etaList stop and resume auto refresh`() =
    coroutineScope.runBlockingTest {
        coEvery {
            getEtaUseCase(
                Language.ENGLISH,
                Line.TKL,
                Station.QUB,
                GetEtaUseCase.SortBy.DIRECTION,
            )
        }.returnsMany(
            EtaResult.Success(
                schedule = listOf(
                    EtaResult.Success.Eta(
                        direction = EtaResult.Success.Eta.Direction.UP,
                        destination = Station.LHP,
                        platform = "1",
                        time = ZonedDateTime.of(
                            DEFAULT_LOCAL_DATE,
                            LocalTime.of(13, 20, 30),
                            DEFAULT_TIMEZONE
                        ).toInstant(),
                    ),
                ),
            ),
            EtaResult.Success(
                schedule = listOf(
                    EtaResult.Success.Eta(
                        direction = EtaResult.Success.Eta.Direction.UP,
                        destination = Station.LHP,
                        platform = "2",
                        time = ZonedDateTime.of(
                            DEFAULT_LOCAL_DATE,
                            LocalTime.of(13, 30, 0),
                            DEFAULT_TIMEZONE
                        ).toInstant(),
                    ),
                ),
            ),
            EtaResult.Success(
                schedule = listOf(
                    EtaResult.Success.Eta(
                        direction = EtaResult.Success.Eta.Direction.UP,
                        destination = Station.LHP,
                        platform = "3",
                        time = ZonedDateTime.of(
                            DEFAULT_LOCAL_DATE,
                            LocalTime.of(13, 30, 0),
                            DEFAULT_TIMEZONE
                        ).toInstant(),
                    ),
                ),
            ),
        )

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

        viewModel.etaList.test {
            viewModel.startAutoRefresh()
            // StateFlow 初始值
            expectThat(awaitItem()).isEmpty()
            // 第一次 getEtaUseCase
            expectThat(awaitItem()).hasSize(2).and {
                get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
                get(1).assertEta(
                    direction = EtaResult.Success.Eta.Direction.UP,
                    destination = Station.LHP,
                    platform = "1",
                    minuteCountdown = 20,
                )
            }
            viewModel.stopAutoRefresh()
            advanceTimeBy(KotlinDuration.seconds(5))
            expectNoEvents()
            viewModel.startAutoRefresh()
            // 未够十秒,上游不会有新的值
            expectNoEvents()
            advanceTimeBy(KotlinDuration.seconds(5))
            // 第二次 getEtaUseCase
            expectThat(awaitItem()).hasSize(2).and {
                get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
                get(1).assertEta(
                    direction = EtaResult.Success.Eta.Direction.UP,
                    destination = Station.LHP,
                    platform = "2",
                    minuteCountdown = 29,
                )
            }
            expectNoEvents()
            viewModel.stopAutoRefresh()
            advanceTimeBy(KotlinDuration.minutes(20))
            viewModel.startAutoRefresh()
            // 第三次 getEtaUseCase 执行中,仍然是用第二次 getEtaUseCase
            expectThat(awaitItem()).hasSize(2).and {
                get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
                get(1).assertEta(
                    direction = EtaResult.Success.Eta.Direction.UP,
                    destination = Station.LHP,
                    platform = "2",
                    minuteCountdown = 9,
                )
            }
	        // 第三次 getEtaUseCase
            expectThat(awaitItem()).hasSize(2).and {
                get(0).assertHeader(EtaResult.Success.Eta.Direction.UP)
                get(1).assertEta(
                    direction = EtaResult.Success.Eta.Direction.UP,
                    destination = Station.LHP,
                    platform = "3",
                    minuteCountdown = 9,
                )
            }
            expectNoEvents()
        }
        coVerify(exactly = 3) {
            getEtaUseCase(
                Language.ENGLISH,
                Line.TKL,
                Station.QUB,
                GetEtaUseCase.SortBy.DIRECTION,
            )
        }
    }

小结

这次的 code 比较长,这是因为那些 use case 的 return value 本身都很长,加上每个值都要做 assertion,但 test case 的写法来来去去都是差不多。本篇主要介绍了 Kotlin Coroutine 测试时如何快进时间,另外亦实际示范了为甚麽我们要用 Clock 来获取当前时间。其余的 test case 因为性质相近所以我就不再写了,因为现在都可以示范到那些手法。而我们的班次示范 app 来到现在都大致上完结,下篇会再抽一些题目再讨论一下。这次的 code 可以在 GitHub repo 找到。


<<:  [Android Studio 30天自我挑战] CardView点击後换页

>>:  DAY 28 文章列表 - 2

安装Jupyterhub

JupyterHub为一个提供可多人撰写的notebook的工具, 属於撰写notebook工具中的...

【设计+切版30天实作】|Day11 - [设计进阶挑战] 如何把Reviews的呈现方式改成可滚动式的卡片呢?

设计大纲 在上一篇设计「Reviews」这个区块时,有提到切版时可能会有「切不出来」的情况发生,所以...

20210208-台湾菁英圆桌分享会 (Elite Round Table in Taiwan)

这是我个人的考上Cissp的分享会,其中分享如何有效的念书,提供想考Cissp的朋友一些参考。 ...

Day21-部署篇(三)Laravel 专案部署与 MySQL、Nginx 设定

大家好~ 继续昨天的主题, 今天要来把 Laravel 部署上 Server, 顺便设定一下 MyS...

InnoDB的表格空间-Part2(各类型页面详细情况)

今天来进一步探讨更细节的几个问题 像是XDES Entry结构到底储存在表格空间的那边? 直属於表格...