Android Testing Strategy
The Visla GPS Android app has 1,388 tests across 70 test files covering every architectural layer. The test suite is designed around the principle that business logic correctness is verified through fast, deterministic unit tests, while a small number of UI tests validate critical user flows end-to-end.
Sub-Pagesβ
- ViewModel Tests β IntentViewModel pattern, Screen ViewModel tests, Flow-Based ViewModels
- Data Layer Tests β Repository tests, Mapper tests, DataStore tests
- UseCase Tests β UseCase testing patterns
- UI Tests β Hilt Test Runner, Test DI Module Overrides, MockWebServer, Robot Pattern, Permission Handling
Test Pyramid Overviewβ
| Layer | Test Count | Files | Framework |
|---|---|---|---|
| Unit β ViewModels | ~540 | 29 | JUnit 4 + MockK + Coroutines Test |
| Unit β Use Cases | ~230 | 12 | JUnit 4 + Mockito-Kotlin + Turbine |
| Unit β Repositories | ~230 | 11 | JUnit 4 + MockK + Coroutines Test |
| Unit β Mappers | ~210 | 9 | JUnit 4 (no mocking) |
| Unit β DataStores | ~100 | 5 | JUnit 4 + Turbine + Coroutines Test |
| Unit β Other | ~70 | 2 | JUnit 4 |
| UI β Instrumentation | 9 | 2 | Compose Testing + Hilt + MockWebServer |
Coroutine Test Setupβ
Every test that touches coroutines follows the same pattern: replace Dispatchers.Main with a StandardTestDispatcher in @Before, reset it in @After, and use runTest for test bodies.
@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
// ... create mocks and SUT
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `successful login saves tokens and emits success event`() = runTest {
val loginResult = createLoginResult()
coEvery { loginUseCase(any(), any()) } returns LoginResponse.Success(loginResult)
viewModel.handle(LoginIntent.UpdateEmail("test@example.com"))
viewModel.handle(LoginIntent.UpdatePassword("password123"))
viewModel.handle(LoginIntent.Login)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.errorMessage)
assertEquals(LoginEvent.Success, viewModel.loginEvent.value)
}
}
Key details:
StandardTestDispatcher(notUnconfinedTestDispatcher) is used everywhere, giving tests explicit control over coroutine execution viaadvanceUntilIdle().runTestwraps all async test bodies and providestestSchedulerfor fine-grained control.- Parallelism is maximized:
maxParallelForks = Runtime.getRuntime().availableProcessors()inbuild.gradle.kts.
Flow Testing with Turbineβ
Turbine is used to test Flow and SharedFlow emissions. It appears in DataStore tests, use case tests that return Flows, and the UserProfileInteractor.
class DeviceDataStoreTest {
@Test
fun `updatePosition - emits to positionUpdates flow`() = runTest {
val device = createDeviceWithPosition(1)
dataStore.initialize(listOf(device))
val newPosition = createPosition(deviceId = 1, latitude = 45.0, longitude = -122.0)
dataStore.positionUpdates.test {
dataStore.updatePosition(newPosition)
val emitted = awaitItem()
assertEquals(45.0, emitted.latitude!!, 0.0001)
cancelAndIgnoreRemainingEvents()
}
}
}
class RealTimeUseCasesTest {
@Test
fun `observePositionUpdates - emits multiple positions`() = runTest {
val position1 = createPosition(deviceId = 1)
val position2 = createPosition(deviceId = 2)
observePositionUpdatesUseCase().test {
positionUpdatesFlow.emit(position1)
assertEquals(1, awaitItem().deviceId)
positionUpdatesFlow.emit(position2)
assertEquals(2, awaitItem().deviceId)
cancelAndIgnoreRemainingEvents()
}
}
}
class AuthUseCasesTest {
@Test
fun `UserProfileInteractor - observeProfile emits updates`() = runTest {
val interactor = UserProfileInteractor(authRepository)
val user = createTestUser()
whenever(authRepository.getProfile()).thenReturn(user)
interactor.observeProfile().test {
assertNull(awaitItem())
interactor.loadProfile()
assertEquals(user, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}
Pattern: collect the Flow inside .test { }, trigger the action that causes an emission, then awaitItem() to assert.
Mocking Strategyβ
The codebase uses two mocking libraries for different layers:
| Layer | Library | Rationale |
|---|---|---|
| ViewModels, Repositories, DataStores | MockK (io.mockk) | Kotlin-first API, coEvery/coVerify for coroutines, relaxed mocks |
| Use Cases | Mockito-Kotlin (org.mockito.kotlin) | Lightweight, whenever/verify pattern, mock() factory |
MockK Patternsβ
// Relaxed mock β returns defaults for un-stubbed calls
loginUseCase = mockk(relaxed = true)
// Stub a suspend function
coEvery { loginUseCase(any(), any()) } returns LoginResponse.Success(loginResult)
// Stub with argument matching
coEvery { authApi.login(match { it.email == "test@example.com" }) } returns responseDto
// Verify a suspend function was called
coVerify { tokenManager.saveTokens(accessToken = "access-token-123", refreshToken = "refresh-token-456", userId = 1) }
// Verify not called
coVerify(exactly = 0) { loginUseCase(any(), any()) }
// Stub void suspend function
coEvery { dataStore.clear() } just Runs
// Stub a property (non-suspend)
every { devicesInteractor.devicesFlow } returns devicesFlow
Mockito-Kotlin Patternsβ
// Create mock
authRepository = mock()
// Stub
whenever(authRepository.login("test@example.com", "password123")).thenReturn(loginResult)
// Stub that throws
whenever(authRepository.login(any(), any()))
.thenAnswer { throw TwoFactorRequiredException("temp_token_123", "totp") }
// Verify
verify(authRepository).login("user@example.com", "password123")
// Verify no interactions
verifyNoInteractions(authRepository)
// Nullable argument matching
verify(authRepository).updateUser(eq(1), eq("New Name"), isNull(), isNull(), isNull())
Build Targetsβ
# Run all unit tests (parallel execution)
make test-unit
# Run a specific test file
make test-unit test=LoginViewModel
# Run all UI tests (requires connected device/emulator)
make test-ui
# Run a single UI test
make test-ui-single TEST=LoginTest#test05_login_withValidCredentials_thenLogout
Under the hood:
| Target | Gradle Command |
|---|---|
make test-unit | ./gradlew testDebugUnitTest |
make test-unit test=X | ./gradlew testDebugUnitTest --tests "*X*" |
make test-ui | ./gradlew connectedDebugAndroidTest |
make test-ui-single TEST=X | ./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=...X |
Test Dependenciesβ
From app/build.gradle.kts:
// Unit Testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.21.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:6.2.3")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("io.mockk:mockk:1.14.9")
testImplementation("app.cash.turbine:turbine:1.2.1")
// Hilt Testing
testImplementation("com.google.dagger:hilt-android-testing:2.59.2")
kspTest("com.google.dagger:hilt-compiler:2.59.2")
// UI Testing (Compose)
androidTestImplementation(platform("androidx.compose:compose-bom:2026.02.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test:rules:1.7.0")
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
androidTestImplementation("com.squareup.okhttp3:mockwebserver3:5.3.2")
androidTestImplementation("com.google.dagger:hilt-android-testing:2.59.2")
kspAndroidTest("com.google.dagger:hilt-compiler:2.59.2")
Test Configurationβ
From build.gradle.kts:
android {
testOptions {
unitTests.all {
it.maxParallelForks = Runtime.getRuntime().availableProcessors()
}
}
}
Unit tests run in parallel across all available CPU cores. No code coverage tools (JaCoCo) are currently configured.
Design Decisionsβ
Two Mocking Librariesβ
The codebase uses MockK for ViewModels/Repositories and Mockito-Kotlin for Use Cases. This is intentional: MockK's relaxed mocks and coEvery syntax are ideal for ViewModels with many dependencies, while Mockito-Kotlin's lighter API suits use cases that typically have a single repository dependency and benefit from verifyNoInteractions().
No Hilt in Unit Testsβ
Unit tests manually construct their subjects with mocked dependencies rather than using Hilt's test injection. This keeps unit tests fast (no annotation processing overhead) and explicit about what is being tested. Hilt testing infrastructure is reserved for instrumentation tests where the full DI graph is needed.
IntentViewModel as Testability Foundationβ
The IntentViewModel base class makes ViewModels inherently testable: state changes go through handle(intent) β reduce(intent), creating a clear inputβoutput contract. Tests call handle() and assert on uiState.value β no need to simulate UI events or observe LiveData.
Robot Pattern for UI Testsβ
UI tests use the Robot pattern to separate "what to do" (Robot) from "what to verify" (Verification). This makes tests readable as user stories and allows changing screen implementation details without rewriting test logic. The infix fun verify DSL creates a natural "action then verify" flow.
MockWebServer over Fake Repositoriesβ
UI tests hit real Retrofit HTTP calls against a local MockWebServer rather than replacing repositories with fakes. This tests the full network stack (interceptors, serialization, error mapping) while remaining deterministic. The MockServerRule handles server lifecycle and automatically configures TestBaseUrlProvider.
Ordered UI Testsβ
UI tests use @FixMethodOrder(MethodSorters.NAME_ASCENDING) with test01_, test02_ prefixes. This ensures simpler tests (screen display, validation) run before complex flows (login + logout), so failures are easier to diagnose. Each test class documents its ordering rationale.
StandardTestDispatcher over Unconfinedβ
All tests use StandardTestDispatcher rather than UnconfinedTestDispatcher. This requires explicit advanceUntilIdle() calls but provides deterministic control over coroutine execution order, prevents subtle race conditions, and matches closer to production behavior.
Test Factory Helpersβ
Every test file defines private createTest*() helper functions for building domain objects. This avoids repetitive constructor calls, makes tests focus on the behavior under test, and provides a single place to update when domain models change.
private fun createTestUser(name: String = "Test User") = User(
id = 1, name = name, email = "test@example.com",
role = UserRole.USER, hasPassword = true, termsAccepted = true,
emailVerified = true, twoFactorEnabled = false, timezone = "UTC", language = "en"
)