Unit Testing in Android

📖 Concept

Unit testing validates individual functions and classes in isolation. At the senior level, you should write testable code by design, not as an afterthought.

What to test:

  • ViewModel logic: State transitions, error handling, data mapping
  • Repository methods: Data source selection, caching, error transformation
  • Use Cases: Business rules, filtering, validation
  • Mappers/Utilities: Data transformation, formatting

What NOT to unit test:

  • Android framework classes directly (Activity, Fragment) — use UI tests
  • Third-party library internals (Room, Retrofit) — trust the library
  • Trivial getters/setters — no logic = no value

Testing coroutines: Use kotlinx-coroutines-test with runTest and StandardTestDispatcher or UnconfinedTestDispatcher.

Testing Flow: Use .first() for single emission, Turbine library for multi-emission testing.

Test doubles:

  • Fake: Working implementation with simplified logic (FakeRepository)
  • Mock: Configured to return specific values (Mockk/Mockito)
  • Stub: Returns hardcoded values
  • Spy: Wraps real implementation, records calls

Google's preference: Fakes over Mocks. Fakes are real implementations that test behavior, not implementation details.

💻 Code Example

codeTap to expand ⛶
1// ViewModel test with coroutines
2class TaskViewModelTest {
3 // Replace main dispatcher for testing
4 @get:Rule
5 val mainDispatcherRule = MainDispatcherRule()
6
7 private lateinit var viewModel: TaskViewModel
8 private val fakeRepository = FakeTaskRepository()
9
10 @Before
11 fun setup() {
12 viewModel = TaskViewModel(fakeRepository)
13 }
14
15 @Test
16 fun `loadTasks emits success state with tasks`() = runTest {
17 // Given
18 val tasks = listOf(Task("1", "Test", false))
19 fakeRepository.setTasks(tasks)
20
21 // When — ViewModel loads on init
22
23 // Then — use Turbine to test Flow emissions
24 viewModel.uiState.test {
25 val state = awaitItem()
26 assertTrue(state is TaskUiState.Success)
27 assertEquals(tasks, (state as TaskUiState.Success).tasks)
28 }
29 }
30
31 @Test
32 fun `toggleComplete updates task state`() = runTest {
33 fakeRepository.setTasks(listOf(Task("1", "Test", false)))
34
35 viewModel.toggleComplete("1")
36 advanceUntilIdle() // Process all coroutines
37
38 viewModel.uiState.test {
39 val state = awaitItem() as TaskUiState.Success
40 assertTrue(state.tasks.first().isComplete)
41 }
42 }
43
44 @Test
45 fun `loadTasks emits error on repository failure`() = runTest {
46 fakeRepository.setShouldFail(true)
47 viewModel = TaskViewModel(fakeRepository) // Re-create to trigger load
48
49 viewModel.uiState.test {
50 val state = awaitItem()
51 assertTrue(state is TaskUiState.Error)
52 }
53 }
54}
55
56// Fake repository — preferred over mocks
57class FakeTaskRepository : TaskRepository {
58 private val tasks = MutableStateFlow<List<Task>>(emptyList())
59 private var shouldFail = false
60
61 fun setTasks(newTasks: List<Task>) { tasks.value = newTasks }
62 fun setShouldFail(fail: Boolean) { shouldFail = fail }
63
64 override fun getTasks(): Flow<List<Task>> {
65 if (shouldFail) return flow { throw IOException("Network error") }
66 return tasks
67 }
68
69 override suspend fun toggleComplete(id: String) {
70 if (shouldFail) throw IOException("Network error")
71 tasks.update { list ->
72 list.map { if (it.id == id) it.copy(isComplete = !it.isComplete) else it }
73 }
74 }
75}
76
77// MainDispatcherRule — replaces Main dispatcher in tests
78class MainDispatcherRule(
79 private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
80) : TestWatcher() {
81 override fun starting(description: Description) {
82 Dispatchers.setMain(dispatcher)
83 }
84 override fun finished(description: Description) {
85 Dispatchers.resetMain()
86 }
87}

🏋️ Practice Exercise

Practice:

  1. Write unit tests for a ViewModel with 3 state transitions (Loading → Success → Error)
  2. Create a Fake repository and compare it to a Mock-based test
  3. Test a coroutine with delay() using runTest and advanceTimeBy()
  4. Test a Flow with multiple emissions using Turbine
  5. Achieve 80% code coverage for a Repository class

⚠️ Common Mistakes

  • Not replacing Dispatchers.Main in tests — causes 'Module with the Main dispatcher is missing' error

  • Using Mockito for everything — fakes test behavior, mocks test implementation details

  • Testing implementation instead of behavior — don't verify internal method call order

  • Not testing error paths — happy path works, but how does the app handle failures?

💼 Interview Questions

🎤 Mock Interview

Mock interview is powered by AI for Unit Testing in Android. Login to unlock this feature.