终於来到为 ViewModel
写 unit test 的部分,亦都意味着这个系列快要完结。之前我们写过其他 layer 的 unit test,用过 MockK 和 Strikt。来到现在偏向 UI 那边的 unit test,我们会用到 Robolectric。
在 Android SDK 入面有不少 class 是跟 Java Standard Library 一样,但亦都有一大堆 class 是 Android SDK 才会有,例子有 Context
、Uri
等等。由於 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 getResources
和 getString
两个 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,用来控制「装置」的配置。由於 mapLine
和 mapStation
内里要判断当前系统语言来决定输出中文还是英文名字,所以我们需要改变配置来令测试达至全面覆盖。除了改语系之外,@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 state
用 viewModel.list.test
订阅这个 Flow
并取得它的值做 assertion。那个 test { ... }
不是 Kotlin Flow 提供的,是用了 Turbine 这个 library。这样就可以像 RxJava 般做测试。test
lambda 入面写的 code 都是在订阅後进行的。在 test
lambda 入面我们可以用 awaitItem()
等待 Flow
的最新值然後拿来做 assertion。如果你期望那个 Flow
会射出两个值那你就要 call 两次 awaitItem()
。最尾的 expectNoEvents()
顾名思义就是说这个 Flow
应该不会再有其他东西射出来,如果真的有就会报错。而最尾的 assertGroup
和 assertChild
是 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.setMain
和 Dispatchers.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 {
// 略……
}
}
留意要用 MainCoroutineScopeRule
的 coroutineScope
来做 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
还在执行中。
当我们改用 MainCoroutineScopeRule
的 coroutineScope
来做 runBlockingTest
,就能等待 viewModelScope
执行完才把 runBlockingTest
完结。
上述图片撷取自 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 写法很简单,就是看看当 onClickLineAndStation
後 launchEtaScreen
有没有发射那个路綫和车站 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 找到。
router-link to 函数 指定导向,包含以下方法 <!-- 直接指定路径 -->...
前天使用 updown.io 架设了 status page,并且让它可以在服务无法连上的时候,自动...
终於到第四天了,难熬的星期六放假日,为了完成这项意志力挑战,我还是起了个大早~ 今天我们来尝试制作地...
今天去打疫苗,想说做个感染人的坏东西,可惜没时间好好美化他,主要做出以下功能 设定两个角色(Frie...
原始题目 You are given a large integer represented as ...