上一篇我们写了一些 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 就要用到 TestScheduler
的 advanceTimeBy
method 把时间快进。Kotlin Coroutine 的作法都是差不多,写法是在 runBlockingTest
内 call advanceTimeBy
。之前我们在写 Ktor client 的测试时因为找不到 Ktor client 如何自订 Executor
或者 Dispatcher
,所以惟有用了 runBlocking
而不是 runBlockingTest
。runBlocking
跟 runBlockingTest
的分别是 runBlocking
内如果加了 delay(Duration.seconds(10))
的话那个 test case 就真的会在等十秒才执行 delay
的下一句;但 runBlockingTest
就会自动把这些 delay
快进,直到它发现已经进入闲置状态。这样就可以令 test case 执行速度加快,不用再乾等十秒。
回到我们的 EtaViewModel
,我们在每次收到 getEtaUseCase
的回传值就会在 onEach
内执行一句 delay
,delay
之後就向 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。因为 showFullScreenError
是 StateFlow
,所以并没有连续发射出两个 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
onPause
再 onResume
的情景。这个重新返回班次页的情景可以分为两个:在十秒内返回和过十秒後返回。
@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点击後换页
JupyterHub为一个提供可多人撰写的notebook的工具, 属於撰写notebook工具中的...
设计大纲 在上一篇设计「Reviews」这个区块时,有提到切版时可能会有「切不出来」的情况发生,所以...
这是我个人的考上Cissp的分享会,其中分享如何有效的念书,提供想考Cissp的朋友一些参考。 ...
大家好~ 继续昨天的主题, 今天要来把 Laravel 部署上 Server, 顺便设定一下 MyS...
今天来进一步探讨更细节的几个问题 像是XDES Entry结构到底储存在表格空间的那边? 直属於表格...