Your app works on your phone. Your QA tester says it works on their phone. But does it actually work? What happens when the API returns an empty list? What if the network call fails? What if the user is logged out? You can’t manually test every scenario every time you change code. Unit tests do it for you — automatically, in seconds, every time you build. They catch bugs before users do, give you confidence to refactor, and prove your code works. This guide covers unit testing in Android from zero to production — JUnit, MockK, Turbine, and the patterns that matter.


The Mental Model — What Is a Unit Test?

// A unit test verifies ONE small piece of code in ISOLATION
//
// Think of it like testing a SINGLE GEAR in a watch:
// You don't test the entire watch — you test each gear alone
// Does it turn the right direction? The right speed? Handle resistance?
//
// In Android terms:
// UNIT = a function, a class, a ViewModel, a UseCase, a Repository method
// TEST = call the function with known inputs → check the output
// ISOLATION = fake everything the unit depends on (API, database, etc.)
//
// Example:
// ArticleViewModel.loadArticles()
// → calls Repository.getArticles()
// → returns articles
// → sets uiState to Success
//
// Unit test: give ViewModel a FAKE Repository that returns known articles
// → call loadArticles()
// → verify uiState is Success with those articles
//
// You're testing the ViewModel's LOGIC, not the real network or database

Setup

// build.gradle.kts
dependencies {
    // JUnit 5 — the test framework
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
    // junit-jupiter is a LIBRARY — assertions, annotations, test runner

    // MockK — Kotlin-first mocking library
    testImplementation("io.mockk:mockk:1.13.10")
    // mockk is a LIBRARY — creates fake objects (mocks) for dependencies

    // Turbine — testing Flows
    testImplementation("app.cash.turbine:turbine:1.1.0")
    // turbine is a LIBRARY — makes testing Flow emissions easy

    // Coroutines test — TestDispatcher, runTest
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
    // coroutines-test provides: runTest, TestDispatcher, advanceUntilIdle

    // Truth or AssertJ (optional — more readable assertions)
    testImplementation("com.google.truth:truth:1.4.2")

    // Architecture Components testing
    testImplementation("androidx.arch.core:core-testing:2.2.0")
}

// In test runner config:
tasks.withType<Test> {
    useJUnitPlatform()   // Use JUnit 5 platform
}

Your First Test — Pure Functions

// Start simple — test a pure function with no dependencies

// Production code:
class ArticleMapper {
    fun toUiModel(entity: ArticleEntity): ArticleUiModel {
        return ArticleUiModel(
            id = entity.id,
            title = entity.title,
            summary = entity.content.take(100),
            formattedDate = formatDate(entity.publishedAt),
            isNew = System.currentTimeMillis() - entity.publishedAt < 86_400_000
        )
    }
}

// Test code (in test/ folder, NOT androidTest/):
class ArticleMapperTest {

    private val mapper = ArticleMapper()
    // Create the real object — no mocking needed for pure functions

    @Test
    // @Test is an ANNOTATION from JUnit — marks this as a test method
    fun `maps entity to ui model with correct title`() {
        // GIVEN — set up test data
        val entity = ArticleEntity(
            id = "1", title = "Kotlin Basics", content = "This is a long content...",
            publishedAt = System.currentTimeMillis()
        )

        // WHEN — call the function being tested
        val result = mapper.toUiModel(entity)

        // THEN — verify the result
        assertEquals("Kotlin Basics", result.title)
        // assertEquals is a FUNCTION from JUnit — checks expected == actual
    }

    @Test
    fun `summary is limited to 100 characters`() {
        val longContent = "A".repeat(500)
        val entity = ArticleEntity(id = "1", title = "T", content = longContent, publishedAt = 0)

        val result = mapper.toUiModel(entity)

        assertEquals(100, result.summary.length)
    }

    @Test
    fun `article published less than 24h ago is marked as new`() {
        val entity = ArticleEntity(
            id = "1", title = "T", content = "C",
            publishedAt = System.currentTimeMillis() - 3_600_000   // 1 hour ago
        )

        val result = mapper.toUiModel(entity)

        assertTrue(result.isNew)
        // assertTrue is a FUNCTION from JUnit — checks condition is true
    }
}

// Run with: ./gradlew test
// Or right-click the test class → Run in Android Studio
// Tests run on YOUR MACHINE (JVM), not on a device — fast!

Test Doubles — Fakes, Mocks, and Stubs

When testing a class that depends on others (Repository, API, Database), you need to replace those dependencies with test doubles:

// THREE types of test doubles:
//
// 1. FAKE — a working implementation with simplified behaviour
//    The BEST choice for most tests — predictable, readable, reusable
//
// 2. MOCK — a recorded object that verifies interactions (using MockK)
//    Good for: verifying a function WAS called with specific arguments
//
// 3. STUB — returns fixed values, doesn't verify interactions
//    Good for: providing data without caring about how it's accessed

// ═══ FAKE — the recommended approach ═════════════════════════════════

// Interface:
interface ArticleRepository {
    fun getArticlesFlow(): Flow<List<Article>>
    suspend fun refreshArticles()
    suspend fun getArticle(id: String): Article?
}

// Fake implementation for tests:
class FakeArticleRepository : ArticleRepository {
    // Controllable state — tests set these to control behaviour
    var articles = mutableListOf<Article>()
    var shouldThrow = false
    var refreshCount = 0   // track how many times refresh was called

    override fun getArticlesFlow(): Flow<List<Article>> {
        return flowOf(articles.toList())
    }

    override suspend fun refreshArticles() {
        if (shouldThrow) throw IOException("Fake network error")
        refreshCount++
    }

    override suspend fun getArticle(id: String): Article? {
        return articles.find { it.id == id }
    }
}

// Usage in test:
val fakeRepo = FakeArticleRepository()
fakeRepo.articles = mutableListOf(testArticle1, testArticle2)
val viewModel = ArticleViewModel(fakeRepo)
// ViewModel thinks it has a real repository — but it's the fake!

// WHY Fakes are best:
// ✅ Easy to understand — real code, just simplified
// ✅ Reusable across many tests
// ✅ No mocking framework needed
// ✅ Tests the BEHAVIOUR, not the implementation
// ═══ MOCK (using MockK) — for verifying interactions ═════════════════

// MockK creates a mock object that records all calls
val mockAnalytics = mockk<Analytics>(relaxed = true)
// mockk() is a FUNCTION from MockK — creates a mock object
// relaxed = true → returns default values for unspecified calls
//                  (0 for Int, "" for String, Unit for functions)

// Set up expected behaviour:
every { mockAnalytics.isEnabled() } returns true
// every { } is a FUNCTION from MockK — defines what the mock returns

// Run the code under test:
val viewModel = ArticleViewModel(fakeRepo, mockAnalytics)
viewModel.loadArticles()

// VERIFY the mock was called:
verify { mockAnalytics.logEvent("articles_loaded", any()) }
// verify { } is a FUNCTION from MockK — checks the mock was called
// any() matches any argument

// Verify call count:
verify(exactly = 1) { mockAnalytics.logEvent("articles_loaded", any()) }
verify(exactly = 0) { mockAnalytics.logEvent("error", any()) }

// WHEN to use Mocks:
// ✅ Verify side effects (analytics tracked, event sent, function called)
// ✅ Verify function was NOT called
// ❌ Don't mock data sources — use Fakes instead (more readable)
// ═══ WHEN TO USE WHICH ═══════════════════════════════════════════════
//
// Data source (Repository, API, DAO)  → FAKE (working simplified impl)
// Analytics, logging, crash reporting → MOCK (verify calls)
// Clock, random number generator      → FAKE (return known values)
// Complex third-party library         → MOCK (too hard to fake)
//
// Rule: PREFER Fakes, use Mocks for INTERACTIONS you need to VERIFY

Testing ViewModels

// ViewModels are the MOST IMPORTANT thing to test
// They contain all business logic for a screen

@HiltViewModel
class ArticleViewModel @Inject constructor(
    private val repository: ArticleRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    fun loadArticles() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val articles = repository.getArticles()
                _uiState.value = if (articles.isEmpty()) UiState.Empty
                                  else UiState.Success(articles)
            } catch (e: CancellationException) { throw e }
            catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }

    sealed interface UiState {
        data object Loading : UiState
        data object Empty : UiState
        data class Success(val articles: List<Article>) : UiState
        data class Error(val message: String) : UiState
    }
}

// TEST:
class ArticleViewModelTest {

    // Replace Dispatchers.Main with a test dispatcher
    @OptIn(ExperimentalCoroutinesApi::class)
    private val testDispatcher = UnconfinedTestDispatcher()
    // UnconfinedTestDispatcher is a CLASS from coroutines-test
    // Runs coroutines IMMEDIATELY (no delay) — predictable for tests

    private val fakeRepository = FakeArticleRepository()
    private lateinit var viewModel: ArticleViewModel

    @BeforeEach
    // @BeforeEach is an ANNOTATION from JUnit 5 — runs before each test
    fun setup() {
        Dispatchers.setMain(testDispatcher)
        // setMain() is a FUNCTION from coroutines-test
        // Replaces Dispatchers.Main with our test dispatcher
        // Without this: viewModelScope.launch fails (no Android Main thread in tests)
        viewModel = ArticleViewModel(fakeRepository)
    }

    @AfterEach
    // @AfterEach is an ANNOTATION from JUnit 5 — runs after each test
    fun tearDown() {
        Dispatchers.resetMain()
        // resetMain() — restore original dispatcher
    }

    @Test
    fun `loadArticles sets Success state with articles`() = runTest {
        // runTest is a TOP-LEVEL FUNCTION from coroutines-test
        // Runs a coroutine-based test — handles dispatchers and timing

        // GIVEN
        fakeRepository.articles = mutableListOf(
            Article(id = "1", title = "Kotlin Basics"),
            Article(id = "2", title = "Compose Guide")
        )

        // WHEN
        viewModel.loadArticles()

        // THEN
        val state = viewModel.uiState.value
        assertTrue(state is UiState.Success)
        assertEquals(2, (state as UiState.Success).articles.size)
    }

    @Test
    fun `loadArticles sets Empty state when no articles`() = runTest {
        fakeRepository.articles = mutableListOf()   // empty

        viewModel.loadArticles()

        assertTrue(viewModel.uiState.value is UiState.Empty)
    }

    @Test
    fun `loadArticles sets Error state on exception`() = runTest {
        fakeRepository.shouldThrow = true

        viewModel.loadArticles()

        val state = viewModel.uiState.value
        assertTrue(state is UiState.Error)
        assertEquals("Fake network error", (state as UiState.Error).message)
    }
}

Testing Flows with Turbine

// Turbine makes testing Flow emissions simple and readable

@Test
fun `uiState emits Loading then Success`() = runTest {
    fakeRepository.articles = mutableListOf(testArticle)

    viewModel.uiState.test {
        // test { } is an EXTENSION FUNCTION on Flow from Turbine
        // It collects the Flow and lets you assert each emission

        assertEquals(UiState.Loading, awaitItem())
        // awaitItem() is a FUNCTION from Turbine — waits for the next emission
        // Returns the emitted value — you assert it

        viewModel.loadArticles()

        // After loadArticles, state goes Loading → Success
        val loadingState = awaitItem()
        assertEquals(UiState.Loading, loadingState)

        val successState = awaitItem()
        assertTrue(successState is UiState.Success)

        cancelAndIgnoreRemainingEvents()
        // cancelAndIgnoreRemainingEvents() — clean up the Flow collection
    }
}

@Test
fun `search results update when query changes`() = runTest {
    viewModel.searchResults.test {
        assertEquals(emptyList<Article>(), awaitItem())   // initial state

        viewModel.onQueryChanged("kotlin")
        advanceUntilIdle()   // advance past debounce delay
        // advanceUntilIdle() is a FUNCTION from coroutines-test
        // Runs all pending coroutines (including delayed ones)

        val results = awaitItem()
        assertTrue(results.isNotEmpty())

        cancelAndIgnoreRemainingEvents()
    }
}

// Turbine functions:
// awaitItem() — wait for next emission, fail if none
// awaitError() — wait for an error emission
// awaitComplete() — wait for Flow completion
// expectNoEvents() — assert nothing was emitted
// cancelAndIgnoreRemainingEvents() — clean up

Testing Suspend Functions

// Suspend functions are tested inside runTest { }

class ArticleRepositoryTest {

    private val fakeApi = FakeArticleApi()
    private val fakeDao = FakeArticleDao()
    private val repository = ArticleRepositoryImpl(fakeApi, fakeDao)

    @Test
    fun `refreshArticles saves API response to database`() = runTest {
        // GIVEN — API will return articles
        fakeApi.articlesToReturn = listOf(
            ArticleDto(id = "1", title = "Kotlin", content = "...", author = "Alice", publishedAt = 0)
        )

        // WHEN
        repository.refreshArticles()

        // THEN — articles are saved in the DAO
        assertEquals(1, fakeDao.insertedArticles.size)
        assertEquals("Kotlin", fakeDao.insertedArticles[0].title)
    }

    @Test
    fun `refreshArticles throws on network error`() = runTest {
        fakeApi.shouldThrow = true

        // assertThrows verifies an exception is thrown
        val exception = assertThrows<IOException> {
            repository.refreshArticles()
        }
        assertEquals("Fake network error", exception.message)
    }

    @Test
    fun `getArticle returns null for unknown id`() = runTest {
        val result = repository.getArticle("unknown-id")
        assertNull(result)
    }
}

Testing SavedStateHandle (Process Death)

// Test that ViewModel correctly uses SavedStateHandle

class SearchViewModelTest {

    private val testDispatcher = UnconfinedTestDispatcher()
    private val fakeRepo = FakeArticleRepository()

    @BeforeEach
    fun setup() { Dispatchers.setMain(testDispatcher) }

    @AfterEach
    fun tearDown() { Dispatchers.resetMain() }

    @Test
    fun `query is restored from SavedStateHandle`() = runTest {
        // Simulate process death restoration
        val restoredHandle = SavedStateHandle(mapOf("query" to "kotlin"))
        // SavedStateHandle constructor takes a Map — simulates restored state

        val viewModel = SearchViewModel(fakeRepo, restoredHandle)

        assertEquals("kotlin", viewModel.searchQuery.value)
    }

    @Test
    fun `changing query updates SavedStateHandle`() = runTest {
        val handle = SavedStateHandle()
        val viewModel = SearchViewModel(fakeRepo, handle)

        viewModel.onQueryChanged("compose")

        assertEquals("compose", handle.get<String>("query"))
        // If process death happens now, "compose" will be restored
    }

    @Test
    fun `search is re-performed on restoration with existing query`() = runTest {
        fakeRepo.searchResults = listOf(testArticle)
        val restoredHandle = SavedStateHandle(mapOf("query" to "kotlin"))

        val viewModel = SearchViewModel(fakeRepo, restoredHandle)
        advanceUntilIdle()

        val state = viewModel.searchState.value
        assertTrue(state is SearchState.Success)
    }
}

MockK Patterns — Common Scenarios

// ═══ MOCKING SUSPEND FUNCTIONS ═══════════════════════════════════════
val mockApi = mockk<ArticleApi>()

coEvery { mockApi.getArticles() } returns listOf(testArticleDto)
// coEvery is a FUNCTION from MockK — for suspend functions
// Use coEvery instead of every for suspend functions

coVerify { mockApi.getArticles() }
// coVerify — verify a suspend function was called

// ═══ ARGUMENT CAPTURE ════════════════════════════════════════════════
val slot = slot<String>()
// slot() is a FUNCTION from MockK — captures an argument

every { mockAnalytics.logEvent(capture(slot), any()) } returns Unit

viewModel.loadArticles()

assertEquals("articles_loaded", slot.captured)
// slot.captured — the value that was passed to the function

// ═══ RETURNING DIFFERENT VALUES ON SUCCESSIVE CALLS ══════════════════
coEvery { mockApi.getArticles() } returnsMany listOf(
    emptyList(),                    // first call returns empty
    listOf(testArticleDto)          // second call returns articles
)

// ═══ THROWING EXCEPTIONS ═════════════════════════════════════════════
coEvery { mockApi.getArticles() } throws IOException("timeout")

// ═══ RELAXED MOCKS ═══════════════════════════════════════════════════
val relaxedMock = mockk<Analytics>(relaxed = true)
// relaxed = true → all functions return default values without setup
// Int → 0, String → "", Boolean → false, Unit → Unit
// No need to specify every {} for every function

Testing Best Practices

// 1. TEST NAMING — describe the scenario and expected result
@Test fun `loadArticles sets Success state with articles`()
@Test fun `loadArticles sets Error state on network failure`()
@Test fun `search debounces input by 300ms`()
// Use backtick names for readability (Kotlin feature)

// 2. ARRANGE-ACT-ASSERT (AAA) or GIVEN-WHEN-THEN
@Test
fun `empty list shows Empty state`() {
    // GIVEN (Arrange) — set up preconditions
    fakeRepo.articles = emptyList()

    // WHEN (Act) — perform the action
    viewModel.loadArticles()

    // THEN (Assert) — verify the result
    assertTrue(viewModel.uiState.value is UiState.Empty)
}

// 3. ONE ASSERTION PER TEST (ideally)
// ❌ Testing too many things
@Test fun `loadArticles works`() {
    viewModel.loadArticles()
    assertTrue(state is Success)
    assertEquals(2, state.articles.size)
    assertEquals("Kotlin", state.articles[0].title)
    verify { analytics.log(any()) }
}

// ✅ Each test verifies one behaviour
@Test fun `loadArticles sets Success state`() { /* ... */ }
@Test fun `loadArticles returns correct article count`() { /* ... */ }
@Test fun `loadArticles logs analytics event`() { /* ... */ }

// 4. DON'T TEST IMPLEMENTATION — test BEHAVIOUR
// ❌ Testing that a specific private function was called
// ✅ Testing the observable RESULT (uiState, emitted values, return values)

// 5. TEST EDGE CASES
@Test fun `handles empty article list`() { /* ... */ }
@Test fun `handles null article fields`() { /* ... */ }
@Test fun `handles network timeout`() { /* ... */ }
@Test fun `handles concurrent loadArticles calls`() { /* ... */ }

What to Test and What Not to Test

// ✅ TEST these (high value):
// - ViewModel logic (state transitions, error handling, loading states)
// - Use Cases / Interactors (business rules)
// - Repository (data mapping, caching logic, error wrapping)
// - Mappers (data transformation)
// - Validators (form validation, input parsing)
// - Utility functions (date formatting, string manipulation)

// ❌ DON'T unit test these (low value or wrong tool):
// - Android framework classes (Activity, Fragment, View) → use UI tests
// - Trivial getters/setters with no logic
// - Third-party library code (Retrofit, Room) → they're already tested
// - Generated code (Dagger/Hilt components, Room DAOs)
// - UI layout and styling → use screenshot tests or manual QA

// PRIORITY ORDER:
// 1. ViewModels (most logic, most impact)
// 2. Use Cases / Domain logic
// 3. Repositories (data flow)
// 4. Mappers and utilities
// 5. Everything else if time permits

Common Mistakes to Avoid

Mistake 1: Not replacing Dispatchers.Main in tests

// ❌ Test crashes — no Android Main looper in JVM tests
@Test
fun test() = runTest {
    val viewModel = ArticleViewModel(fakeRepo)
    viewModel.loadArticles()   // 💥 viewModelScope uses Dispatchers.Main!
}

// ✅ Replace Main dispatcher before each test
@BeforeEach
fun setup() { Dispatchers.setMain(UnconfinedTestDispatcher()) }

@AfterEach
fun tearDown() { Dispatchers.resetMain() }

Mistake 2: Testing implementation instead of behaviour

// ❌ Verifying internal calls — fragile, breaks on refactor
verify { repository.getArticlesFromCache() }
verify { repository.getArticlesFromNetwork() }

// ✅ Test the RESULT — doesn't break when implementation changes
val state = viewModel.uiState.value
assertTrue(state is UiState.Success)

Mistake 3: Shared mutable state between tests

// ❌ State leaks between tests — order-dependent failures
class ViewModelTest {
    private val fakeRepo = FakeArticleRepository()   // shared!
    private val viewModel = ArticleViewModel(fakeRepo)   // shared!

    @Test fun test1() { fakeRepo.articles = listOf(article1) }
    @Test fun test2() { /* fakeRepo still has article1 from test1! */ }
}

// ✅ Create fresh instances in @BeforeEach
class ViewModelTest {
    private lateinit var fakeRepo: FakeArticleRepository
    private lateinit var viewModel: ArticleViewModel

    @BeforeEach
    fun setup() {
        fakeRepo = FakeArticleRepository()   // fresh for each test
        viewModel = ArticleViewModel(fakeRepo)
    }
}

Mistake 4: Ignoring CancellationException in tested code

// ❌ Production code catches CancellationException — tests pass but app breaks
try { api.getArticles() }
catch (e: Exception) { _uiState.value = Error(e.message) }
// CancellationException caught → coroutine can't cancel → unkillable!

// ✅ Always re-throw — and test for it
try { api.getArticles() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { _uiState.value = Error(e.message) }

Mistake 5: Not testing error paths

// ❌ Only testing the happy path
@Test fun `loads articles`() { /* ... */ }   // only success!

// ✅ Test EVERY state transition
@Test fun `sets Loading state initially`() { /* ... */ }
@Test fun `sets Success state with articles`() { /* ... */ }
@Test fun `sets Empty state when no articles`() { /* ... */ }
@Test fun `sets Error state on network failure`() { /* ... */ }
@Test fun `sets Error state on timeout`() { /* ... */ }
@Test fun `retries after error`() { /* ... */ }

Summary

  • Unit tests verify small pieces of code in isolation — fast, reliable, run on JVM (no device needed)
  • Use JUnit 5 (library) for test annotations and assertions: @Test, assertEquals(), assertTrue(), assertThrows()
  • Use MockK (library) for creating mocks: mockk(), every {}, verify {}, coEvery {} for suspend
  • Use Turbine (library) for testing Flows: .test { awaitItem() }, cancelAndIgnoreRemainingEvents()
  • Use runTest {} (top-level function from coroutines-test) to run coroutine-based tests
  • Always replace Dispatchers.Main with UnconfinedTestDispatcher in @BeforeEach
  • Prefer Fakes (working simplified implementations) over Mocks for data sources
  • Use Mocks for verifying side effects (analytics, logging, events)
  • Follow Given-When-Then pattern: set up data → perform action → verify result
  • Test behaviour, not implementation — verify observable outputs (uiState, return values), not internal calls
  • Test SavedStateHandle by passing pre-populated maps to simulate process death restoration
  • Test all state transitions: Loading, Success, Empty, Error — not just the happy path
  • Create fresh instances in @BeforeEach — avoid shared mutable state between tests
  • Priority: ViewModels > Use Cases > Repositories > Mappers > Utilities
  • advanceUntilIdle() (function from coroutines-test) runs all pending coroutines including delayed ones

Unit tests are the highest-value investment you can make in code quality. A ViewModel with 20 tests gives you confidence to refactor, catches regressions instantly, and documents exactly how your code behaves. Start with ViewModels (they have the most logic), use Fakes for data sources, Turbine for Flows, and test every state transition. Once you have tests, every code change becomes safer.

Happy coding!