Apps that work on your phone can still fail on other phones. Tests are small programs that check your code for you, every time, in seconds. They let you change code without fear, and teams expect them. Android has two main kinds.
Two kinds of tests
- Local unit tests live in
src/testand run on your computer's JVM with no phone. They are fast. Use them for pure functions and ViewModels. - Instrumented tests live in
src/androidTestand run on a device or emulator. They are slower. Use them for Compose UI and for anything that needs Android itself.
Add the testing libraries to Gradle first.
`Gradle additions for tests`
// dependencies block: tests that run on your computer
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
// tests that run on an emulator or phone
androidTestImplementation(platform("androidx.compose:compose-bom:2024.12.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
Unit test of a pure function
The tip calculation from project 1 has no Android code, so it is easy to test. A good test has a clear name, a setup, an action and a check.
`app/src/test/java/com/example/tipcalc/TipMathTest.kt`
package com.example.tipcalc
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class TipMathTest {
@Test
fun tenPercentOfOneHundred() {
val r = calculateTip(bill = 100.0, tipPercent = 10, people = 1, roundUp = false)
assertEquals(10.0, r.tip, 0.001)
assertEquals(110.0, r.total, 0.001)
}
@Test
fun splitBetweenPeople() {
val r = calculateTip(bill = 90.0, tipPercent = 0, people = 3, roundUp = false)
assertEquals(30.0, r.perPerson, 0.001)
}
@Test
fun roundUpRaisesTheTip() {
val r = calculateTip(bill = 33.0, tipPercent = 15, people = 1, roundUp = true) // 4.95 becomes 5.0
assertEquals(5.0, r.tip, 0.001)
}
@Test
fun zeroPeopleIsRejected() {
assertThrows(IllegalArgumentException::class.java) { calculateTip(10.0, 10, 0, false) }
}
}
Testing a ViewModel with coroutines
ViewModels use viewModelScope, which runs on the main dispatcher, and unit tests have no main thread. The fix is a small JUnit rule that swaps the main dispatcher for a test dispatcher, so coroutines run instantly and in order.
`app/src/test/java/com/example/todo/MainDispatcherRule.kt`
package com.example.todo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
// ViewModels use Dispatchers.Main, which does not exist in plain unit tests. This rule swaps in a test one.
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}
`app/src/test/java/com/example/todo/TodoViewModelTest.kt`
package com.example.todo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class TodoViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test
fun addTrimsAndStoresTask() {
val vm = TodoViewModel()
vm.add(" Buy milk ")
assertEquals(listOf("Buy milk"), vm.tasks.value.map { it.title })
}
@Test
fun blankTaskIsIgnored() {
val vm = TodoViewModel()
vm.add(" ")
assertTrue(vm.tasks.value.isEmpty())
}
@Test
fun toggleAndClearDone() {
val vm = TodoViewModel()
vm.add("One"); vm.add("Two")
vm.toggle(vm.tasks.value.first().id)
vm.clearDone()
assertEquals(listOf("Two"), vm.tasks.value.map { it.title })
}
}
Testing a Compose screen
A Compose test finds elements on the screen by text or tag, performs actions and checks the results. It runs on a device or emulator.
`app/src/androidTest/java/com/example/tipcalc/TipScreenTest.kt`
package com.example.tipcalc
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput
import org.junit.Rule
import org.junit.Test
// Runs on an emulator or a phone (androidTest folder).
class TipScreenTest {
@get:Rule val rule = createComposeRule()
@Test
fun typingABillShowsTheTotal() {
rule.setContent { TipScreen() }
rule.onNodeWithText("Bill amount").performTextInput("100")
rule.onNodeWithText("Total: 115.00").assertIsDisplayed() // default tip is 15%
}
}
Run local tests with the green arrow next to the test class, or with ./gradlew test. Run instrumented tests with ./gradlew connectedAndroidTest while an emulator is open. Every test should pass before you ship.
Performance habits
- Never block the main thread. Network, database and file work belong in coroutines. Room and Retrofit suspend functions are already safe.
- Use LazyColumn with keys for long lists, and keep each row cheap.
- Do not do heavy work inside a composable. It can run many times. Use
rememberfor expensive values and move real work to the ViewModel. - Pass stable, small data to composables. Passing a new list on every pass makes Compose redraw more than needed.
- Load images at the size you show them. Coil does this for you.
- Measure, do not guess. Android Studio's Profiler shows CPU, memory and network use. Layout Inspector shows how often composables recompose.
Crash prevention
- Handle every network and database error with a visible state.
- Use null safety and avoid
!!. - Test with airplane mode, rotation, dark mode and a large font setting.
- Use Logcat to read crash traces. The first line that names your own package usually points at the cause.
- Once the app is live, use Android vitals in Play Console, or a crash reporting tool, to find crashes on real devices.
Your task
- Add and run the three test files in the matching projects.
- Add a test for
parseCentsfrom project 7. - Break the tip logic on purpose and watch the test fail, then fix it.
- Open the Profiler while scrolling a long list and look for dropped frames.
How to check it worked
./gradlew testfinishes with all tests passing.- You can say the difference between a unit test and an instrumented test.
- You can name three habits that keep an app smooth.