You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

单元测试NewsViewModel时cachedIn触发空指针异常,求解决方案

ViewModel单元测试中cachedIn引发NullPointerException的解决思路

异常信息

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

核心问题分析

  1. Mock对象未设置返回值:测试中使用@Mock注解的NewsRepository,但未给getBreakingNewsStream方法指定返回值,导致flatMapLatest操作后得到的Flow为null,调用cachedIn时触发空指针异常。
  2. 测试代码与ViewModel实现不匹配:测试中调用的getNewsStream方法、依赖的newsFlow属性在NewsViewModel中均不存在,属于错误调用。
  3. ViewModel初始化参数缺失:NewsViewModel构造函数需要NewsRepository和SavedStateHandle两个参数,但测试中仅传入了newsRepository,导致SavedStateHandle相关的流初始化逻辑异常。

相关代码实现

NewsViewModel.kt

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
        }
    }
}

NewsRepository.kt

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>>
}

原测试代码 NewsViewModelTest.kt

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
        )

    }
}

解决方案

1. 修正ViewModel初始化

在测试中传入SavedStateHandle实例,确保构造参数完整:

// 在setUp方法中添加
val savedStateHandle = SavedStateHandle()
savedStateHandle[NewsViewModel.KEY_SUBREDDIT] = "us"
newsViewModel = NewsViewModel(newsRepository, savedStateHandle)

2. 为Mock Repository设置有效返回值

使用Mockito为getBreakingNewsStream方法返回空的PagingData流,避免Flow为null:

import androidx.paging.PagingData
import kotlinx.coroutines.flow.flowOf

// 在测试方法中添加
val emptyPagingData = PagingData.empty<Article>()
`when`(newsRepository.getBreakingNewsStream(anyString())).thenReturn(flowOf(emptyPagingData))

3. 修正测试逻辑,观察正确的StateFlow

测试应观察ViewModel中实际存在的uiState,验证加载状态变化与数据更新:

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun test_uiStateChanges() = runTest {
    // 准备Mock数据
    val emptyPagingData = PagingData.empty<Article>()
    `when`(newsRepository.getBreakingNewsStream("us")).thenReturn(flowOf(emptyPagingData))
    `when`(newsRepository.refreshTasks()).thenReturn(Unit)

    // 收集uiState变化
    val uiStates = mutableListOf<NewsItemListUiState>()
    val job = launch {
        newsViewModel.uiState.collect { uiStates.add(it) }
    }

    // 验证初始加载状态
    assertEquals(true, uiStates.first().isLoading)

    // 触发刷新操作
    newsViewModel.refresh()
    assertEquals(true, newsViewModel.uiState.value.isLoading)

    advanceUntilIdle()

    // 验证刷新完成后的状态
    assertEquals(false, newsViewModel.uiState.value.isLoading)
    assertNotNull(newsViewModel.uiState.value.news)

    job.cancel()
}

最终修正后的测试代码

package com.sarmad.newsprism.news.ui

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.SavedStateHandle
import androidx.paging.PagingData
import com.sarmad.newsprism.data.entities.Article
import com.sarmad.newsprism.data.repository.NewsRepository
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.flow.flowOf
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.Mockito.`when`
import org.mockito.MockitoAnnotations

internal class NewsViewModelTest {

    @Mock
    lateinit var newsRepository: NewsRepository

    private lateinit var newsViewModel: NewsViewModel
    private lateinit var savedStateHandle: SavedStateHandle

    @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)
        savedStateHandle = SavedStateHandle()
        savedStateHandle[NewsViewModel.KEY_SUBREDDIT] = "us"
        newsViewModel = NewsViewModel(newsRepository, savedStateHandle)
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @After
    fun tearDown() {
        Dispatchers.resetMain()
        mainThreadSurrogate.close()
    }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    fun test_uiStateChanges() = runTest {
        // 准备Mock数据
        val emptyPagingData = PagingData.empty<Article>()
        `when`(newsRepository.getBreakingNewsStream("us")).thenReturn(flowOf(emptyPagingData))
        `when`(newsRepository.refreshTasks()).thenReturn(Unit)

        // 收集uiState变化
        val uiStates = mutableListOf<NewsItemListUiState>()
        val job: Job = launch {
            newsViewModel.uiState.collect { uiStates.add(it) }
        }

        // 验证初始加载状态
        assertEquals(true, uiStates.first().isLoading)

        // 触发刷新操作
        newsViewModel.refresh()
        assertEquals(true, newsViewModel.uiState.value.isLoading)

        advanceUntilIdle()

        // 验证刷新完成后的状态
        assertEquals(false, newsViewModel.uiState.value.isLoading)
        assertNotNull(newsViewModel.uiState.value.news)

        job.cancel()
    }
}

内容的提问来源于stack exchange,提问作者sarmad sohaib

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.10 23:15:42