Now in Android `:core:data-test` 模块剖析:数据层测试替身与 Hilt 测试安装指南
本篇技术指南聚焦 Now in Android(NIA)示例应用中 :core:data-test 模块的完整设计与实现。该模块是数据层(:core:data)的专用测试基础设施:它通过 Hilt 的 @TestInstallIn 机制将生产环境的仓库、网络监控与时区监控实现整体替换为内存版 Fake 与固定桩(stub),从而让 App 可以在无网络、无后端的环境下用本地 JSON 假数据跑通全部功能与截图测试。读完本文,你将掌握:NIA 数据层测试模块的构建配置与依赖关系、5 个 Fake 仓库与 2 个监控器替身的实现细节、@TestInstallIn 模块替换的完整装配流程,以及如何在 testDemo / androidTest 中落地这套方案。
模块定位:为数据层准备的"测试专用变体"
core/data-test/README.md 是模块的唯一定义文档,它通过模块依赖图明确了 :core:data-test 在 NIA 模块化架构中的位置。根据文档中的依赖图(graph TB 中 :core:data-test --> :core:data 的实线箭头),该模块单向依赖 :core:data,两者共同隶属于 :core 分组。文档中 :core:data 的依赖关系还揭示了数据层本体的构成:
- 实线(强依赖):
:core:common、:core:database、:core:datastore、:core:network - 虚线(弱依赖,以
.->表示)::core:analytics、:core:notifications
这正是 NIA 中"离线优先(offline-first)"数据层的模块划分:网络层提供远端数据,数据库提供本地缓存,DataStore 保存用户偏好,Common 提供跨模块的调度器与工具,Analytics/Notifications 作为可选旁路依赖存在。
依赖图还提供了模块类型的图例(legend):
android-application、android-feature、android-library、jvm-library、android-test等五类着色规范,可用于理解整个 NIA 仓库所有模块 README 中内嵌的同类 Mermaid 依赖图。
构建配置:一个"测试产物"如何被打包
:core:data-test 的构建配置位于 core/data-test/build.gradle.kts,整体极为精简:
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.hilt)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.data.test"
}
dependencies {
api(projects.core.data)
implementation(libs.hilt.android.testing)
}
三个关键设计值得注意:
api(projects.core.data):以api而非implementation暴露数据层,意味着凡是依赖:core:data-test的模块(如app的测试源码集),会同时传递性地获得:core:data的全部 API 可见性。Fake 类需要实现NewsRepository、TopicsRepository等接口,因此必须能编译期访问这些接口。libs.hilt.android.testing:这是 Hilt 的测试支持库,提供了@TestInstallIn、CustomTestApplication等测试专用注解与运行时支持——它是下面TestDataModule能"替换"生产模块的前提。namespace独立:模块命名空间为com.google.samples.apps.nowinandroid.core.data.test,与 core/data-test/src/main/AndroidManifest.xml 中的空<manifest />声明一致——本模块不包含任何四大组件,只是纯 Kotlin 测试代码的载体。
使用方:谁在消费这个模块
在 app/build.gradle.kts 中可以找到两个消费点:
testImplementation(projects.core.dataTest) // 本地单元测试(testDemo)
androidTestImplementation(projects.core.dataTest) // 仪器化测试(androidTest)
结合 settings.gradle.kts 中的 include(":core:data-test"),可以确认该模块同时服务于 app 的 demo 变体单元测试与 androidTest 仪器化测试两类场景。这一"主源码集(main)中放测试替身代码"的组织方式,与 NIA 中 core/datastore-test、sync/sync-test 等姊妹模块保持了一致的模式。
Fake 仓库:5 个数据层接口的测试实现
core/data-test/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/test/repository/ 目录下提供了 5 个 Fake 实现,分别对应 core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository 中的 5 个仓库接口:
| Fake 类 | 实现的接口 | 数据来源 | 典型行为 |
|---|---|---|---|
FakeNewsRepository |
NewsRepository |
DemoNiaNetworkDataSource(news.json + topics.json) |
按 NewsResourceQuery 过滤新闻,syncWith 直接返回 true |
FakeTopicsRepository |
TopicsRepository |
DemoNiaNetworkDataSource(topics.json) |
映射为外部 Topic 模型,syncWith 直接返回 true |
FakeUserDataRepository |
UserDataRepository |
NiaPreferencesDataSource(DataStore) |
透传全部读写到真实 DataStore |
FakeRecentSearchRepository |
RecentSearchRepository |
无(硬编码空值) | 查询返回空列表,写操作空实现 |
FakeSearchContentsRepository |
SearchContentsRepository |
无(硬编码空值) | searchContents 返回空流,getSearchContentsCount 返回 1 |
FakeNewsRepository:带查询过滤的内存版新闻仓库
FakeNewsRepository.kt 是最有代表性的实现。它通过 DemoNiaNetworkDataSource 读取打包在 core:network 模块 assets 中的 news.json 与 topics.json,在内存中完成 NewsResourceQuery 的过滤逻辑:
class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: DemoNiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> =
flow {
val newsResources = datasource.getNewsResources()
val topics = datasource.getTopics()
emit(
newsResources
.filter { networkNewsResource ->
// 无 filterNewsIds / filterTopicIds 时直接返回
listOfNotNull(
true,
query.filterNewsIds?.contains(networkNewsResource.id),
query.filterTopicIds?.let { filterTopicIds ->
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
},
).all(true::equals)
}
.map { it.asExternalModel(topics) },
)
}.flowOn(ioDispatcher)
override suspend fun syncWith(synchronizer: Synchronizer) = true
}
实现细节与生产版本(OfflineFirstNewsRepository)形成鲜明对照:
- 过滤语义:
listOfNotNull(true, ...)保证当查询未指定任何过滤条件时所有条目都被保留;当同时指定filterNewsIds与filterTopicIds时,采用 AND 语义(all(true::equals))。 - 外部模型映射:
asExternalModel(topics)将网络层模型(NetworkNewsResource)与主题信息合并为 UI 层消费的NewsResource领域模型。 syncWith恒为true:Fake 无需真正的同步逻辑,直接报告"同步成功",从而把"同步"从测试路径中彻底摘除。- 调度器保持:仍使用
@Dispatcher(IO)与flowOn(ioDispatcher),保留真实实现中的 IO 调度语义,避免测试出现并发层面的假象。
FakeUserDataRepository:唯一"半真半假"的仓库
FakeUserDataRepository.kt 与其它 4 个 Fake 不同——它没有抛弃底层存储,而是注入真实的 NiaPreferencesDataSource(DataStore),并将全部读写操作透传过去:
class FakeUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository {
override val userData: Flow<UserData> =
niaPreferencesDataSource.userData
override suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds)
// setTopicIdFollowed / setNewsResourceBookmarked / setNewsResourceViewed
// setThemeBrand / setDarkThemeConfig / setDynamicColorPreference / setShouldHideOnboarding
// 均为一对一透传
}
其 doc 注释说明这正是设计意图:"Fake implementation of the UserDataRepository that returns hardcoded user data. This allows us to run the app with fake data, without needing an internet connection or working backend." 也就是说,用户偏好(关注主题、收藏、已读、主题品牌、深色模式、动态取色、是否隐藏引导)需要真实的持久化来支撑跨界面交互,而"假"的部分只在于不依赖远端后端。这与 FakeNewsRepository/FakeTopicsRepository 的"纯 JSON 假数据"策略互补,构成了 NIA 测试数据的完整闭环。
其余三个极简 Fake
- FakeRecentSearchRepository.kt:
insertOrReplaceRecentSearch/clearRecentSearches为空操作,getRecentSearchQueries返回flowOf(emptyList())。 - FakeSearchContentsRepository.kt:
searchContents返回空流,getSearchContentsCount返回flowOf(1)(用于满足界面计数展示的最小值),populateFtsData为空操作。这两个 Fake 从代码结构看,服务于搜索页在 demo 变体下的占位呈现。 - FakeTopicsRepository.kt:从 JSON 读取主题后手工映射
Topic,getTopic(id)基于getTopics()做first { it.id == id }查找,syncWith同样恒为true。
监控器替身:固定的网络与时区状态
除了仓库,数据层还暴露两个"环境状态"接口——NetworkMonitor(网络在线状态)与 TimeZoneMonitor(当前时区)。生产实现分别基于 ConnectivityManager 广播与系统时区广播(见 DataModule.kt),在测试中则需要完全确定性的替身:
// AlwaysOnlineNetworkMonitor.kt
class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {
override val isOnline: Flow<Boolean> = flowOf(true)
}
// DefaultZoneIdTimeZoneMonitor.kt
class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor {
override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of("Europe/Warsaw"))
}
两个替身的核心价值是确定性:
AlwaysOnlineNetworkMonitor让isOnline恒为true。这使依赖网络状态决定的数据刷新路径(例如"在线时后台同步")在测试中始终走"在线"分支,规避了模拟器/CI 环境中网络状态抖动带来的 flaky 测试。DefaultZoneIdTimeZoneMonitor将时区固定为Europe/Warsaw,保证新闻卡片的"相对时间"文案(如"x 分钟前")在快照测试中完全可复现——这正是 NIA 截图测试(screenshot testing)能稳定对比像素的前提。
核心装配:@TestInstallIn 模块替换的完整流程
连接 5 个 Fake 仓库与 2 个监控器替身的关键,是 TestDataModule.kt 中的 Hilt 测试模块:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DataModule::class],
)
internal interface TestDataModule {
@Binds
fun bindsTopicRepository(fakeTopicsRepository: FakeTopicsRepository): TopicsRepository
@Binds
fun bindsNewsResourceRepository(fakeNewsRepository: FakeNewsRepository): NewsRepository
@Binds
fun bindsUserDataRepository(userDataRepository: FakeUserDataRepository): UserDataRepository
@Binds
fun bindsRecentSearchRepository(recentSearchRepository: FakeRecentSearchRepository): RecentSearchRepository
@Binds
fun bindsSearchContentsRepository(searchContentsRepository: FakeSearchContentsRepository): SearchContentsRepository
@Binds
fun bindsNetworkMonitor(networkMonitor: AlwaysOnlineNetworkMonitor): NetworkMonitor
@Binds
fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor
}
替换语义逐条对照
将它与生产模块 DataModule.kt 并排阅读,可以清楚看到一一对应的替换关系:
| 接口 | 生产绑定(DataModule) | 测试绑定(TestDataModule) |
|---|---|---|
TopicsRepository |
OfflineFirstTopicsRepository |
FakeTopicsRepository |
NewsRepository |
OfflineFirstNewsRepository |
FakeNewsRepository |
UserDataRepository |
OfflineFirstUserDataRepository |
FakeUserDataRepository |
RecentSearchRepository |
DefaultRecentSearchRepository |
FakeRecentSearchRepository |
SearchContentsRepository |
DefaultSearchContentsRepository |
FakeSearchContentsRepository |
NetworkMonitor |
ConnectivityManagerNetworkMonitor |
AlwaysOnlineNetworkMonitor |
TimeZoneMonitor |
TimeZoneBroadcastMonitor |
DefaultZoneIdTimeZoneMonitor |
@TestInstallIn(replaces = <a href="https://link.gitcode.com/i/bcf970f9d73d623f6e6335138fe641bc" target="_blank">DataModule::class]) 的语义是:在测试构建中,用本模块的绑定整体覆盖 DataModule 的同名绑定,其余未覆盖的绑定(如 database、datastore 提供的 DAO 与 DataStore 绑定)继续由生产模块提供。由于 TestDataModule 与 DataModule 都是 @Binds 抽象模块,实际实例化发生在 @Inject constructor 标注的 Fake 类上——这要求每个 Fake 的构造参数都能被 Hilt 解析,例如 FakeNewsRepository 依赖的 DemoNiaNetworkDataSource 由 :core:network 的 demo 风味模块([FlavoredNetworkModule.kt 中 binds(impl: DemoNiaNetworkDataSource): NiaNetworkDataSource)提供。
数据流全景
从源码可以梳理出完整的数据流:测试 App(demo 变体)→ 界面 ViewModel → Fake 仓库 → DemoNiaNetworkDataSource → assets 中的 news.json / topics.json。DemoNiaNetworkDataSource(见 core/network/src/main/kotlin/com/google/samples/apps/nowinandroid/core/network/demo/DemoNiaNetworkDataSource.kt)使用 kotlinx.serialization 从 JSON 流式解码(API 23 及以下走 decodeFromString 兼容路径),数据文件位于 core/network/src/main/assets(news.json、topics.json)。这套链路让测试完全脱离真实后端。
工程价值与适用场景总结
结合 app/build.gradle.kts 中 testDemo 与 androidTest 对 projects.core.dataTest 的引入,:core:data-test 的实际价值可归纳为:
- 离线可跑:App 的 demo 变体与截图测试(例如 app/src/testDemo 下的
SnackbarScreenshotTests、SnackbarInsetsScreenshotTests)无需网络与后端即可运行,彻底移除环境不确定性。 - 行为确定:固定的在线状态与时区使"时间敏感、网络敏感"的 UI 快照可像素级复现,是 NIA 截图测试稳定性的基石。
- 接口兼容:Fake 严格实现数据层公共接口,测试代码与生产代码共享同一套类型系统,切换成本为零。
- 模式可复用:
@TestInstallIn+ Fake 仓库 + 固定监控器的三件套,是 Android 多模块项目中"为数据层构建测试替身"的推荐范式,与 core/datastore-test、core/testing 等模块共同构成 NIA 完整的测试基础设施矩阵。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.23 K637- DDeepSeek-V4.1-FlashDeepSeek-V4.1-Flash 是一个多模态混合专家(MoE)模型,拥有 5520 亿骨干参数,并支持最多一百万 token 的上下文长度。该模型原生支持图像和文本输入,并以自回归方式生成文本Python560
cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端TypeScript2.02 K146
hello-agents📚 《从零开始构建智能体》——从零开始的智能体原理与实践教程Python51372
new-apiAI模型聚合管理中转分发系统,一个应用管理您的所有AI模型,支持将多种大模型转为统一格式调用,支持OpenAI、Claude、Gemini等格式,可供个人或者企业内部管理与分发渠道使用。🍥 A Unified AI Model Management & Distribution System. Aggregate all your LLMs into one app and access them via an OpenAI-compatible API, with native support for Claude (Messages) and Gemini formats.Go22245
JeecgBoot🔥企业级低代码平台集成了AI应用平台,帮助企业快速实现低代码开发和构建AI应用!前后端分离架构 SpringBoot,SpringCloud、Mybatis,Ant Design4、 Vue3.0、TS+vite!强大的代码生成器让前后端代码一键生成,无需写任何代码! 引领AI低代码开发模式: AI生成->OnlineCoding-> 代码生成-> 手工MERGE,显著的提高效率,又不失灵活~Java35751