Android:使用协程进行不稳定的 ViewModel 单元测试
Android: flaky ViewModel unit tests with coroutines
我有一个 VM
class CityListViewModel(private val repository: Repository) : ViewModel() {
@VisibleForTesting
val allCities: LiveData<Resource<List<City>>> =
liveData(context = viewModelScope.coroutineContext + Dispatchers.IO) {
emit(Resource.Loading())
emit(repository.getCities())
}
}
我的测试是:
@ExperimentalCoroutinesApi
class CityListViewModelTest {
@get:Rule
val rule = InstantTaskExecutorRule()
@get:Rule
val coroutineTestRule = CoroutinesTestRule()
@Test
fun `allCities should emit first loading and then a Resource#Success value`() =
runBlockingTest {
val fakeSuccessResource = Resource.Success(
listOf(
City(
1,
"UK",
"London",
Coordinates(34.5, 56.2)
)
)
)
val observer: Observer<Resource<List<City>>> = mock()
val repositoryMock: Repository = mock()
val sut =
CityListViewModel(repositoryMock)
doAnswer { fakeSuccessResource }.whenever(repositoryMock).getCities()
sut.allCities.observeForever(observer)
sut.allCities
val captor = argumentCaptor<Resource<List<City>>>()
captor.run {
verify(observer, times(2)).onChanged(capture())
assertEquals(fakeSuccessResource.data, lastValue.data)
}
}
@Test
fun `allCities should emit first loading and then a Resource#Error value`() =
runBlockingTest {
val fakeErrorResource = Resource.Error<List<City>>("Error")
val observer: Observer<Resource<List<City>>> = mock()
val repositoryMock: Repository = mock()
val sut =
CityListViewModel(repositoryMock)
doAnswer { fakeErrorResource }.whenever(repositoryMock).getCities()
sut.allCities.observeForever(observer)
sut.allCities
val captor = argumentCaptor<Resource<List<City>>>()
captor.run {
verify(observer, times(2)).onChanged(capture())
assertEquals(fakeErrorResource.data, lastValue.data)
}
}
}
我遇到的问题是测试非常不稳定:有时它们都通过,有时一个失败,但我似乎无法找出问题所在。
谢谢!
问题是在测试中,您无法控制 IO Dispatcher。我假设你的 CoroutinesTestRule
是这样的 Gist?这只会覆盖 Dispatchers.Main
,但您的 CityListViewModel
使用 Dispatchers.IO
。
有几个不同的选项:
- 在
CityListViewModel
中,您可以避免显式使用 Dispatchers.IO
,而是仅依赖默认为 Dispatchers.Main
的 viewModelScope
。在您真正的 Repository
实现中,确保您的挂起 getCities()
方法重定向到 Dispatchers.IO
,即
suspend fun getCities(): List<City> {
withContext(Dispatchers.IO) {
// do work
return cities
}
}
并且在 CityListViewModel
:
val allCities: LiveData<Resource<List<City>>> =
liveData(context = viewModelScope.coroutineContext) {
emit(Resource.Loading())
emit(repository.getCities())
}
在这种情况下,事情将继续像当前一样工作,并且在您的测试中,模拟 Repository
将立即 return 一个值。
- 改为注入
Dispatchers.IO
。如果你使用的是像 Dagger 这样的 DI 框架,这会更容易,但你基本上可以这样做:
class CityListViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val repository: Repository
) : ViewModel() {
@VisibleForTesting
val allCities: LiveData<Resource<List<City>>> =
liveData(context = viewModelScope.coroutineContext + ioDispatcher) {
emit(Resource.Loading())
emit(repository.getCities())
}
}
然后在你的测试中:
val viewModel = CityListViewModel(
ioDispatcher = TestCoroutineDispatcher(),
repository = repository
)
其中任何一个都应该使您的测试具有确定性。如果您使用的是 Dagger,那么我建议您同时执行这两项操作(创建一个生产模块来提供 Main、IO 和默认调度程序,但有一个提供 TestCoroutineDispatcher
实例的测试模块),但也可以执行选项1 这是为了确保您的挂起函数将工作定向到另一个调度程序,如果他们正在进行阻塞工作。
我认为最好的方法是将 Dispatcher 传递给您的 viewModels,这样测试就超级简单了。就像这里一样:
我有一个 VM
class CityListViewModel(private val repository: Repository) : ViewModel() {
@VisibleForTesting
val allCities: LiveData<Resource<List<City>>> =
liveData(context = viewModelScope.coroutineContext + Dispatchers.IO) {
emit(Resource.Loading())
emit(repository.getCities())
}
}
我的测试是:
@ExperimentalCoroutinesApi
class CityListViewModelTest {
@get:Rule
val rule = InstantTaskExecutorRule()
@get:Rule
val coroutineTestRule = CoroutinesTestRule()
@Test
fun `allCities should emit first loading and then a Resource#Success value`() =
runBlockingTest {
val fakeSuccessResource = Resource.Success(
listOf(
City(
1,
"UK",
"London",
Coordinates(34.5, 56.2)
)
)
)
val observer: Observer<Resource<List<City>>> = mock()
val repositoryMock: Repository = mock()
val sut =
CityListViewModel(repositoryMock)
doAnswer { fakeSuccessResource }.whenever(repositoryMock).getCities()
sut.allCities.observeForever(observer)
sut.allCities
val captor = argumentCaptor<Resource<List<City>>>()
captor.run {
verify(observer, times(2)).onChanged(capture())
assertEquals(fakeSuccessResource.data, lastValue.data)
}
}
@Test
fun `allCities should emit first loading and then a Resource#Error value`() =
runBlockingTest {
val fakeErrorResource = Resource.Error<List<City>>("Error")
val observer: Observer<Resource<List<City>>> = mock()
val repositoryMock: Repository = mock()
val sut =
CityListViewModel(repositoryMock)
doAnswer { fakeErrorResource }.whenever(repositoryMock).getCities()
sut.allCities.observeForever(observer)
sut.allCities
val captor = argumentCaptor<Resource<List<City>>>()
captor.run {
verify(observer, times(2)).onChanged(capture())
assertEquals(fakeErrorResource.data, lastValue.data)
}
}
}
我遇到的问题是测试非常不稳定:有时它们都通过,有时一个失败,但我似乎无法找出问题所在。
谢谢!
问题是在测试中,您无法控制 IO Dispatcher。我假设你的 CoroutinesTestRule
是这样的 Gist?这只会覆盖 Dispatchers.Main
,但您的 CityListViewModel
使用 Dispatchers.IO
。
有几个不同的选项:
- 在
CityListViewModel
中,您可以避免显式使用Dispatchers.IO
,而是仅依赖默认为Dispatchers.Main
的viewModelScope
。在您真正的Repository
实现中,确保您的挂起getCities()
方法重定向到Dispatchers.IO
,即
suspend fun getCities(): List<City> {
withContext(Dispatchers.IO) {
// do work
return cities
}
}
并且在 CityListViewModel
:
val allCities: LiveData<Resource<List<City>>> =
liveData(context = viewModelScope.coroutineContext) {
emit(Resource.Loading())
emit(repository.getCities())
}
在这种情况下,事情将继续像当前一样工作,并且在您的测试中,模拟 Repository
将立即 return 一个值。
- 改为注入
Dispatchers.IO
。如果你使用的是像 Dagger 这样的 DI 框架,这会更容易,但你基本上可以这样做:
class CityListViewModel(
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val repository: Repository
) : ViewModel() {
@VisibleForTesting
val allCities: LiveData<Resource<List<City>>> =
liveData(context = viewModelScope.coroutineContext + ioDispatcher) {
emit(Resource.Loading())
emit(repository.getCities())
}
}
然后在你的测试中:
val viewModel = CityListViewModel(
ioDispatcher = TestCoroutineDispatcher(),
repository = repository
)
其中任何一个都应该使您的测试具有确定性。如果您使用的是 Dagger,那么我建议您同时执行这两项操作(创建一个生产模块来提供 Main、IO 和默认调度程序,但有一个提供 TestCoroutineDispatcher
实例的测试模块),但也可以执行选项1 这是为了确保您的挂起函数将工作定向到另一个调度程序,如果他们正在进行阻塞工作。
我认为最好的方法是将 Dispatcher 传递给您的 viewModels,这样测试就超级简单了。就像这里一样: