UniTest ViewModel при использовании отложенных в сопрограммах и модернизации

#android #retrofit2 #kotlin-coroutines

#Android #модернизация 2 #kotlin-сопрограммы

Вопрос:

Я хочу написать unitTest для моего класса ViewModel :

 @RunWith(MockitoJUnitRunner::class)
class MainViewModelTest {

    @get:Rule
    var rule: TestRule = InstantTaskExecutorRule()

    @Mock
    private lateinit var context: Application
    @Mock
    private lateinit var api: SuperHeroApi
    @Mock
    private lateinit var dao: HeroDao

    private lateinit var repository: SuperHeroRepository
    private lateinit var viewModel: MainViewModel

    private lateinit var heroes: List<Hero>

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)

        val localDataSource = SuperHeroLocalDataSource(dao)
        val remoteDataSource = SuperHeroRemoteDataSource(context, api)

        repository = SuperHeroRepository(localDataSource, remoteDataSource)
        viewModel = MainViewModel(repository)

        heroes = mutableListOf(
            Hero(
                1, "Batman",
                Powerstats("1", "2", "3", "4", "5"),
                Biography("Ali", "Tehran", "first"),
                Appearance("male", "Iranian", arrayOf("1.78cm"), arrayOf("84kg"), "black", "black"),
                Work("Android", "-"),
                Image("url")
            )
        )
    }

    @Test
    fun loadHeroes() = runBlocking {
        `when`(repository.getHeroes(anyString())).thenReturn(Result.Success(heroes))

        with(viewModel) {
            showHeroes(anyString())

            assertFalse(dataLoading.value!!)
            assertFalse(isLoadingError.value!!)
            assertTrue(errorMsg.value!!.isEmpty())

            assertFalse(getHeroes().isEmpty())
            assertTrue(getHeroes().size == 1)
        }
    }
}
  

Я получаю следующее исключение :

 java.lang.NullPointerException
    at com.sample.android.superhero.data.source.remote.SuperHeroRemoteDataSource$getHeroes$2.invokeSuspend(SuperHeroRemoteDataSource.kt:25)
    at |b|b|b(Coroutine boundary.|b(|b)
    at com.sample.android.superhero.data.source.SuperHeroRepository.getHeroes(SuperHeroRepository.kt:21)
    at com.sample.android.superhero.MainViewModelTest$loadHeroes$1.invokeSuspend(MainViewModelTest.kt:68)
Caused by: java.lang.NullPointerException
    at com.sample.android.superhero.data.source.remote.SuperHeroRemoteDataSource$getHeroes$2.invokeSuspend(SuperHeroRemoteDataSource.kt:25)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:233)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
    at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
    at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)
  

И вот мой класс RemoteDataSource :

 @Singleton
class SuperHeroRemoteDataSource @Inject constructor(
    private val context: Context,
    private val api: SuperHeroApi
) : SuperHeroDataSource {

    override suspend fun getHeroes(query: String): Result<List<Hero>> = withContext(Dispatchers.IO) {
        try {
            val response = api.searchHero(query).await()
            if (response.isSuccessful amp;amp; response.body()?.response == "success") {
                Result.Success(response.body()?.wrapper!!)
            } else {
                Result.Error(DataSourceException(response.body()?.error))
            }
        } catch (e: SocketTimeoutException) {
            Result.Error(
                DataSourceException(context.getString(R.string.no_internet_connection))
            )
        } catch (e: IOException) {
            Result.Error(DataSourceException(e.message ?: "unknown error"))
        }
    }
}
  

Когда мы используем Rxjava , мы можем создать наблюдаемую так просто, как :

 val observableResponse = Observable.just(SavingsGoalWrapper(listOf(savingsGoal)))
`when`(api.requestSavingGoals()).thenReturn(observableResponse)
  

Как насчет Deferred сопрограмм? Как я могу протестировать свой метод :

 fun searchHero(@Path("name") name: String): Deferred<Response<HeroWrapper>>
  

Ответ №1:

Лучший способ, который я нашел для этого, — ввести a CoroutineContextProvider и предоставить a TestCoroutineContext в тесте. Мой интерфейс провайдера выглядит следующим образом:

 interface CoroutineContextProvider {
    val io: CoroutineContext
    val ui: CoroutineContext
}
  

Фактическая реализация выглядит примерно так:

 class AppCoroutineContextProvider: CoroutineContextProvider {
    override val io = Dispatchers.IO
    override val ui = Dispatchers.Main
}
  

И тестовая реализация будет выглядеть примерно так:

 class TestCoroutineContextProvider: CoroutineContextProvider {
    val testContext = TestCoroutineContext()
    override val io: CoroutineContext = testContext
    override val ui: CoroutineContext = testContext
}
  

Таким образом, ваш SuperHeroRemoteDataSource становится:

 @Singleton
class SuperHeroRemoteDataSource @Inject constructor(
        private val coroutineContextProvider: CoroutineContextProvider,
        private val context: Context,
        private val api: SuperHeroApi
) : SuperHeroDataSource {

    override suspend fun getHeroes(query: String): Result<List<Hero>> = withContext(coroutineContextProvider.io) {
        try {
            val response = api.searchHero(query).await()
            if (response.isSuccessful amp;amp; response.body()?.response == "success") {
                Result.Success(response.body()?.wrapper!!)
            } else {
                Result.Error(DataSourceException(response.body()?.error))
            }
        } catch (e: SocketTimeoutException) {
            Result.Error(
                    DataSourceException(context.getString(R.string.no_internet_connection))
            )
        } catch (e: IOException) {
            Result.Error(DataSourceException(e.message ?: "unknown error"))
        }
    }
}
  

Когда вы вводите TestCoroutineContextProvider , вы можете вызывать такие методы, как triggerActions() и advanceTimeBy(long, TimeUnit) на testContext , чтобы ваш тест выглядел примерно так:

 @Test
fun `test action`() {
    val repository = SuperHeroRemoteDataSource(testCoroutineContextProvider, context, api)

    runBlocking {
        when(repository.getHeroes(anyString())).thenReturn(Result.Success(heroes)) 
    }

    // NOTE: you should inject the coroutineContext into your ViewModel as well
    viewModel.getHeroes(anyString())

    testCoroutineContextProvider.testContext.triggerActions()

    // Do assertions etc
}
  

Обратите внимание, что вы также должны ввести поставщик контекста сопрограммы в свою ViewModel. Также TestCoroutineContext() ObsoleteCoroutinesApi на нем есть предупреждение, поскольку оно будет переработано как часть обновления structured concurrency, но на данный момент нет никаких изменений или нового способа сделать это, см. Этот выпуск на GitHub для справки.

Комментарии:

1. 1 за вашу помощь до сих пор. repository.getHeroes(anyString() является функцией приостановки. Поэтому его следует вызвать runBlocking . Когда я запускаю тест, он никогда не заканчивается. Вы знаете, почему?

2. Он застревает в этой строке : when(repository.getHeroes("Batman")).thenReturn(Result.Success(heroes))

3. Ах да, к сожалению, вам все равно придется обернуть свои when verify блоки and runBlocking .

4. Но снова он застревает в следующей строке. Я вставил код в локальную ветку модульного тестирования в github, которым я поделился в вопросе. Вы знаете, как это решить?

5. Я вытащил и посмотрел на него. Произошло несколько вещей, которых я раньше не видел. Прежде всего, SuperHeroRepository это не макет, поэтому `when` он блокируется, поскольку он пытается запустить фактический вызов. Самый чистый способ издеваться SuperHeroRepository — сделать его интерфейсом. Кроме того, triggerActions должно выполняться только после viewModel.showHeroes вызова.