Station list screen testing

终於来到为 ViewModel 写 unit test 的部分,亦都意味着这个系列快要完结。之前我们写过其他 layer 的 unit test,用过 MockKStrikt。来到现在偏向 UI 那边的 unit test,我们会用到 Robolectric

Robolectric

在 Android SDK 入面有不少 class 是跟 Java Standard Library 一样,但亦都有一大堆 class 是 Android SDK 才会有,例子有 ContextUri 等等。由於 Java Standard Library 没有这堆 class,如果无特别处理的话就不能在电脑上跑 unit test,只能拿到 Android 装置上执行(实机或模拟器),所以就出现了 Robolectric 这个 library。它能令你在电脑上执行带有 Android SDK 独有 class 的 unit test,原理就是它会为每个 Android 版本都预备一个 JAR 档案,入面载入那些 Android SDK 独有 class 的 stub,好让在执行 unit test 时不会找不到那些 class。当然你亦可以自己用 mock library 例如 MockK 把 Android SDK 的 class 都 mock 一次,但实际上要 mock 的话就很大机会不只要 mock 一个 class。例如 context.resources.getString 这个 method 你要先 mock Context 再 mock Resources 然後再 mock getResourcesgetString 两个 method,所以还是用 Robolectric 比较实际。

顺带一提,正因为 Robolectric 是制造一堆跟 Android SDK 同 signature 的 class,所以当新的 Android 版本推出时不会马上就有对应该 Android 版本的 JAR 可供下载,要等好几个月才会有。

首先我们需要加入 Android 测试相关的 dependency:

testImplementation "androidx.test:core-ktx:$testCoreVersion"
testImplementation "androidx.test.ext:junit:$testExtJunitVersion"
testImplementation "androidx.arch.core:core-testing:$coreTestingVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"

然後我们会以 LineStationPresenter 先来个简单的示范,看看如何使用 Robolectric。

@RunWith(AndroidJUnit4::class)
class LineStationPresenterTest {

    private lateinit var presenter: LineStationPresenter

    @Before
    fun setUp() {
        presenter = LineStationPresenter(ApplicationProvider.getApplicationContext())
    }

    @Test
    @Config(qualifiers = "en-rUS")
    fun `mapLine english`() {
        expectThat(presenter.mapLine(Line.AEL)).isEqualTo("Airport Express")
    }

    @Test
    @Config(qualifiers = "fr-rFR")
    fun `mapLine french`() {
        expectThat(presenter.mapLine(Line.AEL)).isEqualTo("Airport Express")
    }

    @Test
    @Config(qualifiers = "zh-rTW")
    fun `mapLine chinese taiwan`() {
        expectThat(presenter.mapLine(Line.AEL)).isEqualTo("机场快綫")
    }

    @Test
    @Config(qualifiers = "en-rUS")
    fun `mapStation english`() {
        expectThat(presenter.mapStation(Station.QUB)).isEqualTo("Quarry Bay")
    }

    @Test
    @Config(qualifiers = "fr-rFR")
    fun `mapStation french`() {
        expectThat(presenter.mapStation(Station.QUB)).isEqualTo("Quarry Bay")
    }

    @Test
    @Config(qualifiers = "zh-rHK")
    fun `mapStation chinese hong kong`() {
        expectThat(presenter.mapStation(Station.QUB)).isEqualTo("鰂鱼涌")
    }
}

LineStationPresenter 本身就是很简单,只有两个 method。由於在 constructor 需要用到 Context,所以要用到 Robolectric。当 JUnit 4 test 要用到 Robolectric 的话我们就要在 test class 加注 @RunWith(AndroidJUnit4::class)。如果要取得 Context 就要经 ApplicationProvider.getApplicationContext() 取得。

到了 test case 的部分,除了 @Test 之外我们还加了 @Config。这个是 Robolectric 的 annotation,用来控制「装置」的配置。由於 mapLinemapStation 内里要判断当前系统语言来决定输出中文还是英文名字,所以我们需要改变配置来令测试达至全面覆盖。除了改语系之外,@Config 还可以改变其他的配置,例如屏幕密度、尺寸等等,详情可以参阅 Robolectric 的文档

可能会出现的错误

有时候执行 Robolectric 的 test 出现以下错误:

java.lang.NullPointerException
	at java.base/java.io.File.<init>(File.java:279)
	at com.facebook.soloader.ApplicationSoSource.<init>(ApplicationSoSource.java:46)
	at com.facebook.soloader.SoLoader.initSoSources(SoLoader.java:285)
	at com.facebook.soloader.SoLoader.init(SoLoader.java:207)
	at com.facebook.soloader.SoLoader.init(SoLoader.java:189)
	at com.facebook.soloader.SoLoader.init(SoLoader.java:217)
	at net.swiftzer.etademo.flipper.FlipperHelper.init(FlipperHelper.kt:25)
	at net.swiftzer.etademo.EtaDemoApp.onCreate(EtaDemoApp.kt:16)
	at org.robolectric.android.internal.AndroidTestEnvironment.lambda$installAndCreateApplication$2(AndroidTestEnvironment.java:350)

这是因为我们的 EtaDemoApp 用了 Flipper,但其实执行 unit test 应该不会用到 Flipper。要解决这个问题有两个做法。第一个是好像之前处理 debug 和 release build type 的做法另外做一个假的 FlipperHelper。在 app/src/test/java/net/swiftzer/etademo/flipper 建立 FlipperHelper.kt

class FlipperHelper @Inject constructor(*
    @ApplicationContext private val context: Context,
    private val inspectorFlipperPlugin: InspectorFlipperPlugin,
    private val crashReporterPlugin: CrashReporterPlugin,
    private val databasesFlipperPlugin: DatabasesFlipperPlugin,
    private val sharedPreferencesFlipperPlugin: SharedPreferencesFlipperPlugin,
    private val networkFlipperPlugin: NetworkFlipperPlugin,
) {
    fun init() {
        // no-op
    }
}

另一个方法是在 app/src/test/resources 放一个名为 robolectric.properties 的档案。

然後入面放这些内容:

application=android.app.Application

意思就是把所有 Robolectic 的 test 都转用 android.app.Application 作为 Application class,那就避开了 EtaDemoApp 有 Flipper 的问题。先前提到 Robolectic 支援最新版 Android 会有滞後,在等待支援新版 Android 的时候我们可以在 robolectric.properties 指定 SDK level:

sdk=30

如果 test class 或 method 出现 @Config 的话,Robolectric 会优先使用 @Config 的配置。

测试 StationListViewModel

来到我们第一个 ViewModel 测试。由於 StationListViewModel 没有用到 Android SDK 的东西,所以不用加 @RunWith(AndroidJUnit4::class)。我们先来试试第一个 test 看看一开始时 StationListViewModel.list 是不是只显示路綫名称。

class StationListViewModelTest {

    private lateinit var viewModel: StationListViewModel

    @MockK
    private lateinit var getLinesAndStations: GetLinesAndStationsUseCase

    @Before
    fun setUp() {
        MockKAnnotations.init(this)
        every { getLinesAndStations() } returns linkedMapOf(
            Line.TKL to linkedSetOf(Station.LHP, Station.TKO),
            Line.TCL to linkedSetOf(Station.TUC, Station.SUN, Station.TSY),
            Line.TML to linkedSetOf(Station.TUM, Station.SIH, Station.TIS),
        )
        viewModel = StationListViewModel(getLinesAndStations)
    }

    @Test
    fun `station list default state`() = runBlockingTest {
        viewModel.list.test {
            expectThat(awaitItem()).hasSize(3).and {
                get(0).assertGroup(Line.TKL, false)
                get(1).assertGroup(Line.TCL, false)
                get(2).assertGroup(Line.TML, false)
            }
            expectNoEvents()
        }
    }

		private fun Assertion.Builder<StationListItem>.assertGroup(line: Line, isExpanded: Boolean) =
        isA<StationListItem.Group>().and {
            get(StationListItem.Group::line).isEqualTo(line)
            get(StationListItem.Group::isExpanded).isEqualTo(isExpanded)
        }

    private fun Assertion.Builder<StationListItem>.assertChild(line: Line, station: Station) =
        isA<StationListItem.Child>().and {
            get(StationListItem.Child::line).isEqualTo(line)
            get(StationListItem.Child::station).isEqualTo(station)
        }
}

由於只是测试,我们在 setUp 随便弄几条路綫和车站就可以了。在 station list default stateviewModel.list.test 订阅这个 Flow 并取得它的值做 assertion。那个 test { ... } 不是 Kotlin Flow 提供的,是用了 Turbine 这个 library。这样就可以像 RxJava 般做测试。test lambda 入面写的 code 都是在订阅後进行的。在 test lambda 入面我们可以用 awaitItem() 等待 Flow 的最新值然後拿来做 assertion。如果你期望那个 Flow 会射出两个值那你就要 call 两次 awaitItem()。最尾的 expectNoEvents() 顾名思义就是说这个 Flow 应该不会再有其他东西射出来,如果真的有就会报错。而最尾的 assertGroupassertChild 是 custom assertion,之前已经介绍过。

要用 Turbine 首先要加入这个 dependency:

testImplementation "app.cash.turbine:turbine:$turbineVersion"

然後因为 Turbine 用了未正式推出的 Kotlin Time,所以要 opt-in。

android {
    // 略……
    kotlinOptions {
        jvmTarget = '1.8'
        freeCompilerArgs += [
                "-Xuse-experimental=kotlin.time.ExperimentalTime",
        ]
    }
}

之後我们试试执行这个 test:

Exception in thread "Test worker" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
	at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:113)
	略……
Caused by: java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.
	at android.os.Looper.getMainLooper(Looper.java)
  略……

出现这个错误是因为我们在 StationListViewModel 用了 viewModelScope。而 viewModelScope 是用 Main dispatcher,Main dispatcher 在 Android 上是理解为在 UI thread 上执行。但 Kotlin Coroutine 在 Android 上用的话那个 Main dispatcher 的定义会在 kotlinx-coroutines-android 提供。如果要令 unit test 成功执行我们可以用 Dispatchers.setMain 换成 TestCoroutineDispatcher。我们一般在 ViewModel 时常用到 viewModelScope,如果每个 test method 都写一个 Dispatchers.setMainDispatchers.resetMain 会很麻烦,所以我们可以写 test rule。以下的 MainCoroutineScopeRule 就是从 Kotlin Coroutines codelab 抄回来的:

@ExperimentalCoroutinesApi
class MainCoroutineScopeRule(
    val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher(),
) : TestWatcher(), TestCoroutineScope by TestCoroutineScope(dispatcher) {
    override fun starting(description: Description?) {
        super.starting(description)
        // If your codebase allows the injection of other dispatchers like
        // Dispatchers.Default and Dispatchers.IO, consider injecting all of them here
        // and renaming this class to `CoroutineScopeRule`
        //
        // All injected dispatchers in a test should point to a single instance of
        // TestCoroutineDispatcher.
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(description: Description?) {
        super.finished(description)
        cleanupTestCoroutines()
        Dispatchers.resetMain()
    }
}

然後之前的 code 会变成这样:

class StationListViewModelTest {

    @get:Rule
    val coroutineScope = MainCoroutineScopeRule()

    // 略……

    @Test
    fun `station list default state`() = coroutineScope.runBlockingTest {
        // 略……
    }
}

留意要用 MainCoroutineScopeRulecoroutineScope 来做 runBlockingTest,否则会报错:

Unfinished coroutines during teardown. Ensure all coroutines are completed or cancelled by your test.
kotlinx.coroutines.test.UncompletedCoroutinesError: Unfinished coroutines during teardown. Ensure all coroutines are completed or cancelled by your test.
	at kotlinx.coroutines.test.TestCoroutineDispatcher.cleanupTestCoroutines(TestCoroutineDispatcher.kt:178)
	at kotlinx.coroutines.test.TestCoroutineScopeImpl.cleanupTestCoroutines(TestCoroutineScope.kt:35)
	at net.swiftzer.etademo.MainCoroutineScopeRule.cleanupTestCoroutines(MainCoroutineScopeRule.kt)
	at net.swiftzer.etademo.MainCoroutineScopeRule.finished(MainCoroutineScopeRule.kt:78)
  略……

大意就是像 Android Dev Summit '19 的 Testing Coroutines on Android 所讲的情况有点像:test case 的 runBlockingTest 执行完时 viewModelScope 还在执行中。

https://ithelp.ithome.com.tw/upload/images/20211011/20139666Bvl5DApc6C.png

当我们改用 MainCoroutineScopeRulecoroutineScope 来做 runBlockingTest,就能等待 viewModelScope 执行完才把 runBlockingTest 完结。

https://ithelp.ithome.com.tw/upload/images/20211011/20139666VxLbKfZMda.png

上述图片撷取自 Testing Coroutines on Android (Android Dev Summit '19)

这个东西在之後测试 EtaViewModel 时很有用。

我们继续写余下的 test case。这次我们试试展开其中一条路綫,看看那条路綫的车站有没有放出来。

@Test
fun `station list expand line`() = coroutineScope.runBlockingTest {
    viewModel.list.test {
        expectThat(awaitItem()).hasSize(3)
        viewModel.toggleExpanded(Line.TCL)
        expectThat(awaitItem()).hasSize(6).and {
            get(0).assertGroup(Line.TKL, false)
            get(1).assertGroup(Line.TCL, true)
            get(2).assertChild(Line.TCL, Station.TUC)
            get(3).assertChild(Line.TCL, Station.SUN)
            get(4).assertChild(Line.TCL, Station.TSY)
            get(5).assertGroup(Line.TML, false)
        }
        expectNoEvents()
    }
}

由於我们已经在另一个 test case 试过一开始时会显示三条路綫,所以针对第一个值的 assertion 只检查是不是有三项内容就算了。

由於其他跟展开和收合路綫的 test case 写法都是大同小异,我就不贴出来。现在看看当按下车站时会不会触发导航至班次页的 event。

@Test
fun `launch eta screen`() = coroutineScope.runBlockingTest {
    viewModel.launchEtaScreen.test {
        viewModel.onClickLineAndStation(Line.AEL, Station.AIR)
        expectThat(awaitItem()).isEqualTo(Line.AEL to Station.AIR)
        expectNoEvents()
    }
}

这个 test 写法很简单,就是看看当 onClickLineAndStationlaunchEtaScreen 有没有发射那个路綫和车站 Pair

小结

我们看过 Robolectric 的设定和示范了如何在 unit test 取得 Context。有了 Robolectric 我们就可以把一些不太跟 UI 有很大关系但又用了 Android SDK 的 class 的 code 在非 Android 装置上执行 unit test。这样可以加快 unit test 执行速度(因为在 Android 装置执行 unit test 必定比在普通电脑上执行 unit test 慢)。另外又示范了改变 Main dispatcher 的方法和用 Turbine 帮助测试 Kotlin Flow。下一篇我们会开始写 EtaViewModel 的 unit test。完整的 code 可以在 GitHub repo 找到。

参考


<<:  [DAY27]将Line讯息存入资料库(01)

>>:  建立Endpoint执行二次开发

[30天 Vue学好学满 DAY24] Vue Router-3

router-link to 函数 指定导向,包含以下方法 <!-- 直接指定路径 -->...

Day 5:浅谈警报 (alert) 的设计

前天使用 updown.io 架设了 status page,并且让它可以在服务无法连上的时候,自动...

[Tableau Public] day 4:尝试制作不同种类的报表-1

终於到第四天了,难熬的星期六放假日,为了完成这项意志力挑战,我还是起了个大早~ 今天我们来尝试制作地...

Day01 - 复习 canvas 做个同化别人的小方块

今天去打疫苗,想说做个感染人的坏东西,可惜没时间好好美化他,主要做出以下功能 设定两个角色(Frie...

[24] 用 python 刷 Leetcode: 66 plus-one

原始题目 You are given a large integer represented as ...