androidengineers.Book a session

Testing Architecture

Exercise: Testable DI Graph

exercise55 minHard

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

  • ArticleRepository backed by Room + Retrofit
  • ArticleViewModel using the repository
  • ArticleListScreen using 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

LayerTest typeTool
ViewModelJUnit unit testFake repository, runTest
RepositoryIntegration testIn-memory Room, MockWebServer
Screen/UIHilt + Compose UI test@TestInstallIn, fake module
No mocking frameworksPrefer fakesCompile-time safe; refactor-proof

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Testable DI Graph | Android System Design | Android Engineers