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 找到。

参考


<<:  you only look once - YOLO (1)

>>:  Day 27 - 建立自己的K线资料库 (中)

Day 21 | 状态管理套件 MobX - 到底什麽是状态管理

状态管理? 在介绍 MobX 以前我想先来说一下什麽是「状态管理」 究竟为什麽我们需要「状态管理」,...

[Day18] Flutter with GetX binding (二 ) 元件与属性绑定

承接上一篇的基本型别与Obx绑定 这篇接下来说 List<自订型别>, enum , M...

[Day 23] 让tinyML感受你的律动

话说为了要作出吃了会考试100分、会变聪明、变漂亮的「爆浆濑尿虾牛丸」,我特别找来庙街最重情义的火鸡...

[DAY 09] 光头古早味手工蛋饼

光头古早味手工蛋饼 地点:台南市新营区东兴路203号 时间:6:00~11:30 如果仔细看可以发现...

JS AJAX基础实作(4) DAY29

昨天我们已经将 gotop按钮实做出来 但有时候我们不想要它一直出现 而是使用者滚轮滑到下面 它才会...