Testing Android apps: a practical Android & Kotlin guide
By day 47 of a modern Android development journey, you've built screens, connected APIs, and shaped business logic into production code. None of it matters if you can't verify that it works. Testing Android apps requires a deliberate strategy that spans unit tests on the JVM, UI assertions in Compose, and integration tests on real devices. We'll walk through a practical testing strategy that catches regressions early, keeps feedback loops tight, and lets you refactor without fear.
Unit tests — ViewModels, repositories and use cases on the JVM
Unit tests are your first line of defense. They run on the JVM in seconds, require no emulator or device, and cover the logic that drives your app: ViewModels, repositories, use cases, and utility functions. The goal is to test a single unit in isolation, mocking or faking its dependencies.
A typical MVVM structure lends itself well to unit testing. Consider a login use case that validates credentials and returns a user object:
class LoginUseCase(private val authRepository: AuthRepository) {
suspend fun execute(email: String, password: String): Result<User> {
if (email.isBlank() || password.isBlank()) {
return Result.failure(Exception("Email and password required"))
}
return authRepository.login(email, password)
}
}
A unit test for this use case would verify both the happy path and error cases:
class LoginUseCaseTest {
private lateinit var authRepository: FakeAuthRepository
private lateinit var loginUseCase: LoginUseCase
@Before
fun setup() {
authRepository = FakeAuthRepository()
loginUseCase = LoginUseCase(authRepository)
}
@Test
fun execute_withValidCredentials_returnsUser() = runTest {
authRepository.setLoginResponse(Result.success(User("john@example.com", "John")))
val result = loginUseCase.execute("john@example.com", "password")
assertTrue(result.isSuccess)
assertEquals("John", result.getOrNull()?.name)
}
@Test
fun execute_withBlankEmail_returnsFailure() = runTest {
val result = loginUseCase.execute("", "password")
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message?.contains("required") == true)
}
}
Notice we use runTest from the kotlinx-coroutines-test library. This handles coroutine execution in a deterministic way, advancing virtual time as needed. We also use a FakeAuthRepository rather than a mock—we'll cover that pattern in detail below.
For ViewModels, test the state emission and side effects. If your login ViewModel exposes a StateFlow of login states, assert that the right state is emitted when the use case succeeds or fails:
class LoginViewModelTest {
private val loginUseCase = FakeLoginUseCase()
private val viewModel = LoginViewModel(loginUseCase)
@Test
fun loginFlow_onSuccess_emitsSuccessState() = runTest {
loginUseCase.setResult(Result.success(User("jane@example.com", "Jane")))
viewModel.login("jane@example.com", "password")
val state = viewModel.state.first { it is LoginState.Success }
assertEquals("Jane", (state as LoginState.Success).user.name)
}
@Test
fun loginFlow_onFailure_emitsErrorState() = runTest {
loginUseCase.setResult(Result.failure(Exception("Network error")))
viewModel.login("jane@example.com", "password")
val state = viewModel.state.first { it is LoginState.Error }
assertTrue((state as LoginState.Error).message.contains("Network"))
}
}
Unit tests should be fast, deterministic, and focus on logic. Avoid testing framework boilerplate or Android system details. If you find yourself struggling to test a unit, it's often a sign that the unit has too many responsibilities or dependencies—refactor toward smaller, more testable pieces.
Compose UI tests — Find nodes and assert UI behavior
Once your logic is solid, test the UI layer. Jetpack Compose makes this straightforward with a semantic tree that you can query and interact with. The Compose testing library provides `ComposeTestRule`, which runs your Compose code in a test environment and lets you find nodes by text, content description, or other properties.
Imagine a simple login screen:
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = viewModel.email,
onValueChange = { viewModel.email = it },
label = { Text("Email") }
)
TextField(
value = viewModel.password,
onValueChange = { viewModel.password = it },
label = { Text("Password") }
)
Button(onClick = { viewModel.login() }) {
Text("Login")
}
when (state) {
is LoginState.Success -> Text("Welcome, ${(state as LoginState.Success).user.name}")
is LoginState.Error -> Text("Error: ${(state as LoginState.Error).message}")
else -> {}
}
}
}
A Compose UI test finds the Login button, clicks it, and asserts that the Welcome message appears:
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginScreen_onLoginButtonClick_displaysWelcomeMessage() {
val fakeViewModel = FakeLoginViewModel()
fakeViewModel.setLoginResult(Result.success(User("jane@example.com", "Jane")))
composeTestRule.setContent {
LoginScreen(viewModel = fakeViewModel)
}
composeTestRule.onNodeWithText("Login").performClick()
composeTestRule.onNodeWithText("Welcome, Jane").assertIsDisplayed()
}
@Test
fun loginScreen_onErrorState_displaysErrorMessage() {
val fakeViewModel = FakeLoginViewModel()
fakeViewModel.setLoginResult(Result.failure(Exception("Invalid credentials")))
composeTestRule.setContent {
LoginScreen(viewModel = fakeViewModel)
}
composeTestRule.onNodeWithText("Login").performClick()
composeTestRule.onNodeWithText("Error: Invalid credentials").assertIsDisplayed()
}
}
These tests run on the JVM with Robolectric, which provides Android framework stubs. They're much faster than instrumented tests on a device. Use `onNodeWithText()` for simple cases, but also leverage `onNodeWithContentDescription()` for accessibility labels and `onNodeWithTag()` for custom test tags when text is dynamic or unavailable:
Button(
onClick = { viewModel.login() },
modifier = Modifier.testTag("loginButton")
) {
Text("Login")
}
// In test:
composeTestRule.onNodeWithTag("loginButton").performClick()
Compose UI tests excel at verifying user journeys and visual state transitions. They're your bridge between unit logic and real-device behavior.
Fakes — Fake repositories instead of mocks
Mocking libraries like Mockito or MockK can obscure test intent and make tests brittle. A fake is a simplified, real implementation of a dependency that you control. It's a genuine class that returns canned data or state you set in your test, making assertions clearer and tests more readable.
Here's a fake repository instead of a mock:
class FakeAuthRepository : AuthRepository {
private var loginResult: Result<User> = Result.failure(Exception("Not set"))
fun setLoginResponse(result: Result<User>) {
this.loginResult = result
}
override suspend fun login(email: String, password: String): Result<User> {
return loginResult
}
override suspend fun logout(): Result<Unit> {
return Result.success(Unit)
}
}
In tests, you instantiate the fake and tell it what data to return:
@Test
fun userRepository_fetchUser_returnsUser() = runTest {
val fakeAuthRepository = FakeAuthRepository()
fakeAuthRepository.setLoginResponse(
Result.success(User("bob@example.com", "Bob"))
)
val userRepository = UserRepository(fakeAuthRepository)
val user = userRepository.getUser()
assertEquals("Bob", user.name)
}
Fakes shine when dependencies have complex interactions. A fake database, fake API client, or fake file system is far easier to understand in a test than a tangle of Mockito calls. They're also useful for instrumented tests and Compose UI tests, since they allow your test code to be consistent across layers.
Keep fakes simple and focused. Avoid duplicating production logic or building fakes so elaborate they become hard to maintain. A good fake captures just enough behavior for your test to run and verify the unit under test.
Instrumented tests — Room, navigation and integration on devices
Some tests must run on an actual Android device or emulator. Instrumented tests have access to the real Android framework, allowing you to test Room database operations, navigation flows, and integrated features that span multiple layers.
A Room database test verifies that data persists and queries work correctly:
@RunWith(AndroidJUnit4::class)
class UserDaoTest {
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).build()
userDao = database.userDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun insertUser_thenQueryUser_returnsUser() = runTest {
val user = User(id = 1, name = "Alice", email = "alice@example.com")
userDao.insert(user)
val retrieved = userDao.getUserById(1)
assertEquals("Alice", retrieved.name)
}
@Test
fun deleteUser_thenQueryUser_returnsNull() = runTest {
val user = User(id = 1, name = "Alice", email = "alice@example.com")
userDao.insert(user)
userDao.delete(user)
val retrieved = userDao.getUserById(1)
assertNull(retrieved)
}
}
Navigation tests verify that screens transition correctly. If you use Jetpack Navigation, test that clicking a button navigates to the expected destination:
@RunWith(AndroidJUnit4::class)
class NavigationTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun navigationFromLoginToHome_onSuccessfulLogin() {
composeTestRule.setContent {
AppNavHost()
}
composeTestRule.onNodeWithText("Email").performTextInput("test@example.com")
composeTestRule.onNodeWithText("Password").performTextInput("password")
composeTestRule.onNodeWithText("Login").performClick()
// Assert we're now on the home screen
composeTestRule.onNodeWithText("Home").assertIsDisplayed()
}
}
Instrumented tests are slower than JVM tests—often taking seconds per test—so run them less frequently, perhaps in CI before release. Use them for integration scenarios and device-specific behavior, not for simple logic.
Common mistakes
- Over-mocking. Mocking every dependency makes tests hard to read and changes to the system under test can slip through. Use fakes for clarity and mocks sparingly.
- Testing implementation instead of behavior. Don't assert that a mock was called with exact arguments. Assert the outcome: the state changed, the UI was updated, or the correct data was returned.
- Ignoring test isolation. Each test should be independent. Don't rely on test execution order or shared state between tests. Use
@Beforeand@Afterto set up and tear down fresh instances. - Testing framework details. Don't test that Android's
onCreateis called or that Compose recomposes. Test your code's logic and side effects. - Skipping instrumented tests. It's tempting to test everything on the JVM with mocks, but some bugs only surface on a real device. Strike a balance.
Key takeaways
- Unit tests on the JVM are fast and form the foundation of your test suite. Test ViewModels, use cases, and repositories with real logic and fakes.
- Compose UI tests let you verify user interactions and state transitions without a device, using
onNodeWithText()and similar queries. - Fakes are simpler and more readable than mocks. Build small, focused fake implementations that return canned data.
- Instrumented tests on a device or emulator are necessary for Room operations, navigation, and integrated features. Use them sparingly where the JVM isn't enough.
- A balanced testing strategy spans all layers: logic, UI, and integration. Aim for fast feedback in development and confidence in production.






Comments
Loading comments…