Unit Testing Android with Mockito: ViewModels, Repositories, and Coroutines

Unit Testing Android with Mockito: ViewModels, Repositories, and Coroutines

Unit tests on Android have a reputation for being awkward. Some of that is earned — the platform is deeply tied to Android SDK classes that require a device or Robolectric to instantiate. But most Android logic that matters can be tested in pure JVM unit tests if you structure the code right. Mockito is the primary tool for isolating the code you're actually testing from everything it depends on.

Setup

Add these dependencies to your app/build.gradle.kts:

dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.mockito:mockito-core:5.3.1")
    testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
    testImplementation("androidx.arch.core:core-testing:2.2.0")
    testImplementation("app.cash.turbine:turbine:1.0.0") // optional, for Flow testing
}

The mockito-kotlin library is not optional — it adds Kotlin-friendly wrappers (whenever, verify, argumentCaptor, mock<T>()) that make tests significantly more readable than the raw Java Mockito API.

core-testing provides InstantTaskExecutorRule which replaces LiveData's background executor with a synchronous one — you need this for any test touching LiveData.

Mocking Basics

Creating mocks:

// Mockito annotation approach (requires MockitoJUnit.rule())
@Mock lateinit var userRepository: UserRepository

@get:Rule val mockitoRule = MockitoJUnit.rule()

// Or inline
val userRepository = mock<UserRepository>()

Stubbing return values:

// mockito-kotlin style
whenever(userRepository.getUser(42)).thenReturn(User(id = 42, name = "Alice"))

// For suspend functions
whenever(userRepository.getUserAsync(42)).thenReturn(User(id = 42, name = "Alice"))

// Throw on call
whenever(userRepository.getUser(-1)).thenThrow(IllegalArgumentException("Invalid ID"))

Verifying interactions:

verify(userRepository).saveUser(any())
verify(userRepository, times(2)).getUser(42)
verify(userRepository, never()).deleteUser(any())
verifyNoMoreInteractions(userRepository)

verifyNoMoreInteractions is useful for checking that your code didn't make unexpected calls — but use it judiciously. Overly strict interaction tests break whenever you add logging or caching, even if the behavior hasn't changed.

Testing ViewModels

ViewModels are the sweet spot for Android unit tests. They contain business logic, they don't touch the Android UI directly, and they're relatively easy to isolate.

A typical ViewModel under test:

class UserViewModel(
    private val userRepository: UserRepository,
    private val analyticsService: AnalyticsService
) : ViewModel() {

    private val _user = MutableLiveData<Result<User>>()
    val user: LiveData<Result<User>> = _user

    fun loadUser(id: Int) {
        viewModelScope.launch {
            _user.value = Result.Loading
            try {
                val user = userRepository.getUserAsync(id)
                analyticsService.track("user_loaded", mapOf("id" to id))
                _user.value = Result.Success(user)
            } catch (e: Exception) {
                _user.value = Result.Error(e)
            }
        }
    }
}

The test class:

@ExtendWith(InstantExecutorExtension::class)
class UserViewModelTest {

    @get:Rule
    val mainCoroutineRule = MainCoroutineRule()

    private val userRepository = mock<UserRepository>()
    private val analyticsService = mock<AnalyticsService>()

    private lateinit var viewModel: UserViewModel

    @Before
    fun setup() {
        viewModel = UserViewModel(userRepository, analyticsService)
    }

    @Test
    fun `loadUser emits success when repository returns user`() = runTest {
        val expectedUser = User(id = 1, name = "Alice")
        whenever(userRepository.getUserAsync(1)).thenReturn(expectedUser)

        viewModel.loadUser(1)
        advanceUntilIdle()

        val result = viewModel.user.value
        assertThat(result).isInstanceOf(Result.Success::class.java)
        assertThat((result as Result.Success).data).isEqualTo(expectedUser)
    }

    @Test
    fun `loadUser emits error when repository throws`() = runTest {
        whenever(userRepository.getUserAsync(1))
            .thenThrow(NetworkException("No connection"))

        viewModel.loadUser(1)
        advanceUntilIdle()

        val result = viewModel.user.value
        assertThat(result).isInstanceOf(Result.Error::class.java)
    }

    @Test
    fun `loadUser tracks analytics on successful load`() = runTest {
        whenever(userRepository.getUserAsync(1)).thenReturn(User(id = 1, name = "Alice"))

        viewModel.loadUser(1)
        advanceUntilIdle()

        verify(analyticsService).track("user_loaded", mapOf("id" to 1))
    }
}

You need a MainCoroutineRule to control the Dispatchers.Main coroutine dispatcher in tests. Without it, coroutines launched with viewModelScope will run on a real main thread that doesn't exist in JVM tests.

class MainCoroutineRule(
    val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }
}

For newer versions of kotlinx-coroutines-test (1.6+), use StandardTestDispatcher or UnconfinedTestDispatcher and runTest directly:

@Before
fun setup() {
    Dispatchers.setMain(StandardTestDispatcher())
}

@After
fun teardown() {
    Dispatchers.resetMain()
}

Testing the Repository Pattern

Repositories coordinate between data sources — a network API, a local database, and possibly an in-memory cache. Testing a repository in isolation means mocking the data sources.

class UserRepository(
    private val apiService: ApiService,
    private val userDao: UserDao,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend fun getUser(id: Int): User = withContext(dispatcher) {
        userDao.getUser(id) ?: run {
            val user = apiService.fetchUser(id)
            userDao.insertUser(user)
            user
        }
    }
}

The test:

class UserRepositoryTest {

    private val apiService = mock<ApiService>()
    private val userDao = mock<UserDao>()
    private val testDispatcher = StandardTestDispatcher()

    private lateinit var repository: UserRepository

    @Before
    fun setup() {
        repository = UserRepository(apiService, userDao, testDispatcher)
    }

    @Test
    fun `getUser returns cached user when available`() = runTest(testDispatcher) {
        val cachedUser = User(id = 1, name = "Alice")
        whenever(userDao.getUser(1)).thenReturn(cachedUser)

        val result = repository.getUser(1)

        assertThat(result).isEqualTo(cachedUser)
        verify(apiService, never()).fetchUser(any())
    }

    @Test
    fun `getUser fetches from network and caches when not in database`() = runTest(testDispatcher) {
        whenever(userDao.getUser(1)).thenReturn(null)
        val networkUser = User(id = 1, name = "Alice")
        whenever(apiService.fetchUser(1)).thenReturn(networkUser)

        val result = repository.getUser(1)

        assertThat(result).isEqualTo(networkUser)
        verify(userDao).insertUser(networkUser)
    }
}

Injecting the dispatcher (rather than using Dispatchers.IO directly in the repository) is the key design decision that makes this testable. Pass testDispatcher in tests, Dispatchers.IO in production. This is a tiny amount of additional complexity that pays for itself immediately.

Coroutines Testing with runTest

runTest is the standard way to test suspending functions and coroutines in kotlinx-coroutines-test 1.6+. It runs the test in a TestCoroutineScope, controls virtual time, and automatically fails the test if any unhandled exceptions are thrown in coroutines.

@Test
fun `suspend function completes correctly`() = runTest {
    val result = repository.getUser(1)
    assertThat(result).isNotNull()
}

Advancing time manually (for testing timeouts, delays, or retry logic):

@Test
fun `retry logic retries after delay`() = runTest {
    var callCount = 0
    whenever(apiService.fetchUser(1)).thenAnswer {
        callCount++
        if (callCount < 3) throw NetworkException("Timeout")
        User(id = 1, name = "Alice")
    }

    val result = repository.getUserWithRetry(1)

    // Advance past the retry delays
    advanceTimeBy(5000)
    runCurrent()

    assertThat(callCount).isEqualTo(3)
    assertThat(result.name).isEqualTo("Alice")
}

Testing Flow emissions with Turbine:

@Test
fun `user flow emits updates`() = runTest {
    whenever(userDao.getUserFlow(1)).thenReturn(
        flowOf(User(id = 1, name = "Alice"), User(id = 1, name = "Alice Updated"))
    )

    repository.getUserFlow(1).test {
        assertThat(awaitItem().name).isEqualTo("Alice")
        assertThat(awaitItem().name).isEqualTo("Alice Updated")
        awaitComplete()
    }
}

Turbine's .test {} block makes Flow testing dramatically cleaner than manually collecting to a list.

@Before and @After: Setup and Teardown

Use @Before for test setup that every test in the class needs:

@Before
fun setup() {
    // Reset mocks to clear any stubbing from previous tests
    reset(userRepository, analyticsService)
    
    // Recreate the subject under test
    viewModel = UserViewModel(userRepository, analyticsService)
}

reset() clears both stubbing and interaction history. Call it in @Before if you're reusing mocks across tests (which is fine) to prevent one test's stubs from leaking into the next.

Use @After for cleanup that must happen even if the test fails:

@After
fun teardown() {
    Dispatchers.resetMain()
    // Close any resources
}

@BeforeClass and @AfterClass run once for the entire test class (in JUnit 4, they must be on companion object members). Use these for expensive shared setup — like creating an in-memory database — that doesn't need to be recreated per test.

What to Mock vs What to Test Real

This is the question that separates effective test suites from fragile ones.

Mock these:

  • External services (network APIs, analytics, push notifications) — you don't control them and they're slow/unreliable
  • Database DAOs when testing repository logic — use a real in-memory Room database when testing DAO logic itself
  • System clock — inject a Clock interface so you can control time in tests
  • Random number generators — inject them; hardcoded seeds are fine for reproducibility
  • Logger/analytics — these are side effects with no return value; mock them to verify they're called

Test real:

  • Pure business logic — transformation functions, validation rules, calculations. No mocking needed or desired
  • The ViewModel itself — it's the subject under test, not a mock
  • Domain models — User, Order, Product are just data; create real instances
  • Your own internal utilities — if you mock your own StringUtils.formatPhone(), you're testing the mock, not the code

Gray area — think before mocking:

  • The repository from the ViewModel's perspective: mocking it is correct because it tests the ViewModel in isolation. But if you always mock it, you never test that the ViewModel and Repository actually work together. Write integration tests for that layer.
  • Room DAOs: mock them in repository unit tests, but also write tests against a real in-memory Room database to verify your SQL and migrations.

The failure mode of over-mocking is tests that pass even when the code is broken, because everything real has been replaced with mocks. A test that mocks userRepository.getUser() to return a user and then asserts that the ViewModel's LiveData contains that user is mostly testing Mockito, not your code. Make sure each test exercises at least one real piece of logic.

Read more

Start now free