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

ViewModel单元测试问题:返回对象数据始终为空

MovieDetailsViewModel单元测试异常排查与解决

问题概述

对MovieDetailsViewModel进行单元测试时,返回的MovieDetailsResponse对象始终为空,同时存在SavedStateHandle模拟不生效、协程调度异常等问题。

相关代码

ViewModel代码

@HiltViewModel
class MovieDetailsViewModel @Inject constructor(
    private val repository: MovieRepository,
    private val savedStateHandle: SavedStateHandle,
    private val appDispatchers: AppDispatchers,
    ): ViewModel() {

init {
    val movieId = savedStateHandle.get<Int>("id") ?: 568124
    viewModelScope.launch {
        val movieDetails = withContext(appDispatchers.IO) {
            getMovieDetails(movieId)
        }
        movieDetailsState.value = movieDetails
    }
}

    val movieDetailsState: MutableState<MovieDetailsResponse> = mutableStateOf(MovieDetailsResponse())

    private suspend fun getMovieDetails(id: Int): MovieDetailsResponse {
        return repository.getMovie(id)
    }
}

MovieRepository代码

class MovieRepository
@Inject constructor(
    private val tmdbWebService: TMDBWebService
) {
    suspend fun getMovie(id: Int): MovieDetailsResponse {
        return tmdbWebService.getMovieDetails(id)
    }
}

AppDispatchers代码

data class AppDispatchers(
    val IO: CoroutineDispatcher = Dispatchers.IO
)

单元测试代码

@ExperimentalCoroutinesApi
class MovieDetailsViewModelTest {

    @get:Rule
    var mainCoroutineRule = MainCoroutineRule()

    private val movieRepository = mock<MovieRepository>()
    private val savedStateHandle = mock<SavedStateHandle>()

    private lateinit var viewModel: MovieDetailsViewModel

    private val testDispatcher = AppDispatchers(
        IO = TestCoroutineDispatcher()
    )

    @Before
    fun setup() {
        viewModel = MovieDetailsViewModel(movieRepository, savedStateHandle, testDispatcher)
    }

@Test
fun `Loading state works`() = runBlockingTest {
    coEvery { savedStateHandle.get<Int>("id") } returns 5
    coEvery { movieRepository.getMovie(5) } returns getDummyMovieDetailData(5)

    viewModel = MovieDetailsViewModel(movieRepository, savedStateHandle, testDispatcher)
    Assert.assertEquals(getDummyMovieDetailData(5), viewModel.movieDetailsState.value)
}

    private fun getDummyMovieDetailData(id: Int): MovieDetailsResponse {
        return MovieDetailsResponse(
            id = id,
            budget = 50000,
            title = "Encanto",
            overview = "The tale of an extraordinary family, the Madrigals, who live hidden in the mountains of Colombia, in a magical house, in a vibrant town, in a wondrous, charmed place called an Encanto. The magic of the Encanto has blessed every child in the family—every child except one, Mirabel. But when she discovers that the magic surrounding the Encanto is in danger, Mirabel decides that she, the only ordinary Madrigal, might just be her exceptional family's last hope.",
            releaseDate = "2021-10-13",
            revenue = 253000000,
            runtime = 102,
            status = "released",
            tagline = "There's a little magic in all of us...almost all of us.",
            voteAverage = 7.637,
            genres = getDummyGenreData(),
            productionCompanies = getDummyProductionCompanyData()
        )
    }

    private fun getDummyGenreData(): ArrayList<Genres> {
        return arrayListOf(
            Genres(16, "Animation"),
            Genres(35, "Comedy"),
            Genres(10751, "Family"),
            Genres(14, "Fantasy"),
        )
    }

    private fun getDummyProductionCompanyData(): ArrayList<ProductionCompanies> {
        return arrayListOf(
            ProductionCompanies(
                6125,
                "/tzsMJBJZINu7GHzrpYzpReWhh66.png",
                "Walt Disney Animation Studios",
                "US"
            ),
            ProductionCompanies(
                2,
                "/wdrCwmRnLFJhEoH8GSfymY85KHT.png",
                "Walt Disney Pictures",
                "US"
            )
        )
    }
}

build.gradle依赖配置

// For local unit tests
testImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptTest 'com.google.dagger:hilt-compiler:2.46.1'

// Mockito
testImplementation 'org.mockito.kotlin:mockito-kotlin:4.1.0'
testImplementation 'org.mockito:mockito-core:3.12.4'
testImplementation 'org.mockito:mockito-inline:2.13.0'
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2"

//Mockk
testImplementation "io.mockk:mockk:1.13.5"
testImplementation "io.mockk:mockk-android:1.13.5"
testImplementation "io.mockk:mockk-agent:1.13.5"
androidTestImplementation "io.mockk:mockk-android:1.13.5"
androidTestImplementation "io.mockk:mockk-agent:1.13.5"

问题细节

  • 测试中通过coEvery设置savedStateHandle.get<Int>("id")返回5,但ViewModel中movieId始终使用默认值568124;
  • 强制将默认id设为5后,返回的movieDetails仍为空;
  • 报错信息:Cannot invoke "androidx.compose.runtime.MutableState.setValue(Object)" because the return value of "com.someone.xmovies.ui.detailsscreen.MovieDetailsViewModel.getMovieDetailsState()" is null;
  • 实际返回的MovieDetailsResponse多数字段为null,与预期数据不符:
    Expected :MovieDetailsResponse(adult=null, backdropPath=null, belongsToCollection=BelongsToCollection(id=null, name=null, posterPath=null, backdropPath=null), budget=50000, genres=[Genres(id=16, name=Animation), Genres(id=35, name=Comedy), Genres(id=10751, nam ...
    
    Actual   :MovieDetailsResponse(adult=null, backdropPath=null, belongsToCollection=BelongsToCollection(id=null, name=null, posterPath=null, backdropPath=null), budget=null, genres=[], homepage=null, id=null, imdbId=null, originalLanguage=null, originalTitle=nul ...
    

解决方案

1. 调整Mock初始化顺序

测试中先初始化ViewModel再配置Mock行为,会导致ViewModel的init块执行时Mock未生效。修改测试代码,先配置Mock再初始化ViewModel:

@Test
fun `Loading state works`() = runBlockingTest {
    // 先配置Mock行为
    coEvery { savedStateHandle.get<Int>("id") } returns 5
    coEvery { movieRepository.getMovie(5) } returns getDummyMovieDetailData(5)

    // 再初始化ViewModel
    viewModel = MovieDetailsViewModel(movieRepository, savedStateHandle, testDispatcher)
    
    // 等待所有协程任务执行完成
    advanceUntilIdle()
    
    Assert.assertEquals(getDummyMovieDetailData(5), viewModel.movieDetailsState.value)
}

同时删除@Before方法中的ViewModel初始化代码,避免提前触发init块。

2. 统一Mock库,消除依赖冲突

项目同时引入Mockito和Mockk会导致Mock行为异常,选择其中一种库使用。例如保留Mockk:

  • 移除Mockito相关依赖:
    // 移除以下依赖
    testImplementation 'org.mockito.kotlin:mockito-kotlin:4.1.0'
    testImplementation 'org.mockito:mockito-core:3.12.4'
    testImplementation 'org.mockito:mockito-inline:2.13.0'
    
  • 用Mockk方式创建Mock:
    private val movieRepository = mockk<MovieRepository>()
    private val savedStateHandle = mockk<SavedStateHandle>()
    

3. 确保协程执行完成

使用runBlockingTest时,调用advanceUntilIdle()等待所有挂起的协程任务执行完毕,否则断言会在数据更新前触发,拿到初始的空对象。

4. 检查数据类的equals实现

确保MovieDetailsResponse及其嵌套类(如Genres、ProductionCompanies)都是data class,Kotlin会自动生成正确的equals方法,否则断言会因对象引用不同而失败。

5. 确认状态变量初始化正确性

确保movieDetailsState在类初始化时就完成赋值,避免使用lateinit var导致空指针问题。当前ViewModel中的初始化方式是正确的:

val movieDetailsState: MutableState<MovieDetailsResponse> = mutableStateOf(MovieDetailsResponse())

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.13 20:35:53