Kotlin+Compose Android应用中FeedScreenViewModel的测试实现求助
Kotlin+Compose Android应用中FeedScreenViewModel的测试实现求助
我正在开发一个用Kotlin和Compose构建的Android应用,目前用Espresso(JUnit4)做UI测试,Activity的测试已经顺利完成,但卡在了ViewModel的测试上。我把FeedScreenViewModel、对应的FeedScreen代码,还有自动生成的测试类框架都贴出来了,麻烦帮我把这些测试方法的具体实现补全一下!
FeedScreenViewModel 代码
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn import com.amplitude.android.Amplitude import com.kotlang.auth.login.UserProfileProto import com.navachar.neptune.data.respository.FeedRepository import com.navachar.neptune.data.db.entities.Post import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class FeedScreenViewModel @Inject constructor( // TODO: Make this private val amplitude: Amplitude, private val feedRepository: FeedRepository, ): ViewModel() { companion object { private const val TAG = "FeedScreenViewModel" } val profile = feedRepository.getProfile().stateIn( scope = viewModelScope, started = SharingStarted.Eagerly, initialValue = UserProfileProto.getDefaultInstance() ) val feedPagingFlow = feedRepository.feedPagingFlow() .cachedIn(viewModelScope) /** * Adds or removes like from the given post * * @param post the post to update like for * @param isLiked the updated like state */ fun togglePostLike(post: Post, isLiked: Boolean) { viewModelScope.launch { feedRepository.setPostLike(post, isLiked) } } }
FeedScreen 代码
import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.items import com.amplitude.android.Amplitude import com.kotlang.auth.login.UserProfileProto import com.navachar.neptune.R import com.navachar.neptune.presentation.screens.main.feed.actionscreens.post.PostActivity import com.navachar.neptune.presentation.screens.main.feed.components.ErrorMessage import com.navachar.neptune.presentation.screens.main.feed.components.PagingFooter import com.navachar.neptune.presentation.screens.main.feed.components.Post import com.navachar.neptune.presentation.screens.main.profile.ProfileActivity import com.navachar.neptune.data.db.entities.Post import com.navachar.neptune.data.db.entities.User import com.navachar.neptune.data.db.entities.PostFull const val TAG8="feed" @Composable fun FeedScreen( modifier: Modifier = Modifier, viewModel: FeedScreenViewModel = hiltViewModel() ) { val context = LocalContext.current val profile by viewModel.profile.collectAsState() val pagingItems = viewModel.feedPagingFlow.collectAsLazyPagingItems() FeedScreen( modifier = modifier, pagingItems = pagingItems, openCreatePostScreen = { context.startActivity(Intent(context, PostActivity::class.java)) }, toggleLike = viewModel::togglePostLike, openPostDetails = { post -> Intent( context, CommentActivity::class.java, ).apply { putExtra("activity", "MainActivity") putExtra("postId", post.id) context.startActivity(this) } }, sharePost = { shareContent( it, profile, viewModel.amplitude, context, ) }, openProfile = { user -> context.startActivity( Intent( context, ProfileActivity::class.java ).apply { putExtra("UserId", user.id) } ) }, onRequestOpenUrl = { url -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } ) } @Composable private fun FeedScreen( modifier: Modifier, pagingItems: LazyPagingItems<PostFull>, openCreatePostScreen: () -> Unit, toggleLike: (post: Post, isLiked: Boolean) -> Unit, openPostDetails: (post: Post) -> Unit, sharePost: (post: PostFull) -> Unit, openProfile: (user: User) -> Unit, onRequestOpenUrl: (url: String) -> Unit, ) { Scaffold( modifier = modifier, content = { Feed( modifier = Modifier .fillMaxSize() .padding(it), pagingItems = pagingItems, toggleLike = toggleLike, openPostDetails = openPostDetails, sharePost = sharePost, openProfile = openProfile, onRequestOpenUrl = onRequestOpenUrl, ) }, bottomBar = { BottomBar(onClick = openCreatePostScreen) } ) } @Composable private fun Feed( modifier: Modifier, pagingItems: LazyPagingItems<PostFull>, toggleLike: (post: Post, isLiked: Boolean) -> Unit, openPostDetails: (post: Post) -> Unit, sharePost: (post: PostFull) -> Unit, openProfile: (user: User) -> Unit, onRequestOpenUrl: (url: String) -> Unit, ) { when (val refreshState = pagingItems.loadState.refresh) { is LoadState.NotLoading -> { FeedLazyList( modifier = modifier.testTag(TAG8), pagingItems = pagingItems, openPostDetails = openPostDetails, openProfile = openProfile, sharePost = sharePost, toggleLike = toggleLike, onRequestOpenUrl = onRequestOpenUrl, ) } is LoadState.Loading -> { Box( modifier = modifier, contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } is LoadState.Error -> { ErrorMessage( modifier = modifier, error = refreshState.error, onRetry = pagingItems::retry, ) } } } @Composable private fun FeedLazyList( modifier: Modifier, pagingItems: LazyPagingItems<PostFull>, openPostDetails: (post: Post) -> Unit, openProfile: (user: User) -> Unit, sharePost: (post: PostFull) -> Unit, toggleLike: (post: Post, isLiked: Boolean) -> Unit, onRequestOpenUrl: (url: String) -> Unit, ) { LazyColumn( modifier = modifier ) { items(pagingItems) { post -> if (post != null) { Post( post = post, openPostDetails = { openPostDetails(post.post) }, openProfile = { if (post.author != null) { openProfile(post.author) } }, onSharePost = { sharePost(post) }, onToggleLike = { toggleLike(post.post, it) }, onRequestOpenUrl = onRequestOpenUrl, ) } else { // TODO: Show a shimmer placeholder for post } } item { PagingFooter( modifier = Modifier .fillMaxWidth() .padding(16.dp), pagingItems = pagingItems ) } } } @Composable private fun BottomBar( onClick: () -> Unit ) { Row( Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { CompositionLocalProvider( LocalContentAlpha provides ContentAlpha.medium ) { Image( modifier = Modifier .size(24.dp) .clip(CircleShape), painter = painterResource(id = R.drawable.ic_account), contentDescription = "", contentScale = ContentScale.FillBounds ) Spacer(modifier = Modifier.width(8.dp)) Text( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.post_something), style = MaterialTheme.typography.body2, textAlign = TextAlign.Start, ) } } } private fun shareContent( post: PostFull, profile: UserProfileProto, amplitude: Amplitude, context: Context ) { // TODO: Separate the amplitude logic from UI val eventName = "CTA_Post_Share" val eventTime = System.currentTimeMillis() val userId = profile.loginId val loadTime = "" val postTag = post.post.tags.joinToString() val postContentType = if (post.media.isEmpty()) "Text" else "Media" val postId = post.post.id amplitude.track( eventType = eventName, eventProperties = mapOf( Pair("Event_Time", eventTime), Pair("Screen_Load_Time", loadTime), Pair("User_ID", userId), Pair("Post_Tag", postTag), Pair("Post_Content_Type", postContentType), Pair("Post_ID", postId) ) ) // TODO: Localise the share content val shareContent = "${post.author?.name} Posted at Urvar:\n\n" + "${post.post.body.take(200)}...\n\n" + "Read More: http://www.urvar.com/post/${post.post.id}" val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND putExtra( Intent.EXTRA_TEXT, shareContent ) type = "text/plain" } val shareIntent = Intent.createChooser(sendIntent, null) context.startActivity(shareIntent) }
自动生成的测试类框架
class FeedScreenViewModelTest { @Before fun setUp() { } @After fun tearDown() { } @Test fun getProfile() { } @Test fun getFeedPagingFlow() { } @Test fun togglePostLike() { } @Test fun getAmplitude() { } }
FeedScreenViewModel 测试实现方案
我们可以用MockK模拟依赖,结合协程测试工具来完成ViewModel的测试,先确保你的测试依赖里添加了mockk、core-testing这些库。下面是完整的测试实现:
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.paging.PagingData import com.amplitude.android.Amplitude import com.kotlang.auth.login.UserProfileProto import com.navachar.neptune.data.respository.FeedRepository import com.navachar.neptune.data.db.entities.Post import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test class FeedScreenViewModelTest { // 让LiveData在测试中同步执行的规则 @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() // 模拟依赖实例 private val mockFeedRepository = mockk<FeedRepository>(relaxed = true) private val mockAmplitude = mockk<Amplitude>(relaxed = true) private lateinit var viewModel: FeedScreenViewModel @Before fun setUp() { // 替换主线程为测试调度器,处理协程逻辑 Dispatchers.setMain(UnconfinedTestDispatcher()) // 初始化ViewModel,传入模拟的依赖 viewModel = FeedScreenViewModel(mockAmplitude, mockFeedRepository) } @After fun tearDown() { // 重置主线程调度器 Dispatchers.resetMain() } @Test fun `getProfile should return correct profile from repository`() = runTest { // 准备测试数据 val testProfile = UserProfileProto.newBuilder() .setLoginId("test_user_001") .setName("Test User") .build() // 模拟Repository返回测试数据 every { mockFeedRepository.getProfile() } returns flowOf(testProfile) // 收集ViewModel的profile流值 val collectedProfile = viewModel.profile.value // 断言数据一致 assert(collectedProfile == testProfile) // 验证Repository方法被调用 every { mockFeedRepository.getProfile() } wasCalled 1 } @Test fun `getFeedPagingFlow should return cached paging data from repository`() = runTest { // 测试用的分页数据 val testPagingData = PagingData.from(listOf<Post>()) // 模拟Repository的分页流 every { mockFeedRepository.feedPagingFlow() } returns flowOf(testPagingData) // 收集分页流数据 val collectedData = viewModel.feedPagingFlow.first() // 断言数据一致 assert(collectedData == testPagingData) // 验证Repository方法被调用 every { mockFeedRepository.feedPagingFlow() } wasCalled 1 } @Test fun `togglePostLike should call repository's setPostLike with correct params`() = runTest { // 测试用的Post和点赞状态 val testPost = Post(id = "post_001", body = "Test Post", likesCount = 0) val testIsLiked = true // 调用ViewModel的方法 viewModel.togglePostLike(testPost, testIsLiked) // 验证Repository的挂起方法被正确调用 coVerify(exactly = 1) { mockFeedRepository.setPostLike(testPost, testIsLiked) } } @Test fun `getAmplitude should return injected instance`() { // 断言ViewModel的amplitude属性和注入的模拟实例一致 assert(viewModel.amplitude == mockAmplitude) } }
测试方法说明
- getProfile:验证ViewModel的
profile流是否正确代理Repository的数据,初始值和返回值符合预期。 - getFeedPagingFlow:验证分页流是否正确从Repository获取数据,
cachedIn属于Jetpack Paging的内部逻辑,无需额外测试。 - togglePostLike:验证点赞切换逻辑是否正确调用Repository的方法,参数传递无误。
- getAmplitude:验证依赖注入是否正常(建议你把
amplitude改成private,这个测试就可以移除了)。
备注:内容来源于stack exchange,提问作者Krishna Agrawal
相关产品推荐
相关产品推荐

