Testing and Performance for Android Apps

Android Development with Kotlin: 21 Real Projects · Advanced Apps and Release · 7 min read · Updated

The testing pyramid

  1. Most tests are small unit tests of plain Kotlin functions. They run in milliseconds.
  2. ViewModel tests check state changes with a test dispatcher.
  3. A few UI tests check that the screens work together on a device.
  4. When all pass, you can release with more confidence.

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/test and run on your computer's JVM with no phone. They are fast. Use them for pure functions and ViewModels.
  • Instrumented tests live in src/androidTest and 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 remember for 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

  1. Add and run the three test files in the matching projects.
  2. Add a test for parseCents from project 7.
  3. Break the tip logic on purpose and watch the test fail, then fix it.
  4. Open the Profiler while scrolling a long list and look for dropped frames.

How to check it worked

  • ./gradlew test finishes 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.

Frequently asked questions

How many tests should I write?

Cover the rules that would hurt if they broke: calculations, money, validation and state changes. Do not chase 100 percent coverage on trivial code.

Why do ViewModel tests need a dispatcher rule?

viewModelScope uses the Android main thread, which does not exist in a local test. The rule replaces it with a test dispatcher.

How do I find slow screens?

Use the Profiler and Layout Inspector in Android Studio. Look for work on the main thread and composables that recompose too often.