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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.23 07:19:11