Build a fully testable Hilt dependency graph where every layer can be tested in isolation — ViewModel with fakes, Repository with an in-memory database, and UI tests with swapped module bindings.
Goal
ArticleRepositorybacked by Room + RetrofitArticleViewModelusing the repositoryArticleListScreenusing the ViewModel- Unit tests for ViewModel (no Android)
- Integration tests for Repository (in-memory Room)
- UI test for Screen (Hilt test with fake module)
Step 1: Production DI Graph
// ArticleApi.kt
interface ArticleApi {
@GET("articles")
suspend fun fetchArticles(@Query("page") page: Int): List<ArticleDto>
}
// ArticleRepository.kt
interface ArticleRepository {
suspend fun getArticles(page: Int): Result<List<Article>>
suspend fun getArticle(id: String): Article?
}
class ArticleRepositoryImpl(
private val api: ArticleApi,
private val dao: ArticleDao
) : ArticleRepository {
override suspend fun getArticles(page: Int): Result<List<Article>> = runCatching {
val remote = api.fetchArticles(page).map { it.toDomain() }
dao.insertAll(remote.map { it.toEntity() })
remote
}.recoverCatching {
// Network failed — serve from cache
dao.getAllArticles().map { it.toDomain() }.also {
if (it.isEmpty()) throw it // rethrow if no cache
}
}
override suspend fun getArticle(id: String) = dao.getArticle(id)?.toDomain()
}
// NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideArticleApi(retrofit: Retrofit): ArticleApi =
retrofit.create(ArticleApi::class.java)
}
// DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
@Provides
fun provideArticleDao(db: AppDatabase): ArticleDao = db.articleDao()
}
// RepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindArticleRepository(impl: ArticleRepositoryImpl): ArticleRepository
}
Step 2: ViewModel — Unit Test with Fake
class ArticleListViewModelTest {
private val fakeRepository = FakeArticleRepository()
private val viewModel = ArticleListViewModel(fakeRepository)
@Test
fun `initial state is loading`() {
assertTrue(viewModel.state.value.isLoading)
}
@Test
fun `loading articles populates state`() = runTest {
fakeRepository.seedArticles(testArticle1, testArticle2)
viewModel.loadArticles()
testDispatcher.scheduler.advanceUntilIdle()
val state = viewModel.state.value
assertFalse(state.isLoading)
assertEquals(2, state.articles.size)
}
@Test
fun `error state set on failure`() = runTest {
fakeRepository.setError(IOException("Network error"))
viewModel.loadArticles()
testDispatcher.scheduler.advanceUntilIdle()
assertNotNull(viewModel.state.value.error)
}
}
// FakeArticleRepository for ViewModel tests
class FakeArticleRepository : ArticleRepository {
private val articles = mutableListOf<Article>()
private var error: Exception? = null
fun seedArticles(vararg articles: Article) { this.articles.addAll(articles) }
fun setError(e: Exception) { error = e }
override suspend fun getArticles(page: Int): Result<List<Article>> {
return error?.let { Result.failure(it) } ?: Result.success(articles)
}
override suspend fun getArticle(id: String) = articles.find { it.id == id }
}
Step 3: Repository — Integration Test with Real Room
@RunWith(AndroidJUnit4::class)
class ArticleRepositoryIntegrationTest {
private lateinit var db: AppDatabase
private lateinit var repository: ArticleRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).build()
val mockServer = MockWebServer()
mockServer.enqueue(MockResponse().setBody("""[{"id":"a1","title":"Test"}]"""))
mockServer.start()
val api = Retrofit.Builder()
.baseUrl(mockServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ArticleApi::class.java)
repository = ArticleRepositoryImpl(api, db.articleDao())
}
@After
fun tearDown() = db.close()
@Test
fun `fetch caches to database`() = runTest {
val result = repository.getArticles(page = 1)
assertTrue(result.isSuccess)
assertEquals("Test", result.getOrNull()?.first()?.title)
// Verify cached
assertNotNull(db.articleDao().getArticle("a1"))
}
}
Step 4: UI Test with Hilt Test Module
// Replace production module with test double
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {
@Binds
abstract fun bindFakeRepository(impl: FakeArticleRepositoryImpl): ArticleRepository
}
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class ArticleListScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject lateinit var fakeRepository: FakeArticleRepositoryImpl
@Before
fun setUp() {
hiltRule.inject()
fakeRepository.seedArticles(
Article("a1", "Article One"),
Article("a2", "Article Two")
)
}
@Test
fun `articles displayed in list`() {
composeTestRule.onNodeWithText("Article One").assertIsDisplayed()
composeTestRule.onNodeWithText("Article Two").assertIsDisplayed()
}
}
Key Takeaways
| Layer | Test type | Tool |
|---|---|---|
| ViewModel | JUnit unit test | Fake repository, runTest |
| Repository | Integration test | In-memory Room, MockWebServer |
| Screen/UI | Hilt + Compose UI test | @TestInstallIn, fake module |
| No mocking frameworks | Prefer fakes | Compile-time safe; refactor-proof |