在我们的应用测试策略中,单元测试是基本测试。通过单元测试,我们可以验证我们编写的单个逻辑是否正确。我们可以在每次构建项目后运行这些单元测试,确保任何新的改动不会对影响到现有的代码。因此,单元测试可帮助我们在发版之前快速捕获并修复问题。
如果你是 Android 开发的新手,或者已经有多年的经验,你现在应该对 Kotlin 非常熟悉了。Android 在 API30 弃用了 AsyncTask 之后,最好的选择是使用 RxJava 或 Kotlin 的异步编程协程。本文是关于 Kotlin 的协程和测试它们的不同方法的。我们还将继续探索新的支持库和最佳实践。
我们将学习什么?
- 使用
runBlocking()
和runBlockingTest()
测试挂起函数; - 使用
TestCoroutineDispatcher
和MainCoroutineRule
测试运行在Dispatchers.Main
上面的协程; - 注入
Dispatcher
(调度器)以测试除了Dispatchers.Main
之外的协程调度器,比如Dispatchers.IO
; - 使用
InstantTaskExecutorRule
测试 LiveData; - 使用
LiveDataTestUtil
监听 LiveData。
配置我们的项目
为了演示该项目,我们将使用以下三个依赖库:
//For using viewModelScope
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0"
//For runBlockingTest, CoroutineDispatcher etc.
testImplementation “org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2”
//For InstantTaskExecutorRule
testImplementation “androidx.arch.core:core-testing:2.1.0”
使用 runBlocking()
和 runBlockingTest()
测试挂起函数
挂起函数(suspending function)指的是可以挂起并在后续恢复的函数。这些函数能够执行耗时操作,直至执行完毕也不会阻塞。挂起函数只能在另一个挂起函数或者协程中调用。
runBlocking()
如果我们的应用程序有任何挂起函数,则必须从另一个挂起函数调用它或从协程启动它。我们可以通过 runBlocking()
函数来解决这个问题。它会启动一个新的协程,并阻塞当前的线程直至代码运行完毕。让我们来看一个例子:
class MainViewModel : ViewModel() {
private var isSessionExpired = false
suspend fun checkSessionExpiry(): Boolean {
withContext(Dispatchers.IO) {
delay(5_000) // to simulate a heavy weight operations
isSessionExpired = true
}
return isSessionExpired
}
}
class MainViewModelTest {
@Test
fun testCheckSessionExpiry() = runBlocking {
val mainViewModel = MainViewModel()
val totalExecutionTime = measureTimeMillis {
val isSessionExpired = mainViewModel.checkSessionExpiry()
assertTrue(isSessionExpired)
}
print("Total Execution Time: $totalExecutionTime")
}
}
在上面的示例中,MainViewModel
中有个 checkSessionExpiry()
函数,该函数会校验用户的 session 是否过期并返回结果。为了模拟 IO 任务,我们使用了 delay
函数。在 MainViewModelTest
类中,我们有一个校验返回值是否为 true
的测试函数。如果你运行的话 它将通过。但你会注意有个 5 秒的延迟,这是因为 runBlocking()
不能跳过 delay
操作。
runBlockingTest()
runBlockingTest()
是 kotlinx.coroutines.runBlock 包的一部分。类似于函数,但它将立即越过延迟并进入 launch
和 async
代码块。你可以使用它来编写在存在延迟调用的情况下执行的测试,而不会花费额外的时间。如果我们使用 runBlockingTest()
来运行前面的测试示例的话就不会产生延迟:
class MainViewModel : ViewModel() {
private var isSessionExpired = false
suspend fun checkSessionExpiry(): Boolean {
withContext(Dispatchers.IO) {
delay(5_000) // to simulate a heavy weight operations
isSessionExpired = true
}
return isSessionExpired
}
}
class MainViewModelTest {
@ExperimentalCoroutinesApi
@Test
fun testCheckSessionExpiry() = runBlockingTest {
val mainViewModel = MainViewModel()
val totalExecutionTime = measureTimeMillis {
val isSessionExpired = mainViewModel.checkSessionExpiry()
assertTrue(isSessionExpired)
}
print("Total Execution Time: $totalExecutionTime")
}
}
注意:
- 此测试被标记为
ExperimentalCoroutinesApi
,因此将来可能会更改;- 永远不要在你的应用中使用
runBlocking()
,它仅适用于单元测试。
使用 TestCoroutineDispatcher
和 MainCoroutineRule
测试运行在 Dispatchers.Main
上面的协程
TestCoroutineDispatcher
我们已经使用 runBlocking()
和 runBlockingTest()
成功编写了一个单元测试,但你是否注意到我们所有的测试都是在 Dispatcher.IO
线程中启动的?如果我们使用 Dispatcher.Main
替代 Dispatcher.IO
会怎么样呢?来看一个例子:
class MainViewModel : ViewModel() {
private var userData: Any? = null
fun getUserData(): Any? = userData
suspend fun saveSessionData() {
viewModelScope.launch {
userData = "some_user_data"
}
}
}
class MainViewModelTest {
@ExperimentalCoroutinesApi
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel()
mainViewModel.saveSessionData()
val userData = mainViewModel.getUserData()
assertEquals("some_user_data", userData)
}
}
注意:
-
viewModelScope
位于 androidx.lifecycle 包中,这个作用域与ViewModel
绑定,会在 ViewModel 被清除时调用,即ViewModel.onCleared()
被调用时;- 默认情况下,
viewModelScope
总是运行在主线程上,但你可以通过配置 Dispatcher 来让它运行在其他线程上。
如果我们尝试在 Dispatcher.Main
线程中运行单元测试,就会失败并报出如下错误
Exception in thread "main @coroutine#1" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
这是正常的,因为我们不能在单元测试中使用主循环器(main looper
)。让我们试试将 Dispatcher
替换为 TestCoroutineDispatcher
。
class MainViewModel : ViewModel() {
private var userData: Any? = null
fun getUserData(): Any? = userData
suspend fun saveSessionData() {
viewModelScope.launch {
userData = "some_user_data"
}
}
}
@ExperimentalCoroutinesApi
class MainViewModelTest {
@ExperimentalCoroutinesApi
private val testDispatcher = TestCoroutineDispatcher()
@Before
fun setup() {
// 1
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
// 2
Dispatchers.resetMain()
// 3
testDispatcher.cleanupTestCoroutines()
}
@ExperimentalCoroutinesApi
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel()
mainViewModel.saveSessionData()
val userData = mainViewModel.getUserData()
assertEquals("some_user_data", userData)
}
}
代码分析:
- 所有在底层使用了
Dispatchers.Main
的地方都会被替换成我们给定的testDispatcher
; - 将
Dispatchers.Main
的状态重置为原始的主调度器(main dispatcher); - 清除
TestCoroutineDispatcher
以确保没有其他操作在运行。
MainCoroutineRule
到目前为止,我们已经知道如何测试 Dispatcher.IO
和 Dispatcher.Main
中的协程。默认情况下,协程起作用于 Dispatcher.Main
,若想测试 Dispatcher.IO
,我们必须使用 TestCoroutineDispatcher
将主调度器替换掉。这个可以有效运行,却不能很好地扩展。每当我们增加测试类时,都必须一次又一次地写相同的样板代码。为了克服这个问题,我们可以创建自定义的 MainCoroutineRule,然后通过 @Rule
注解添加到我们的测试类中。
@ExperimentalCoroutinesApi
class MainCoroutineRule : TestWatcher(), TestCoroutineScope by TestCoroutineScope() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(this.coroutineContext[ContinuationInterceptor] as CoroutineDispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
}
MainCoroutineRule 会在单元测试时将主协程调度器设置为 TestCoroutineScope
。TestCoroutineScope
会控制协程的运行。我们只需像 @Rule
一样使用它即可:
class MainViewModel : ViewModel() {
private var userData: Any? = null
fun getUserData(): Any? = userData
suspend fun saveSessionData() {
viewModelScope.launch(Dispatchers.IO) {
userData = "some_user_data"
}
}
}
@ExperimentalCoroutinesApi
class MainViewModelTest {
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
@ExperimentalCoroutinesApi
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel()
mainViewModel.saveSessionData()
val userData = mainViewModel.getUserData()
assertEquals("some_user_data", userData)
}
}
注入 Dispatcher
(调度器)以测试除了 Dispatchers.Main
之外的协程调度器,比如 Dispatchers.IO
前面我们已经探索了如何在 Dispatchers.Main
和 Dispatchers.IO
上测试协程。不过,如果我们要动态地改变单元测试的调度器的话会出现什么情况呢?来看看下面的例子:
class MainViewModel : ViewModel() {
private var userData: Any? = null
fun getUserData(): Any? = userData
suspend fun saveSessionData() {
viewModelScope.launch(Dispatchers.IO) {
userData = "some_user_data"
}
}
}
@ExperimentalCoroutinesApi
class MainViewModelTest {
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel()
mainViewModel.saveSessionData()
val userData = mainViewModel.getUserData()
assertEquals("some_user_data", userData)
}
}
在这里,我们通过能够接管主调度器(MainDispatcher)的 MainCoroutineRule
将 TestCorotutineDispatcher
设为了主调度器,然而我们还是无法控制通过 viewModelScope.launch(Dispatchers.IO)
启动的协程的运行。与此同时,如果我们运行测试的话就会显示断言错误(assertion error),因为我们的测试是运行在了与 ViewModel
的线程不不同的线程中。现在,我们要如何解决这个问题呢?
引自谷歌:
为了准确的测试,
Dispatchers
应当注入到ViewModels
中。将Dispatcher
传入到ViewModel
的构造方法中能够确保你使用同一调度器测试和编写代码。
我们修改后的 ViewModel
和测试类的代码会是这样的:
class MainViewModel (
private val dispatcher: CoroutineDispatcher
) : ViewModel() {
private var userData: Any? = null
fun getUserData(): Any? = userData
suspend fun saveSessionData() {
viewModelScope.launch(dispatcher) {
userData = "some_user_data"
}
}
}
@ExperimentalCoroutinesApi
class MainViewModelTest {
private val testDispatcher = TestCoroutineDispatcher()
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel(testDispatcher)
mainViewModel.saveSessionData()
val userData = mainViewModel.getUserData()
assertEquals("some_user_data", userData)
}
}
运行测试,现在应该没问题了。
使用 InstantTaskExecutorRule
测试 LiveData;
至此,我们已经学习了测试协程所需的全部知识。作为额外奖励,我想谈谈 LiveData
的测试。让我们从以下的示例开始吧:
class MainViewModel(
private val dispatcher: CoroutineDispatcher
) : ViewModel() {
private var _userData: MutableLiveData<Any> = MutableLiveData<Any>()
val userData: LiveData<Any> = _userData
suspend fun saveSessionData() {
viewModelScope.launch(dispatcher) {
_userData.value = "some_user_data"
}
}
}
@ExperimentalCoroutinesApi
class MainViewModelTest {
private val testDispatcher = TestCoroutineDispatcher()
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel(testDispatcher)
mainViewModel.saveSessionData()
val userData = mainViewModel.userData.value
assertEquals("some_user_data", userData)
}
}
看似是个完美的测试,可一旦运行就会报如下错误了:
Exception in thread "main @coroutine#2" 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)
这是由于 LiveData
是属于 Android 生命周期的一部分,它需要访问主循环器(main looper)才能运行下去。好在我们一行代码就可以解决这个问题:只需将 androidx.arch.core.executor.testing 包中 InstantTaskExecutorRule
作为 @Rule
添加到代码中。现在重新运行,应该就会通过。
class MainViewModel(
private val dispatcher: CoroutineDispatcher
) : ViewModel() {
private var _userData: MutableLiveData<Any> = MutableLiveData<Any>()
val userData: LiveData<Any> = _userData
suspend fun saveSessionData() {
viewModelScope.launch(dispatcher) {
_userData.value = "some_user_data"
}
}
}
@ExperimentalCoroutinesApi
class MainViewModelTest {
private val testDispatcher = TestCoroutineDispatcher()
@ExperimentalCoroutinesApi
@get:Rule
var mainCoroutineRule = MainCoroutineRule()
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()
@Test
fun testsSaveSessionData() = runBlockingTest {
val mainViewModel = MainViewModel(testDispatcher)
mainViewModel.saveSessionData()
val userData = mainViewModel.userData.value
assertEquals("some_user_data", userData)
}
}
InstantTaskExecutorRule
是一个 JUnit
测试规则,它将架构组件使用的后台执行程序与同步执行每个任务的执行程序交换。
使用 LiveDataTestUtil
监听 LiveData
如果你想监听 LiveData
的变化,可以使用扩展函数 LiveDataTestUtil
,它可以帮助你毫不费力地达到目的:
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS,
afterObserve: () -> Unit = {}
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValue.removeObserver(this)
}
}
this.observeForever(observer)
try {
afterObserve.invoke()
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
} finally {
this.removeObserver(observer)
}
@Suppress("UNCHECKED_CAST")
return data as T
}