junit 如果RemoteMediator(paging 3)库中的流已被收集或实现,如何测试ViewModel?

6fe3ivhb  于 2022-11-29  发布在  其他
关注(0)|答案(1)|浏览(118)

我在对viewModel进行单元测试时遇到此异常。

Exception in thread "UI thread @coroutine#1" java.lang.NullPointerException: Parameter specified as non-null is null: method androidx.paging.CachedPagingDataKt.cachedIn, parameter <this>
    at androidx.paging.CachedPagingDataKt.cachedIn(CachedPagingData.kt)
    at com.sarmad.newsprism.news.ui.NewsViewModel$getNewsStream$1.invokeSuspend(NewsViewModel.kt:46)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515)
    at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
    at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304)
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128)
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628)
    at java.base/java.lang.Thread.run(Thread.java:829)
    Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [CoroutineId(1), "coroutine#1":StandaloneCoroutine{Cancelling}@67526363, Dispatchers.Main]

expected:<false> but was:<true>
Expected :false
Actual   :true

我想测试是否调用了newsViewModel.getNewsStream(),它应该开始加载、停止加载并将更新的UiState暴露给NewsFragment,以便片段可以调用adapter.submitData(data)。但是,当我在viewModel中收集流时,cachedIn(viewModelScope)运算符中出现异常指示错误(我是初学者,即使研究了很长时间,我也无法理解)。

新闻视图模型

package com.sarmad.newsprism.news.ui

@HiltViewModel
class NewsViewModel @Inject constructor(
    private val newsRepository: NewsRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    companion object {
        const val KEY_SUBREDDIT = "us"
        const val DEFAULT_SUBREDDIT = "androiddev"
    }

    init {
        if (!savedStateHandle.contains(KEY_SUBREDDIT)) {
            savedStateHandle[KEY_SUBREDDIT] = DEFAULT_SUBREDDIT
        }
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    val articles = savedStateHandle.getLiveData<String>(KEY_SUBREDDIT)
        .asFlow()
        .flatMapLatest {
            newsRepository.getBreakingNewsStream(it)
        }.cachedIn(viewModelScope)

    private val _userMessage = MutableStateFlow<String?>(null)
    private val _isLoading = MutableStateFlow(false)
    private val _newsArticles = articles

    val uiState: StateFlow<NewsItemListUiState> = combine(
        _isLoading, _userMessage, _newsArticles
    ) { isLoading, userMessage, newsArticles ->
        NewsItemListUiState(
            news = newsArticles,
            isLoading = isLoading,
            userMessage = userMessage
        )
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000L),
        initialValue = NewsItemListUiState(isLoading = true)
    )

    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            newsRepository.refreshTasks()
            _isLoading.value = false
        }
    }
}

新闻存储库

package com.sarmad.newsprism.data.repository

import androidx.paging.PagingData
import com.sarmad.newsprism.data.entities.NewsResponse
import kotlinx.coroutines.flow.Flow
import com.sarmad.newsprism.data.Result
import com.sarmad.newsprism.data.entities.Article

interface NewsRepository {

    suspend fun getSearchedNewsStream(searchQuery: String, pageNumber: Int):
            Flow<NewsResponse>

    suspend fun getBreakingNewsStream(countryCode: String): Flow<PagingData<Article>>
}

新闻储存库实作

package com.sarmad.newsprism.data.repository

import android.util.Log
import androidx.paging.*
import com.sarmad.newsprism.data.Result
import com.sarmad.newsprism.data.entities.Article
import com.sarmad.newsprism.data.entities.NewsResponse
import com.sarmad.newsprism.data.localdatasource.ArticleDao
import com.sarmad.newsprism.data.localdatasource.ArticleDatabase
import com.sarmad.newsprism.data.localdatasource.RemoteKeysDao
import com.sarmad.newsprism.data.paging.mediaters.NewsRemoteMediator
import com.sarmad.newsprism.data.remotedatasource.api.NewsApi
import com.sarmad.newsprism.utils.Constants.Companion.PAGING_CONFIG_PAGE_SIZE
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapLatest
import javax.inject.Inject

private const val TAG = "NewsRepositoryImpl"

class NewsRepositoryImpl @Inject constructor(
    private val api: NewsApi,
    private val articleDao: ArticleDao,
    private val articleDatabase: ArticleDatabase,
    private val remoteKeysDao: RemoteKeysDao,
) : NewsRepository {

    override suspend fun getSearchedNewsStream(
        searchQuery: String,
        pageNumber: Int
    ): Flow<NewsResponse> = flow {
        val searchedNewsResponse = api.searchNews(searchQuery, pageNumber)

        if (searchedNewsResponse.isSuccessful) searchedNewsResponse.body()
            ?.let { newsList -> emit(newsList) }
        else emptyFlow<NewsResponse>()
    }

    @OptIn(ExperimentalPagingApi::class)
    override suspend fun getBreakingNewsStream(
        countryCode: String
    ): Flow<PagingData<Article>> {

     return Pager(
            config = PagingConfig(
                pageSize = PAGING_CONFIG_PAGE_SIZE
            ),
            remoteMediator = NewsRemoteMediator(articleDatabase, articleDao, remoteKeysDao, api),
            pagingSourceFactory = { articleDao.getNewsStream() }
        ).flow
    }
}

新闻远程编辑器

package com.sarmad.newsprism.data.paging.mediaters

import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.sarmad.newsprism.data.remotedatasource.api.NewsApi
import com.sarmad.newsprism.data.entities.Article
import com.sarmad.newsprism.data.entities.ArticleRemoteKey
import com.sarmad.newsprism.data.localdatasource.ArticleDao
import com.sarmad.newsprism.data.localdatasource.ArticleDatabase
import com.sarmad.newsprism.data.localdatasource.RemoteKeysDao
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.ceil

@OptIn(ExperimentalPagingApi::class)
class NewsRemoteMediator @Inject constructor(
    private val articleDatabase: ArticleDatabase,
    private val articleDao: ArticleDao,
    private val remoteKeysDao: RemoteKeysDao,
    private val api: NewsApi
) : RemoteMediator<Int, Article>() {

    override suspend fun initialize(): InitializeAction {
        val newsCacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
        val isSkipRefresh = remoteKeysDao.getLastUpdateTime()?.let {
            System.currentTimeMillis() - it >= newsCacheTimeout
        }

        return if (isSkipRefresh == true) {
            InitializeAction.SKIP_INITIAL_REFRESH
        } else {
            InitializeAction.LAUNCH_INITIAL_REFRESH
        }
    }

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Article>
    ): MediatorResult {
        return try {

            val currentPage = when (loadType) {
                LoadType.REFRESH -> {
                    val remoteKey = getRemoteKeyClosestToCurrentPosition(state)
                    remoteKey?.nextPage?.minus(1) ?: 1
                }
                LoadType.PREPEND -> {
                    val remoteKey = getRemoteKeyForFirstItem(state)
                    val prevPage = remoteKey?.prevPage ?: return MediatorResult.Success(
                        remoteKey != null
                    )
                    prevPage
                }
                LoadType.APPEND -> {
                    val remoteKey = getRemoteKeyForLastItem(state)
                    val nextPage =
                        remoteKey?.nextPage
                            ?: return MediatorResult.Success(remoteKey != null)
                    nextPage
                }
            }

            val response = api.getBreakingNews("us", currentPage)

            val totalPages = response.body()?.totalResults?.toDouble()?.div(20)?.let { pages ->
                ceil(pages)
            }?.toInt()

            val endOfPaginationReached = totalPages == currentPage

            val nextPage = if (endOfPaginationReached) null else currentPage.plus(1)
            val prevPage = if (currentPage == 1) null else currentPage.minus(1)

            articleDatabase.withTransaction {

                if (loadType == LoadType.REFRESH) {
                    articleDao.deleteAllArticles()
                    remoteKeysDao.deleteAllArticleRemoteKeys()
                }

                response.body()?.let { response ->
                    val keys = articleDao.insertAll(response.articles)

                    val mappedKeysToArticles = keys.map { key ->
                        ArticleRemoteKey(
                            id = key.toInt(),
                            nextPage = nextPage,
                            prevPage = prevPage,
                            modifiedAt = System.currentTimeMillis()
                        )
                    }
                    remoteKeysDao.insertArticleRemoteKeys(mappedKeysToArticles)
                }
            }
            MediatorResult.Success(endOfPaginationReached)
        } catch (ex: java.lang.Exception) {
            return MediatorResult.Error(ex)
        }
    }

    private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Article>
    ): ArticleRemoteKey? {
        return state.anchorPosition?.let { pos ->
            state.closestItemToPosition(pos)?.id?.let { id ->
                remoteKeysDao.getArticleRemoteKey(id)
            }
        }
    }

    private suspend fun getRemoteKeyForFirstItem(
        state: PagingState<Int, Article>
    ): ArticleRemoteKey? {

        return state.pages.firstOrNull {
            it.data.isNotEmpty()
        }?.data?.firstOrNull().let {
            it?.let { it1 -> remoteKeysDao.getArticleRemoteKey(it1.id) }
        }
    }

    private suspend fun getRemoteKeyForLastItem(
        state: PagingState<Int, Article>
    ): ArticleRemoteKey? {

        return state.pages.lastOrNull {
            it.data.isNotEmpty()
        }?.data?.lastOrNull().let {
            it?.let { it1 -> remoteKeysDao.getArticleRemoteKey(it1.id) }
        }
    }
}

新闻视图模型测试

package com.sarmad.newsprism.news.ui

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.sarmad.newsprism.data.remotedatasource.api.NewsApi
import com.sarmad.newsprism.data.repository.NewsRepository
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations

internal class NewsViewModelTest {

    @Mock
    lateinit var newsRepository: NewsRepository

    @Mock
    private lateinit var newsApi: NewsApi

    private lateinit var newsViewModel: NewsViewModel

    @OptIn(DelicateCoroutinesApi::class)
    private val mainThreadSurrogate = newSingleThreadContext("UI thread")

    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()

    @OptIn(ExperimentalCoroutinesApi::class)
    @Before
    fun setUp() {
        MockitoAnnotations.openMocks(this)
        Dispatchers.setMain(mainThreadSurrogate)
        newsViewModel = NewsViewModel(newsRepository)
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @After
    fun tearDown() {
        Dispatchers.resetMain() // reset the main dispatcher to the original Main dispatcher
        mainThreadSurrogate.close()
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun test_getNewsFlow() = runTest {

        newsViewModel.getNewsStream("us")
        assertEquals(true, newsViewModel.newsFlow.value.isLoading)

        advanceUntilIdle()

        assertEquals(
            false,
            newsViewModel.newsFlow.value.isLoading
        )

        assertNotNull(
            newsViewModel.newsFlow.value.news
        )

    }
}
ou6hu8tu

ou6hu8tu1#

检查呼叫的最佳方法是使用Mock。
为您的NewsViewModel创建一个接口,如INewsViewModel,并使用构造函数或安装程序注入它。根据您的Mock包,它可以如下创建:

//Implementation using Moq     
Mock<INewsViewModel> mock = new Mock<INewsViewModel>(); 
mock.Setup(m => m.getNewsStream());
// Your test 
mock.VerifyAll();

Moq还允许在被模拟的类有一个空的构造函数时创建一个Mock。

相关问题