nowinandroid单元测试:ViewModel与Repository测试
痛点:Android应用测试的复杂性
你是否曾经在开发Android应用时,面对复杂的ViewModel和Repository层测试感到头疼?数据流管理、异步操作、状态维护等问题让测试变得异常复杂。nowinandroid项目通过精心设计的测试架构,为我们展示了如何高效测试MVVM架构中的核心组件。
读完本文,你将掌握:
- ViewModel状态管理和数据流测试的最佳实践
- Repository层数据同步和缓存测试策略
- 测试替身(Test Doubles)在Android测试中的应用
- 协程测试的正确姿势和常见陷阱
nowinandroid测试架构概览
nowinandroid采用分层测试架构,确保每个组件都能被独立测试:
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的测试实践,推荐以下测试架构演进路径:
总结与展望
nowinandroid项目的测试实践为我们提供了宝贵的参考:
- 分层测试策略:从ViewModel到Repository的完整测试覆盖
- 测试替身设计:专业的测试替身实现,支持复杂测试场景
- 协程测试最佳实践:正确的协程测试方法和工具使用
- 状态管理测试:全面的状态流测试覆盖
通过学习和应用这些测试模式,你可以显著提升Android应用的测试质量和开发效率。记住,好的测试不仅是代码正确性的保障,更是架构设计质量的体现。
下一步行动:
- 在你的项目中实践ViewModel测试模式
- 尝试实现类似的测试替身架构
- 逐步建立完整的测试覆盖体系
测试之路永无止境,但每一步改进都将为你的应用质量带来显著提升。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



