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

Android单元测试中如何触发回调?MVVM场景UI状态验证

问题:MVVM架构下ViewModel回调触发的单元测试验证

我在MVVM架构的应用中,需要测试HomeViewModel里的逻辑:调用addListener()后,当storageRepository.addListener()执行完成时,uiState.isLoading会变为false。

我的HomeViewModel.kt核心代码:

@HiltViewModel
class HomeViewModel
@Inject
constructor(
    private val storageRepository: StorageRepository,
    private val accountRepository: AccountRepository,
) : ViewModel() {
    // 初始isLoading为true
    var uiState = mutableStateOf(HomeUiState())

    ...

    fun addListener() {
        viewModelScope.launch {
            storageRepository.addListener(
                accountRepository.getUserId(),
                ::onDocumentEvent,
                onComplete = { 
                    uiState.value = uiState.value.copy(isLoading = false) 
                },
                onError = { 
                    error -> onAddListenerFailure(error) 
                }
            )
        }
    }
}

测试需求:

  • 给定:调用homeViewModel.addListener()
  • 当:storageRepository.addListener(...)执行完成
  • 那么:uiState.isLoading为false

我尝试用Mockito捕获器写了测试,但没达到预期,当前测试代码:

@OptIn(ExperimentalCoroutinesApi::class)
internal class HomeViewModelTest {
    // mock repositories
    @Mock lateinit var storageRepository: StorageRepository
    @Mock lateinit var accountRepository: AccountRepository
    @Mock lateinit var logRepository: LogRepository

    // 测试调度器
    private val dispatcher = StandardTestDispatcher()

    lateinit var callbackCaptor: KArgumentCaptor<() -> Unit>

    @Before
    fun setUp() {
        MockitoAnnotations.openMocks(this)
        Dispatchers.setMain(dispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `loading state is true when viewModel is created`() {
        val homeViewModel = HomeViewModel(storageRepository, accountRepository, logRepository)
        assertTrue(homeViewModel.uiState.value.isLoading)
    }

    @Test
    fun `loading state is false when listener is added successfully`() {
        val homeViewModel = HomeViewModel(storageRepository, accountRepository, logRepository)
        callbackCaptor = argumentCaptor()

        whenever(
            storageRepository.addListener(
                anyString(),
                anyOrNull(),
                callbackCaptor.capture(),
                anyOrNull()
            )
        )
        .thenAnswer { callbackCaptor.firstValue.invoke() }

        homeViewModel.addListener()

        // 等待状态更新
        dispatcher.scheduler.advanceUntilIdle()
        assertFalse(homeViewModel.uiState.value.isLoading)
    }
}

方案一:修正Mockito捕获器的使用方式

当前测试代码存在两个关键问题导致验证失败:

  1. 未mockaccountRepository.getUserId()的返回值,导致传入storageRepository.addListener()的userId为null,无法匹配mock的方法调用
  2. 捕获器的声明和调用逻辑可以优化,提升代码可读性和匹配准确性

修正后的测试代码:

@OptIn(ExperimentalCoroutinesApi::class)
internal class HomeViewModelTest {
    @Mock lateinit var storageRepository: StorageRepository
    @Mock lateinit var accountRepository: AccountRepository
    @Mock lateinit var logRepository: LogRepository

    private val dispatcher = StandardTestDispatcher()

    @Before
    fun setUp() {
        MockitoAnnotations.openMocks(this)
        Dispatchers.setMain(dispatcher)
        // 预先mock getUserId的返回值,确保参数匹配
        whenever(accountRepository.getUserId()).thenReturn("test-user-id")
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `loading state turns false after listener is added successfully`() {
        val homeViewModel = HomeViewModel(storageRepository, accountRepository, logRepository)
        // 声明onComplete回调的捕获器
        val onCompleteCaptor = argumentCaptor<() -> Unit>()

        // 精确匹配方法参数,捕获onComplete回调
        whenever(
            storageRepository.addListener(
                eq("test-user-id"),
                any(),
                onCompleteCaptor.capture(),
                any()
            )
        ).thenAnswer {
            // 触发捕获到的完成回调
            onCompleteCaptor.firstValue.invoke()
        }

        homeViewModel.addListener()
        // 推进协程调度器,直到所有异步任务执行完成
        dispatcher.scheduler.advanceUntilIdle()

        assertFalse(homeViewModel.uiState.value.isLoading)
    }
}

方案二:不使用捕获器,直接触发回调

如果不想依赖Mockito捕获器,可以直接在mock的thenAnswer中获取传入的回调参数并执行,逻辑更直接:

@OptIn(ExperimentalCoroutinesApi::class)
internal class HomeViewModelTest {
    @Mock lateinit var storageRepository: StorageRepository
    @Mock lateinit var accountRepository: AccountRepository
    @Mock lateinit var logRepository: LogRepository

    private val dispatcher = StandardTestDispatcher()

    @Before
    fun setUp() {
        MockitoAnnotations.openMocks(this)
        Dispatchers.setMain(dispatcher)
        whenever(accountRepository.getUserId()).thenReturn("test-user-id")
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `loading state turns false after listener is added successfully`() {
        val homeViewModel = HomeViewModel(storageRepository, accountRepository, logRepository)

        whenever(
            storageRepository.addListener(
                eq("test-user-id"),
                any(),
                any(),
                any()
            )
        ).thenAnswer { invocation ->
            // 直接获取第三个参数(onComplete回调)并执行
            val onComplete = invocation.getArgument<() -> Unit>(2)
            onComplete.invoke()
        }

        homeViewModel.addListener()
        dispatcher.scheduler.advanceUntilIdle()

        assertFalse(homeViewModel.uiState.value.isLoading)
    }
}

这种方式跳过捕获器,直接通过方法调用的参数索引获取回调,适合参数位置固定的场景,代码更简洁。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.11 16:05:27