nowinandroid单元测试:ViewModel与Repository测试

nowinandroid单元测试:ViewModel与Repository测试

【免费下载链接】nowinandroid android/nowinandroid: 是一个用于 Android 开发的开源项目,提供基于 Web 技术的 Android 开发环境,可以用于开发跨平台的 Android 应用程序。 【免费下载链接】nowinandroid 项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroid

痛点:Android应用测试的复杂性

你是否曾经在开发Android应用时,面对复杂的ViewModel和Repository层测试感到头疼?数据流管理、异步操作、状态维护等问题让测试变得异常复杂。nowinandroid项目通过精心设计的测试架构,为我们展示了如何高效测试MVVM架构中的核心组件。

读完本文,你将掌握:

  • ViewModel状态管理和数据流测试的最佳实践
  • Repository层数据同步和缓存测试策略
  • 测试替身(Test Doubles)在Android测试中的应用
  • 协程测试的正确姿势和常见陷阱

nowinandroid测试架构概览

nowinandroid采用分层测试架构,确保每个组件都能被独立测试:

mermaid

ViewModel测试实战

测试环境搭建

nowinandroid使用专门的测试工具类来简化ViewModel测试:

@get:Rule
val dispatcherRule = MainDispatcherRule()

private val userDataRepository = TestUserDataRepository()
private val newsRepository = TestNewsRepository()
private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
    newsRepository = newsRepository,
    userDataRepository = userDataRepository,
)
private lateinit var viewModel: BookmarksViewModel

@Before
fun setup() {
    viewModel = BookmarksViewModel(
        userDataRepository = userDataRepository,
        userNewsResourceRepository = userNewsResourceRepository,
    )
}

状态流测试策略

ViewModel的核心是状态管理,nowinandroid采用以下测试模式:

@Test
fun stateIsInitiallyLoading() = runTest {
    assertEquals(Loading, viewModel.feedUiState.value)
}

@Test
fun oneBookmark_showsInFeed() = runTest {
    backgroundScope.launch(UnconfinedTestDispatcher()) { 
        viewModel.feedUiState.collect() 
    }

    newsRepository.sendNewsResources(newsResourcesTestData)
    userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)
    
    val item = viewModel.feedUiState.value
    assertIs<Success>(item)
    assertEquals(item.feed.size, 1)
}

用户交互测试

测试用户操作如何影响状态:

@Test
fun oneBookmark_whenRemoving_removesFromFeed() = runTest {
    backgroundScope.launch(UnconfinedTestDispatcher()) { 
        viewModel.feedUiState.collect() 
    }
    
    // 设置测试数据
    newsRepository.sendNewsResources(newsResourcesTestData)
    userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)
    
    // 执行用户操作
    viewModel.removeFromSavedResources(newsResourcesTestData[0].id)
    
    // 验证状态变化
    val item = viewModel.feedUiState.value
    assertIs<Success>(item)
    assertEquals(item.feed.size, 0)
    assertTrue(viewModel.shouldDisplayUndoBookmark)
}

Repository层测试深度解析

数据同步测试

Repository负责数据同步和缓存,测试需要覆盖网络和数据库交互:

@Test
fun offlineFirstNewsRepository_sync_pulls_from_network() = testScope.runTest {
    // 用户未完成引导
    niaPreferencesDataSource.setShouldHideOnboarding(false)
    subject.syncWith(synchronizer)

    val newsResourcesFromNetwork = network.getNewsResources()
        .map(NetworkNewsResource::asEntity)
        .map(NewsResourceEntity::asExternalModel)

    val newsResourcesFromDb = newsResourceDao.getNewsResources()
        .first()
        .map(PopulatedNewsResource::asExternalModel)

    assertEquals(
        newsResourcesFromNetwork.map(NewsResource::id).sorted(),
        newsResourcesFromDb.map(NewsResource::id).sorted(),
    )
}

增量同步测试

测试增量数据同步逻辑:

@Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = testScope.runTest {
    // 用户未完成引导
    niaPreferencesDataSource.setShouldHideOnboarding(false)

    // 设置新闻版本为7
    synchronizer.updateChangeListVersions {
        copy(newsResourceVersion = 7)
    }

    subject.syncWith(synchronizer)

    val changeList = network.changeListsAfter(
        CollectionType.NewsResources,
        version = 7,
    )
    val changeListIds = changeList
        .map(NetworkChangeList::id)
        .toSet()

    val newsResourcesFromNetwork = network.getNewsResources()
        .map(NetworkNewsResource::asEntity)
        .map(NewsResourceEntity::asExternalModel)
        .filter { it.id in changeListIds }

    val newsResourcesFromDb = newsResourceDao.getNewsResources()
        .first()
        .map(PopulatedNewsResource::asExternalModel)

    assertEquals(
        expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
        actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
    )
}

测试替身设计模式

nowinandroid使用专业的测试替身来模拟依赖:

TestNewsRepository实现

class TestNewsRepository : NewsRepository {
    private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =
        MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)

    override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
        newsResourcesFlow.map { newsResources ->
            var result = newsResources
            query.filterTopicIds?.let { filterTopicIds ->
                result = newsResources.filter {
                    it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty()
                }
            }
            result
        }

    // 测试专用API
    fun sendNewsResources(newsResources: List<NewsResource>) {
        newsResourcesFlow.tryEmit(newsResources)
    }
}

TestUserDataRepository实现

class TestUserDataRepository : UserDataRepository {
    private val _userData = MutableSharedFlow<UserData>(replay = 1, onBufferOverflow = DROP_OLDEST)

    override val userData: Flow<UserData> = _userData.filterNotNull()

    override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
        currentUserData.let { current ->
            val bookmarkedNews = if (bookmarked) {
                current.bookmarkedNewsResources + newsResourceId
            } else {
                current.bookmarkedNewsResources - newsResourceId
            }
            _userData.tryEmit(current.copy(bookmarkedNewsResources = bookmarkedNews))
        }
    }
}

协程测试最佳实践

测试调度器管理

@get:Rule
val dispatcherRule = MainDispatcherRule()

@Test
fun testCoroutineOperations() = runTest {
    // 使用UnconfinedTestDispatcher收集流
    backgroundScope.launch(UnconfinedTestDispatcher()) { 
        viewModel.feedUiState.collect() 
    }
    
    // 执行测试逻辑
    // ...
}

异步操作测试模式

@Test
fun feedUiState_resourceIsViewed_setResourcesViewed() = runTest {
    backgroundScope.launch(UnconfinedTestDispatcher()) { 
        viewModel.feedUiState.collect() 
    }

    // Given - 设置初始状态
    newsRepository.sendNewsResources(newsResourcesTestData)
    userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)
    
    val itemBeforeViewed = viewModel.feedUiState.value
    assertIs<Success>(itemBeforeViewed)
    assertFalse(itemBeforeViewed.feed.first().hasBeenViewed)

    // When - 执行操作
    viewModel.setNewsResourceViewed(newsResourcesTestData[0].id, true)

    // Then - 验证结果
    val item = viewModel.feedUiState.value
    assertIs<Success>(item)
    assertTrue(item.feed.first().hasBeenViewed)
}

测试覆盖策略对比

测试类型覆盖范围执行速度维护成本适用场景
ViewModel测试UI逻辑、状态管理业务逻辑验证
Repository测试数据同步、缓存数据层完整性
单元测试工具函数、工具类很快基础组件验证
集成测试多组件协作很高端到端流程

常见测试陷阱与解决方案

1. 状态流收集问题

问题:Flow在测试中无法正确收集数据 解决方案:使用UnconfinedTestDispatcher确保即时执行

backgroundScope.launch(UnconfinedTestDispatcher()) { 
    viewModel.feedUiState.collect() 
}

2. 异步操作时序问题

问题:测试在异步操作完成前断言 解决方案:使用runTest协程构建器

@Test
fun testAsyncOperation() = runTest {
    // 测试代码
}

3. 测试数据管理问题

问题:测试数据污染导致测试间相互影响 解决方案:在每个测试前重置测试替身状态

@Before
fun setup() {
    userDataRepository = TestUserDataRepository()
    newsRepository = TestNewsRepository()
    // 重新初始化ViewModel
}

测试架构演进建议

基于nowinandroid的测试实践,推荐以下测试架构演进路径:

mermaid

总结与展望

nowinandroid项目的测试实践为我们提供了宝贵的参考:

  1. 分层测试策略:从ViewModel到Repository的完整测试覆盖
  2. 测试替身设计:专业的测试替身实现,支持复杂测试场景
  3. 协程测试最佳实践:正确的协程测试方法和工具使用
  4. 状态管理测试:全面的状态流测试覆盖

通过学习和应用这些测试模式,你可以显著提升Android应用的测试质量和开发效率。记住,好的测试不仅是代码正确性的保障,更是架构设计质量的体现。

下一步行动

  • 在你的项目中实践ViewModel测试模式
  • 尝试实现类似的测试替身架构
  • 逐步建立完整的测试覆盖体系

测试之路永无止境,但每一步改进都将为你的应用质量带来显著提升。

【免费下载链接】nowinandroid android/nowinandroid: 是一个用于 Android 开发的开源项目,提供基于 Web 技术的 Android 开发环境,可以用于开发跨平台的 Android 应用程序。 【免费下载链接】nowinandroid 项目地址: https://gitcode.com/GitHub_Trending/no/nowinandroid

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值