上一篇我们完成了车站列表页的 ViewModel 和 Presenter 的 unit test。现在转过去写班次页的 unit test。
EtaPresenter
首先我们写 EtaPresenter
的 test。这次我们来点新意思:使用 JUnit 4 的 parameterized test,写法跟之前 LineStationPresenterTest
很不同。Parameterized test 的基本格式是:
Collection
(例如 List
)因为这次要用 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:line
跟 station
,这两个 StateFlow
就是供 data binding 的 MaterialToolbar
显示班次所属於的路綫和车站。这两个 StateFlow
的值其实是来自 SavedStateHandle
(即是 Fragment
的 arguments
),我们需要在 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
,并且在 MutableStateFlow
以 SavedStateHandle
取得的 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
会有怪问题出现。原来是因为我们之前写 etaResult
是 StateFlow<CachedResult>
。如果外围再包多一层 viewModel.etaList.test
的话才会正常,这是因为 StateFlow
是 cold flow。意思是如果没有其他人 collect 这个 flow 的话,那在 etaResult
写的一大串 flatMapLatest
和 scan
是不会执行。但 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 theflow { ... }
function, which is cold and is started separately for each collector.
有一样东西要留意是:如果连续有两个相同的值发送到 SharedFlow
的话,那麽两个值都能交到下游;但 StateFlow
就会吃掉第二个值,直至下一个值跟先前的值不相同才会交到下游。不过在我们这个情况因为下游都是 StateFlow
,即使上游发射重覆的值对那些 StateFlow
的下游都没有分别。
要改成 SharedFlow
,只需把原先的 stateIn
换成 shareIn
。 replay
设定 1
是为了其他人一开始订阅时就能马上收到 SharedFlow
在订阅前所发射的最後一个值,这样就不用让下游在订阅时乾等到下一次更新才能收到 CachedResult
。
改了 etaResult
後还是要改其他地方,因为 SharedFlow
是没有 value
这个 property,要取新最新的值就要用 first()
。所以我们需要一并修改 startAutoRefresh
和 viewIncidentDetail
。我们亦顺带修正 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
,它就是用来控制是否显示 CircularProgressIndicator
。CircularProgressIndicator
会在载入完成後消失,我们会检查它是不是首先显示然後转为不显示。
@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 找到。
<<: you only look once - YOLO (1)
状态管理? 在介绍 MobX 以前我想先来说一下什麽是「状态管理」 究竟为什麽我们需要「状态管理」,...
承接上一篇的基本型别与Obx绑定 这篇接下来说 List<自订型别>, enum , M...
话说为了要作出吃了会考试100分、会变聪明、变漂亮的「爆浆濑尿虾牛丸」,我特别找来庙街最重情义的火鸡...
光头古早味手工蛋饼 地点:台南市新营区东兴路203号 时间:6:00~11:30 如果仔细看可以发现...
昨天我们已经将 gotop按钮实做出来 但有时候我们不想要它一直出现 而是使用者滚轮滑到下面 它才会...