Android · Testing

Testing Android Apps: The Test Pyramid, JUnit, UI Automation and TDD

Khairul Alam·October 2026·~35 min read
TestingJUnitEspressoAppiumTDDAndroid

Every Android team says it wants tests. Fewer can say which tests to write, where they should run, and which of the many libraries to pick. This guide walks the whole map in order: the test pyramid, JUnit 4 and 5, the libraries that sit around JUnit, instrumentation and UI tests, Appium and other UI automation tools, how to keep UI tests from being flaky, and what test-driven development (TDD) really gives you. Every idea has a small Kotlin example.

The whole post in 60 seconds
  • Write many fast unit tests, some integration tests and few end-to-end UI tests. That shape is the test pyramid.
  • Unit tests run on your computer’s JVM (src/test). Instrumented tests run on a device or emulator (src/androidTest).
  • JUnit is the base. Around it you add an assertion library, a mocking library, coroutine and Flow helpers, and (when needed) Robolectric.
  • Espresso and Compose tests drive your own app’s UI. UI Automator and Appium drive the whole device, from the outside.
  • UI tests get flaky from animations, timing, shared state and real networks. Each cause has a known fix.
  • TDD (red, green, refactor) pushes you toward small, decoupled code. It costs time at first and does not suit every kind of work.
  • A starter stack and a four-step path are at the end.
Contents
  1. Why test at all
  2. The test pyramid
  3. Where Android tests live: local and instrumented
  4. Unit tests with JUnit 4 and 5
  5. The library map around JUnit
  6. Fakes, stubs and mocks
  7. Testing coroutines, Flow and ViewModels
  8. Instrumentation tests: Espresso, Compose and UI Automator
  9. UI automation and Appium
  10. Keeping UI tests reliable
  11. TDD: red, green, refactor
  12. A practical test strategy
  13. Common mistakes
  14. Takeaways, a starter path and glossary

1. Why test at all

A test is a small program that checks your program. You run it, it either passes or fails, and nobody has to look at the screen. That one property is why tests are worth the effort: they turn “I think this still works” into “I know this still works”, in seconds, every time you change something.

Good tests give you four things. They catch regressions, which are bugs in behaviour that used to work. They let you refactor with confidence, because a red test tells you immediately when a cleanup broke something. They act as executable documentation of what the code is meant to do. And they shorten code review, because a reviewer can read the tests to see the intent.

Analogy. Tests are the safety net under a trapeze artist. The artist does not perform better because of the net. They perform bolder, because a fall is no longer a disaster. Teams with good tests change old code that other teams are afraid to touch.

Tests are also code, and code has a cost. A slow, brittle test suite is worse than none, because people stop trusting it and start ignoring red builds. The goal is never “100% coverage”. The goal is the most confidence per minute of writing, running and maintaining.

2. The test pyramid

The test pyramid is a picture of how many tests to write at each level. Small, fast tests that check one thing go at the bottom, and there should be many of them. Big, slow tests that drive the whole app go at the top, and there should be few.

UI / E2E Integration Unit tests Few · slow · costly Appium, Espresso, Compose full flows Some · medium Room, MockWebServer, Hilt, Robolectric Many · fast · cheap JUnit, MockK, Truth, Turbine
The shape is a guide: push every check down to the lowest layer that can still catch the bug.
LayerWhat it checksSpeedCost to maintainFlaky?
UnitOne class or function, with its collaborators replaced by fakesMillisecondsLowAlmost never
IntegrationSeveral real pieces together: a repository with a real database, a service with a fake serverSecondsMediumSometimes
UI / end-to-endThe app on a device, driven like a user, from tap to resultTens of seconds to minutesHighOften, unless managed

Why a pyramid and not a rectangle

A common rule of thumb, often credited to Google’s testing guidance, is roughly 70% small, 20% medium and 10% large tests. Treat it as a starting point, not a quota.

Analogy. Building a car: you bench-test each part first (unit), then check that the engine and gearbox work together (integration), and only then take it for a test drive (end to end). If the engine cannot be bench-tested, every fault turns into a long drive with a mechanic.

The shapes to avoid and the other shapes

3. Where Android tests live: local and instrumented

Android splits tests into two homes, and picking the right one is the first real decision.

Local testsInstrumented tests
Foldersrc/testsrc/androidTest
Runs onThe JVM on your computer or CI machineA real device or an emulator
SpeedVery fast, no install stepSlow: build, install, boot or connect a device
Android frameworkNot real. Calls hit stubs (or Robolectric supplies a simulation)The real framework, real UI, real database
RunnerJUnitAndroidJUnitRunner
Gradle task./gradlew testDebugUnitTest./gradlew connectedDebugAndroidTest
Best forBusiness logic, ViewModels, mappers, validators, repositories with fakesUI, navigation, database, permissions, anything that needs the real OS

Google also names tests by size: small (one class, no I/O, runs anywhere), medium (a few components together) and large (the whole app, end to end). The annotations @SmallTest, @MediumTest and @LargeTest from androidx.test.filters let you run a size on its own. They are the pyramid’s three layers with different names.

The “not mocked” error

Local tests compile against a stripped android.jar whose methods throw. If your logic calls Log.d(...) or TextUtils.isEmpty(...), the test fails with “Method ... not mocked”. You have three ways out:

  1. Keep Android out of your logic. This is the best fix: use plain Kotlin types in ViewModels, use cases and mappers, and wrap the framework behind an interface.
  2. Use Robolectric, which supplies a working simulation of the framework on the JVM.
  3. Move the test to androidTest and run it on a device.

The shortcut testOptions { unitTests.isReturnDefaultValues = true } makes the stubs return zero or null instead of throwing. It hides the problem rather than fixing it, so use it only as a stop-gap.

4. Unit tests with JUnit 4 and 5

JUnit is the standard test framework for Java and Kotlin. It finds methods marked @Test, runs each one, and reports pass or fail. Every other tool in this post builds on it or plugs into it.

The anatomy of a test: Arrange, Act, Assert

Every good unit test has three steps. Arrange the objects and inputs. Act by calling the one thing under test. Assert that the result is what you expected. The same idea is called Given, When, Then in behaviour-driven development.

Our running example is a small price rule: orders of 5,000 or more get 10% off.

class DiscountCalculator {
    fun priceAfterDiscount(price: Int): Int {
        require(price >= 0) { "price must not be negative" }
        return if (price >= 5_000) price - price / 10 else price
    }
}

JUnit 4

JUnit 4 (current release 4.13.2) is the default on Android and the base for AndroidX Test.

// build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13.2")
}

// src/test/.../DiscountCalculatorTest.kt
class DiscountCalculatorTest {

    private lateinit var calculator: DiscountCalculator

    @Before
    fun setUp() {
        calculator = DiscountCalculator()          // Arrange (runs before every test)
    }

    @Test
    fun `no discount below the threshold`() {
        assertEquals(4_000, calculator.priceAfterDiscount(4_000))
    }

    @Test
    fun `10 percent off at 5000 or more`() {
        assertEquals(9_000, calculator.priceAfterDiscount(10_000))
    }

    @Test(expected = IllegalArgumentException::class)
    fun `negative price is rejected`() {
        calculator.priceAfterDiscount(-1)
    }
}

Kotlin lets you name a test with spaces by using backticks, which makes failure reports read like sentences. This works well in local tests. Older Android versions cannot load method names with spaces in instrumented tests, so use camelCase there.

JUnit 4 annotationWhen it runs or what it does
@TestMarks a test method. expected = and timeout = are options.
@Before / @AfterBefore and after every test. Use for setup and cleanup.
@BeforeClass / @AfterClassOnce for the whole class (static, so @JvmStatic in Kotlin).
@IgnoreSkips a test. Leave a reason, and do not let skipped tests pile up.
@RuleReusable setup and teardown: TemporaryFolder, TestWatcher, InstantTaskExecutorRule, ActivityScenarioRule.
@RunWithChooses a different runner: AndroidJUnit4, Parameterized, Suite.
@CategoryGroups tests so you can run a subset.

JUnit 5 (Jupiter)

JUnit 5 is a rewrite with a cleaner API and a more flexible extension model. Its pieces are Jupiter (the new API and engine), Vintage (runs old JUnit 3 and 4 tests on the new platform) and the Platform itself. Local tests can use it directly with one Gradle line.

// build.gradle.kts
android {
    testOptions {
        unitTests.all { it.useJUnitPlatform() }     // run local tests on JUnit 5
    }
}
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:<version>")
    // Optional: keep old JUnit 4 tests running in the same module
    testRuntimeOnly("org.junit.vintage:junit-vintage-engine:<version>")
}

// src/test/.../DiscountCalculatorTest.kt
class DiscountCalculatorTest {

    private val calculator = DiscountCalculator()   // a new instance is created for every test

    @Test
    @DisplayName("10% off at 5,000 or more")
    fun tenPercentOff() {
        assertEquals(9_000, calculator.priceAfterDiscount(10_000))
    }

    @ParameterizedTest(name = "{0} becomes {1}")
    @CsvSource("0,0", "4999,4999", "5000,4500", "10000,9000")
    fun `discount table`(price: Int, expected: Int) {
        assertEquals(expected, calculator.priceAfterDiscount(price))
    }

    @Test
    fun `negative price is rejected`() {
        assertThrows<IllegalArgumentException> { calculator.priceAfterDiscount(-1) }
    }
}

The <version> placeholder stands for the latest release shown on the project’s page (each library is linked from the tables in section 5). Library versions change often, so take them from the source rather than from a blog post.

JUnit 4JUnit 5 (Jupiter)
Setup / teardown@Before, @After@BeforeEach, @AfterEach, @BeforeAll, @AfterAll
ExtendingRunners and rulesOne model: @ExtendWith
Parameterised testsSeparate Parameterized runnerBuilt in: @ParameterizedTest with @ValueSource, @CsvSource, @MethodSource
Grouping@Category@Tag, plus @Nested classes
Other featuresBasic@DisplayName, @RepeatedTest, @TestFactory (dynamic tests), assertAll, assertTimeout
Local tests on AndroidWorks out of the boxWorks with useJUnitPlatform()
Instrumented tests on AndroidNative: AndroidX Test is built on JUnit 4Needs a community plugin, android-junit5 (by mannodermaus)
Which should you pick?

Most Android projects use JUnit 4 because every AndroidX Test tool is built on it. Pure-JVM modules (domain and data layers) are a natural place for JUnit 5. Using both in one project is fine: Vintage runs the old tests, Jupiter runs the new ones. Do not switch an existing project over for its own sake.

Assertions

JUnit ships with basic assertions: assertEquals, assertTrue, assertFalse, assertNull, assertNotNull, assertSame, assertArrayEquals and assertThrows. They are enough to start. Dedicated assertion libraries (next section) give you more readable failure messages and richer checks on lists, maps and objects.

5. The library map around JUnit

JUnit runs tests. Everything else (comparing values nicely, replacing collaborators, handling coroutines, simulating Android, driving the UI) comes from libraries around it. This is the map, grouped by job. Pick one from most groups; you do not need them all. Every library name below links to its official page, which is also where you will find the current version.

A. Test frameworks and runners

LibraryArtifact or pluginWhat it is
JUnit 4junit:junitThe default on Android. Rules, runners, the base for AndroidX Test.
JUnit 5 Jupiterorg.junit.jupiter:junit-jupiterNew API and engine. Add junit-jupiter-params for parameterised tests (the aggregate artifact includes it).
JUnit Vintageorg.junit.vintage:junit-vintage-engineRuns JUnit 3 and 4 tests on the JUnit 5 platform.
android-junit5Gradle plugin de.mannodermaus.android-junit5JUnit 5 for instrumented tests.
Kotestio.kotest:kotest-runner-junit5A Kotlin-first framework with many test styles (describe/it, should, behaviour spec), data-driven tests, its own assertions and property testing.
kotlin.testorg.jetbrains.kotlin:kotlin-testKotlin’s own small assertion API (assertEquals, assertFailsWith) that works on top of JUnit and is also used in multiplatform code.
TestNGorg.testng:testngAn older alternative to JUnit. Rare on Android.

B. Assertion libraries

LibraryArtifactStyle
Google Truthcom.google.truth:truthassertThat(list).containsExactly("a", "b"). Very readable failure messages. The usual choice in Google and AndroidX code.
AssertJorg.assertj:assertj-coreFluent assertThat(x).isEqualTo(y) with a huge set of checks.
Hamcrestorg.hamcrest:hamcrestMatchers: assertThat(x, equalTo(y)). Used by Espresso’s view matchers.
Kluentorg.amshove.kluent:kluentKotlin infix style: x shouldBeEqualTo y.
Striktio.strikt:strikt-coreKotlin assertions: expectThat(x).isEqualTo(y), with chained property checks.
Kotest assertionsio.kotest:kotest-assertions-corex shouldBe y, usable without the Kotest runner.

C. Mocking and test doubles

LibraryArtifactNotes
MockKio.mockk:mockk and mockk-androidMocking written for Kotlin. Handles final classes, suspend functions (coEvery, coVerify), objects and extension functions. The usual choice for Kotlin projects.
Mockitoorg.mockito:mockito-core and mockito-androidThe long-standing Java standard. From Mockito 5 the inline mock maker is the default, so Kotlin’s final classes can be mocked.
mockito-kotlinorg.mockito.kotlin:mockito-kotlinKotlin-friendly helpers on top of Mockito (mock<T>(), whenever, argumentCaptor).
PowerMock—Legacy way to mock statics and finals. Avoid it in new code; MockK and Mockito 5 cover the need.
Hand-written fakesnoneA small in-memory class that implements your interface. Often the best double (see section 6).

D. Coroutines, Flow and architecture components

LibraryArtifactUse
kotlinx-coroutines-testorg.jetbrains.kotlinx:kotlinx-coroutines-testrunTest, TestScope, StandardTestDispatcher, UnconfinedTestDispatcher, Dispatchers.setMain. Delays run in virtual time, so a 10-second wait costs nothing.
Turbineapp.cash.turbine:turbineTest a Flow by awaiting its items one at a time: flow.test { awaitItem() }.
Arch core testingandroidx.arch.core:core-testingInstantTaskExecutorRule makes LiveData run immediately in tests.
Lifecycle testingandroidx.lifecycle:lifecycle-runtime-testingTestLifecycleOwner to move a lifecycle through its states.
WorkManager testingandroidx.work:work-testingRun workers synchronously and control constraints.
Navigation testingandroidx.navigation:navigation-testingTestNavHostController to check destinations.

E. Android on the JVM

LibraryArtifactUse
Robolectricorg.robolectric:robolectricRuns the Android framework on the JVM, so activities, views, resources and many system services work in a fast local test. Also runs Compose UI tests.
AndroidX Test coreandroidx.test:core, androidx.test.ext:junitApplicationProvider, ActivityScenario, AndroidJUnit4. The same test can run on Robolectric or on a device.

F. Network, database and dependency injection

LibraryArtifactUse
MockWebServercom.squareup.okhttp3:mockwebserverA real local HTTP server that returns scripted responses. Tests your real Retrofit or OkHttp stack end to end, including errors and timeouts.
Retrofit Mockcom.squareup.retrofit2:retrofit-mockFake Retrofit services with simulated delay and failure.
Ktor MockEngineio.ktor:ktor-client-mockThe same idea for Ktor clients.
WireMockorg.wiremock:wiremockA heavier, more featureful HTTP stub server.
Room testingandroidx.room:room-testingMigration testing, plus in-memory databases for DAO tests.
Hilt testingcom.google.dagger:hilt-android-testing@HiltAndroidTest, @TestInstallIn to swap real modules for fakes.
Koin testio.insert-koin:koin-test (and the -junit4 / -junit5 modules)Check your module graph and swap definitions.

G. UI and instrumented testing

LibraryArtifactUse
Espressoandroidx.test.espresso:espresso-coreDrives your own app’s View UI. Add espresso-contrib (RecyclerView, drawers), espresso-intents (intent checks), espresso-idling-resource, espresso-web.
Compose UI testandroidx.compose.ui:ui-test-junit4 and ui-test-manifestFinds and drives Compose nodes through the semantics tree.
UI Automatorandroidx.test.uiautomator:uiautomatorDrives the whole device, including other apps and system UI.
AndroidX Test runner and rulesandroidx.test:runner, androidx.test:rulesAndroidJUnitRunner, GrantPermissionRule, ServiceTestRule.
Test Orchestratorandroidx.test:orchestratorRuns each test in its own process so one crash or leaked state cannot spoil the next.
Fragment testingandroidx.fragment:fragment-testinglaunchFragmentInContainer to test a fragment on its own.
Barista, Kakao, Kaspressocommunity librariesFriendlier DSLs on top of Espresso. Kaspresso (from Kaspersky) also adds retries, logging and screenshots.
Accessibility checksEspresso AccessibilityChecks, Compose accessibility checksFail the test when a touch target is too small or a label is missing.

H. Screenshot (snapshot) testing

LibraryPlugin or artifactNotes
Paparazziapp.cash.paparazziRenders views and composables on the JVM without a device, then compares to saved images. Fast.
Roborazziio.github.takahirom.roborazziScreenshot tests on Robolectric.
Shotcom.karumi.shotScreenshot tests on a device, with a Gradle workflow to record and verify.
Compose Preview Screenshot TestingAndroid Gradle plugin toolingGoogle’s own approach, which turns @Preview composables into screenshot tests. Still evolving, so check the current docs.

I. Coverage, property tests, BDD and contracts

LibraryArtifact or pluginUse
JaCoCoGradle jacoco pluginCoverage reports for JVM and Android code.
Koverorg.jetbrains.kotlinx.koverJetBrains’ Kotlin-aware coverage tool.
jqwik, Kotest propertynet.jqwik:jqwik, kotest-propertyProperty-based testing: state a rule, and the library tries hundreds of generated inputs.
PIT (Pitest)Gradle PIT pluginMutation testing: it changes your code on purpose and checks that a test fails.
Cucumber Androidio.cucumber:cucumber-androidBehaviour-driven tests written in plain-language Gherkin.
Pactau.com.dius.pactContract tests between the app and its backend.

J. Devices and clouds

OptionWhat it gives you
Android Emulator and Gradle managed devicesEmulators created and torn down by Gradle itself, so the setup is the same on every machine.
Firebase Test LabReal and virtual devices in Google’s data centres, with videos and logs.
AWS Device Farm, BrowserStack, Sauce LabsLarge pools of real devices you can run Espresso or Appium tests on.
A sensible starter stack
  • Local: JUnit 4 (or 5 for pure modules), MockK, Truth (or Kluent), kotlinx-coroutines-test, Turbine.
  • Integration: MockWebServer, an in-memory Room database, Hilt testing. Add Robolectric only when you need the framework on the JVM.
  • UI: Compose UI test or Espresso for your own screens, a few flows.
  • Visual: Paparazzi or Roborazzi for screenshots.
  • Quality: Kover or JaCoCo for coverage.

6. Fakes, stubs and mocks

A unit test should check one class, so everything that class depends on (a database, a server, the clock) is replaced with a stand-in. The stand-ins are called test doubles, and there are five kinds.

DoubleWhat it does
DummyFills a parameter that the test never uses.
StubReturns fixed answers: “when asked for user 7, return Ana”.
FakeA working but simple implementation, such as a repository backed by a list in memory.
SpyWraps a real object and records how it was called.
MockRecords calls and lets you verify them: “the repository was asked to save exactly once”.

Here is the same login test written both ways. The service under test is the interface UserRepository.

interface UserRepository {
    suspend fun login(email: String, password: String): Result<User>
}

// 1) A fake: a tiny real implementation. Reusable across many tests.
class FakeUserRepository : UserRepository {
    var failWith: Throwable? = null
    override suspend fun login(email: String, password: String): Result<User> =
        failWith?.let { Result.failure(it) } ?: Result.success(User(email))
}

// 2) A mock: a recorded stand-in, scripted per test.
val repo = mockk<UserRepository>()
coEvery { repo.login("a@b.com", "bad") } returns Result.failure(IllegalArgumentException())
// ... run the code ...
coVerify(exactly = 1) { repo.login("a@b.com", "bad") }
Habits that keep doubles healthy
  • Prefer fakes for things with state (repositories, data sources). They survive refactors because they test behaviour, not call order.
  • Use mocks at the edges, when the interaction itself is the thing you care about (an analytics event was sent).
  • Do not mock what you do not own. Wrap the third-party class behind your own interface, and double that.
  • Do not mock simple data classes. Create real ones.
  • Inject dependencies through the constructor. A class that builds its own collaborators cannot be tested in isolation.

7. Testing coroutines, Flow and ViewModels

Most Android logic is asynchronous, and asynchronous tests are where beginners get stuck. The fix is to control time and threads. kotlinx-coroutines-test gives you runTest, which runs a coroutine on a virtual clock: a delay(5_000) completes instantly, and the test is deterministic.

Replace the Main dispatcher

A ViewModel launches work on Dispatchers.Main, which does not exist in a local test. Replace it with a test dispatcher using a rule you write once:

class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
    override fun finished(description: Description) = Dispatchers.resetMain()
}

A ViewModel test

data class LoginState(
    val loading: Boolean = false,
    val loggedIn: Boolean = false,
    val error: String? = null
)

class LoginViewModel(private val repo: UserRepository) : ViewModel() {
    private val _state = MutableStateFlow(LoginState())
    val state: StateFlow<LoginState> = _state.asStateFlow()

    fun onLoginClicked(email: String, password: String) {
        viewModelScope.launch {
            _state.update { it.copy(loading = true, error = null) }
            repo.login(email, password)
                .onSuccess { _state.update { s -> s.copy(loading = false, loggedIn = true) } }
                .onFailure { _state.update { s -> s.copy(loading = false, error = "Wrong email or password") } }
        }
    }
}

class LoginViewModelTest {

    @get:Rule val mainRule = MainDispatcherRule()

    private val repo = FakeUserRepository()

    @Test
    fun `wrong password shows an error and stops loading`() = runTest {
        repo.failWith = IllegalArgumentException()
        val viewModel = LoginViewModel(repo)

        viewModel.onLoginClicked("a@b.com", "bad")

        val state = viewModel.state.value
        assertEquals("Wrong email or password", state.error)
        assertFalse(state.loading)
        assertFalse(state.loggedIn)
    }
}

Testing a Flow with Turbine

A Flow emits several values over time. Turbine collects them into a queue and lets you read them in order, with a timeout so a test cannot hang forever.

@Test
fun `emits loading, then the result, then completes`() = runTest {
    val flow = flow { emit("loading"); delay(100); emit("done") }

    flow.test {
        assertEquals("loading", awaitItem())
        assertEquals("done", awaitItem())
        awaitComplete()
    }
}

Two traps. A StateFlow is conflated: if it changes twice before you collect, you see only the last value, so check the final state or use an unconfined dispatcher when you need every step. And LiveData needs InstantTaskExecutorRule in local tests, otherwise it throws because it expects the Android main looper.

Pass the dispatcher into your classes (class Repo(private val io: CoroutineDispatcher)) rather than hard-coding Dispatchers.IO. A test can then supply its own and run everything on one thread.

8. Instrumentation tests: Espresso, Compose and UI Automator

An instrumentation test runs on a real device or emulator. Gradle builds your app and a second “test APK”, installs both, and starts AndroidJUnitRunner. The test process runs in the same process as your app, so it can reach into your code, start activities and read the live UI.

// build.gradle.kts
android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        testInstrumentationRunnerArguments["clearPackageData"] = "true"   // wipe app data between tests (with the orchestrator)
    }
    testOptions {
        animationsDisabled = true                          // no animations while testing
        execution = "ANDROIDX_TEST_ORCHESTRATOR"           // each test in a fresh process
    }
}
dependencies {
    androidTestImplementation("androidx.test.ext:junit:<version>")
    androidTestImplementation("androidx.test.espresso:espresso-core:<version>")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:<version>")
    debugImplementation("androidx.compose.ui:ui-test-manifest:<version>")
    androidTestUtil("androidx.test:orchestrator:<version>")
}

Espresso (Views)

Espresso’s trick is built-in synchronisation. Before each action it waits until the main thread is idle and background work it knows about (AsyncTask, its own idling resources) is finished. That is why Espresso tests are far more stable than tests that sleep. A test has three parts: find a view (onView(withId(...))), act on it (perform(click())), assert (check(matches(isDisplayed()))).

@RunWith(AndroidJUnit4::class)
class LoginScreenTest {

    @get:Rule val rule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun wrongPasswordShowsError() {
        onView(withId(R.id.email)).perform(typeText("a@b.com"), closeSoftKeyboard())
        onView(withId(R.id.password)).perform(typeText("bad"), closeSoftKeyboard())
        onView(withId(R.id.loginButton)).perform(click())

        onView(withText("Wrong email or password")).check(matches(isDisplayed()))
    }
}

Compose UI tests

Compose has no Views, so tests use the semantics tree: the description of the UI that accessibility services also read. You find nodes, perform actions and make assertions. Compose’s test framework synchronises with recomposition automatically.

class LoginScreenComposeTest {

    @get:Rule val rule = createAndroidComposeRule<MainActivity>()

    @Test
    fun wrongPasswordShowsError() {
        rule.onNodeWithTag("email").performTextInput("a@b.com")
        rule.onNodeWithTag("password").performTextInput("bad")
        rule.onNodeWithText("Log in").performClick()

        rule.onNodeWithText("Wrong email or password").assertIsDisplayed()
    }
}

To test one composable on its own, use createComposeRule() and call rule.setContent { LoginScreen(...) }. That is faster than launching the whole activity, and it can run on Robolectric as well. Add Modifier.testTag("email") to nodes that have no unique visible text. For waits that are not tied to recomposition, use rule.waitUntil { ... }.

UI Automator

Espresso and Compose tests can see only your app. UI Automator sees the whole screen, so it can press Home, open the notification shade, grant a system permission dialog, or switch between apps.

val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.openNotification()
device.wait(Until.hasObject(By.text("Order shipped")), 5_000)
device.findObject(By.text("Order shipped")).click()

Choosing between them

ToolSeesRunsBest for
EspressoYour app’s ViewsDevice or emulator (or Robolectric)Screens and flows in a View-based app
Compose UI testYour app’s composablesDevice, emulator or RobolectricScreens and flows in a Compose app
UI AutomatorAny app and the system UIDevice or emulatorPermissions, notifications, deep links, multi-app flows
RobolectricYour app, simulatedJVM, no deviceFast UI and framework tests where full fidelity is not needed

These tools combine well: a Compose test for the screen, UI Automator for the permission dialog that appears on first launch.

9. UI automation and Appium

UI automation means a program drives the app’s user interface the way a person would: tap, type, scroll, read the screen. Espresso is UI automation from the inside. Appium is UI automation from the outside.

Appium is an open-source tool that exposes a standard web-automation protocol (the W3C WebDriver protocol, the same one Selenium uses) for mobile apps. You write a script in almost any language, Appium translates each command into a call to a platform driver, and the driver operates the device. It sees the app as a black box, with no access to your source code.

Test scriptKotlin, Java, Python Appium serverNode.js, port 4723 DriverUiAutomator2 Deviceapp under test HTTPcommandADB One protocol on the left, a different driver on the right for each platform.
Appium separates what you write (a script) from how it is carried out (a driver). Swap the driver and the same ideas work on iOS.

What makes Appium different

Set it up

# You need Node.js, a JDK and the Android SDK (ANDROID_HOME set)
npm install -g appium
appium driver install uiautomator2
appium driver list --installed
appium                       # starts the server at http://127.0.0.1:4723

Write a test (Kotlin, JUnit and the Java client)

dependencies {
    testImplementation("io.appium:java-client:<version>")
    testImplementation("org.seleniumhq.selenium:selenium-java:<version>")
}

class LoginAppiumTest {

    private lateinit var driver: AndroidDriver

    @Before
    fun start() {
        val options = UiAutomator2Options()
            .setDeviceName("Android Emulator")
            .setApp("/path/to/app-debug.apk")
        driver = AndroidDriver(URI("http://127.0.0.1:4723").toURL(), options)
    }

    @Test
    fun wrongPasswordShowsError() {
        driver.findElement(AppiumBy.accessibilityId("email")).sendKeys("a@b.com")
        driver.findElement(AppiumBy.accessibilityId("password")).sendKeys("bad")
        driver.findElement(AppiumBy.accessibilityId("loginButton")).click()

        val error = WebDriverWait(driver, Duration.ofSeconds(5)).until(
            ExpectedConditions.visibilityOfElementLocated(AppiumBy.accessibilityId("error"))
        )
        assertEquals("Wrong email or password", error.text)
    }

    @After
    fun stop() = driver.quit()
}

Locators: how Appium finds an element

LocatorExampleVerdict
Accessibility idAppiumBy.accessibilityId("loginButton")Best. Uses the content description, so it also improves accessibility, and it works across platforms.
Resource idAppiumBy.id("com.example:id/email")Good for Views. Stable as long as ids are not renamed.
UiSelectorAppiumBy.androidUIAutomator("new UiSelector().text(\"Log in\")")Powerful, Android-only. Text-based selectors break when copy or language changes.
XPathAppiumBy.xpath("//*[@text='Log in']")Last resort. Slow and brittle.

Compose tip. By default, Appium cannot see a composable’s testTag. Turn it into a resource id once, near the root of your UI, and AppiumBy.id("email") will find Modifier.testTag("email"):

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AppRoot() {
    Box(Modifier.semantics { testTagsAsResourceId = true }) {
        // your screens
    }
}

Appium or Espresso?

Espresso / Compose testAppium
PositionInside the app processOutside, over the network
Speed and stabilityFaster. Automatic synchronisation with the UI threadSlower. You write explicit waits
LanguageKotlin or Java onlyAlmost any
PlatformsAndroid onlyAndroid, iOS and more, with shared test code
Access to app codeYes: fakes, DI, internal stateNo: only what the user sees
Who writes itDevelopersDevelopers or a separate QA team
Best forMany screen-level tests that run on every pull requestA small set of release-level journeys, or one test suite for two platforms

Neither replaces the other. A common split is Espresso or Compose tests for most UI checks, and a short Appium suite for the handful of flows the business cannot afford to break (sign-up, purchase, login).

Other UI automation tools

ToolNotes
MaestroFlows written in simple YAML, with built-in waiting. Android, iOS and web. Very quick to start. Example below.
DetoxGray-box end-to-end testing for React Native.
Kaspresso, Kakao, BaristaWrappers that make Espresso code shorter and add retries or screenshots.
Katalon, TestComplete and similarCommercial suites with record-and-replay and reporting.
Robotium, Calabash, SelendroidOlder tools. Largely replaced by Espresso, UI Automator and Appium.
# login.yaml  (run with: maestro test login.yaml)
appId: com.example.app
---
- launchApp
- tapOn: "Email"
- inputText: "a@b.com"
- tapOn: "Password"
- inputText: "bad"
- tapOn: "Log in"
- assertVisible: "Wrong email or password"

10. Keeping UI tests reliable

A flaky test passes and fails on the same code. Flaky tests are poison: a red build that people rerun until it turns green teaches them to ignore red. Almost every flaky UI test comes from one of a few causes.

CauseFix
Animations and transitionsTurn them off for tests: testOptions.animationsDisabled = true, or set the three developer-option animation scales to 0 on CI devices.
Timing: acting before the UI is readyUse the framework’s sync (Espresso, Compose), an IdlingResource for custom async work, or an explicit wait in Appium. Never use Thread.sleep.
Shared state between testsUse Test Orchestrator with clearPackageData, create fresh data per test, and never let one test depend on another.
Real network and real backendsPoint the app at MockWebServer or a staging stub so responses are the same every run.
Test data and accountsCreate the data you need inside the test (an API call or a database insert), not by hand beforehand.
Slow or unhealthy emulatorsUse managed devices or a fixed image, enough RAM, hardware acceleration, and a cold start per job.
Screen size and OS differencesPick locators that do not depend on position. Run on a small matrix, not on every device.

The robot pattern

If the test file knows every id and tag, one UI change breaks fifty tests. Put those details in a small helper class (a robot or page object) so tests read like the story and a UI change is fixed in one place.

class LoginRobot(private val rule: ComposeTestRule) {
    fun typeEmail(value: String) = apply { rule.onNodeWithTag("email").performTextInput(value) }
    fun typePassword(value: String) = apply { rule.onNodeWithTag("password").performTextInput(value) }
    fun tapLogin() = apply { rule.onNodeWithText("Log in").performClick() }
    fun assertError(message: String) = apply { rule.onNodeWithText(message).assertIsDisplayed() }
}

@Test
fun wrongPasswordShowsError() {
    LoginRobot(rule)
        .typeEmail("a@b.com")
        .typePassword("bad")
        .tapLogin()
        .assertError("Wrong email or password")
}

Run them in continuous integration

UI tests that no one runs are decoration. Run unit tests on every pull request, because they take seconds. Run a small UI suite on every pull request too if it stays under about ten minutes, and the full suite nightly or before release.

# .github/workflows/ui-tests.yml (excerpt)
- uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: 34
    arch: x86_64
    script: ./gradlew connectedDebugAndroidTest

The snippet uses the android-emulator-runner action. On Linux runners the emulator needs hardware acceleration (KVM) to be usable. Keep a rule for flaky tests: quarantine them quickly so they stop blocking others, fix them within a set time, and track how many reruns the pipeline performs. One automatic retry is a reasonable safety net. Three retries hide a real problem.

11. TDD: red, green, refactor

Test-driven development turns the usual order around. You write a failing test first, then write just enough code to make it pass, then clean up. You repeat this in very small steps, each a minute or two long.

1. RedWrite a failing test 2. GreenMake it pass, simply 3. RefactorClean up, stay green Repeat. Each lap adds one small behaviour.
The loop is short on purpose. If a step takes more than a few minutes, the step was too big.

Robert C. Martin (“Uncle Bob”) states it as three rules: do not write production code until you have a failing test; do not write more of a test than is enough to fail (a compile error counts); do not write more production code than is enough to pass the failing test.

A walk-through

We want the discount rule from section 4. We build it in three laps.

Lap 1, Red. Write the first test. It does not even compile, because DiscountCalculator does not exist yet. Create it with a body that fails.

@Test fun `10 percent off at 5000 or more`() {
    assertEquals(9_000, DiscountCalculator().priceAfterDiscount(10_000))
}

class DiscountCalculator {
    fun priceAfterDiscount(price: Int): Int = TODO()      // test is red
}

Lap 1, Green. Write the simplest code that passes. Not the final code, just the simplest.

fun priceAfterDiscount(price: Int): Int = price - price / 10      // green

Lap 2, Red. Add the next rule as a new test. Small orders must not get a discount.

@Test fun `no discount below the threshold`() {
    assertEquals(4_000, DiscountCalculator().priceAfterDiscount(4_000))   // red: returns 3,600
}

Lap 2, Green. The failing test forces the condition into existence.

fun priceAfterDiscount(price: Int): Int =
    if (price >= 5_000) price - price / 10 else price                    // green

Lap 3, Red and Green. Add a test for negative prices that expects IllegalArgumentException, then add require(price >= 0).

Refactor. With three green tests as a net, tidy the code: name the magic numbers and keep the tests passing.

class DiscountCalculator {
    private companion object {
        const val THRESHOLD = 5_000
        const val PERCENT_OFF = 10
    }

    fun priceAfterDiscount(price: Int): Int {
        require(price >= 0) { "price must not be negative" }
        return if (price >= THRESHOLD) price - price * PERCENT_OFF / 100 else price
    }
}

The benefits of TDD

BenefitWhy it happens
Better designCode that is hard to test is hard to use. Writing the test first forces small classes, clear interfaces and injected dependencies.
Fewer bugs reach later stagesEvery behaviour has a test before it exists, so gaps show up while you are still in the code.
A safety net for changeYou can refactor freely, because a broken behaviour turns a test red within seconds.
Tests as documentationTest names describe what the code does, and they cannot go out of date without failing.
Less over-buildingYou write only what the next failing test needs, which keeps unused features out.
Less time in the debuggerSteps are tiny, so when something breaks, the cause is in the last few lines.
Useful coverage by defaultEvery line exists because a test asked for it.
Confidence and focusOne small goal at a time lowers the stress of big changes.
Easier reviews and onboardingA new teammate can read the tests to learn what the code promises.

The costs, honestly

Where TDD works best on Android

Cousins of TDD

NameWhat it adds
ATDDAcceptance-test-driven development. The first failing test is a high-level acceptance test agreed with the product owner, and unit-level TDD fills in the details.
BDDBehaviour-driven development. Tests are written as Given, When, Then sentences in the language of the business, often with Cucumber or Kotest specs.
Test-afterWrite the code, then the test. It is better than no tests, but the design pressure is lost.
Classic vs mockist TDDThe “Chicago” school uses real collaborators and fakes. The “London” school mocks each collaborator and verifies interactions. Most Android teams lean classic.

For how testing fits into architecture (why MVVM and clean layers make tests cheap), see Android architecture, part 8: testing and proof.

12. A practical test strategy

Put each kind of check at the lowest layer that can catch it. A good first draft for a typical app looks like this:

What you are testingTest typeTypical tools
Business rules, validators, mappersLocal unitJUnit, Truth or Kluent
ViewModels, use cases, reducersLocal unitJUnit, MockK or fakes, coroutines-test, Turbine
Repository with its data sourcesLocal integrationFakes, MockWebServer, in-memory Room (Robolectric if needed)
DAO queries and migrationsInstrumented or RobolectricRoom testing, MigrationTestHelper
One screen’s behaviourUI test with a fake backendCompose UI test or Espresso, Hilt test modules
How a screen looks, in light and dark, large fontScreenshot testPaparazzi, Roborazzi or Shot
Permissions, notifications, other appsInstrumentedUI Automator
The 5 to 10 flows that must never breakEnd to endAppium or Maestro on release builds or a device cloud

Folder layout and naming

app/
  src/
    main/            production code
    test/            local tests (JVM)       <- most of your tests
    androidTest/     instrumented tests      <- fewer, slower
    debug/           test-only helpers such as the Compose test manifest

What to do with coverage

Coverage tools (JaCoCo, Kover) tell you which lines your tests ran, not whether they checked anything. Use the report to find untested code, especially on risky branches. Do not set “90% or the build fails” as a goal for everyone, because people then write assertion-free tests to hit the number. A better pairing is a modest coverage floor plus occasional mutation testing with PIT, which proves your tests actually fail when the code is wrong.

Other kinds of test worth knowing

13. Common mistakes

MistakeBetter
The ice-cream cone: lots of UI tests, few unit testsMove checks down. Test logic in ViewModels and use cases, and keep UI tests for flows.
Testing implementation, not behaviourAssert what the user or caller can observe. Do not verify private calls and call order unless they matter.
Mocking everythingUse real simple objects and fakes. Mock only the boundary.
Thread.sleep in UI testsUse idling resources, Compose sync, or explicit waits on a condition.
Tests that depend on each other or on orderMake each test set up and clean up its own state.
One giant test with twenty assertionsOne behaviour per test, so a failure points to a cause.
Ignoring or skipping failing testsFix or delete. A permanently skipped test is a lie in the suite.
Chasing a coverage numberMeasure risk covered, not lines touched.
Testing the frameworkDo not test that TextView.setText works. Test your own logic.
Production code that reads time, randomness or singletons directlyInject a clock, a random source and dependencies so tests control them.
Assertion-free testsA test that cannot fail proves nothing. Make it red once on purpose to check it can.

14. Takeaways, a starter path and glossary

A four-step path to start

  1. This week: write five JUnit tests for one plain Kotlin class, such as a validator or a mapper. Run them from Gradle.
  2. Next: test one ViewModel with a fake repository, runTest and a main dispatcher rule.
  3. Then: add one Compose or Espresso test for your app’s most important happy path, with a fake backend.
  4. Finally: run the unit tests on every pull request in CI, and the UI test on a schedule. Once that is steady, add screenshot tests and a short Appium or Maestro flow if you need them.
TermIn plain words
Unit testA fast test of one small piece of code in isolation.
Integration testA test of several real pieces working together.
End-to-end (E2E) testA test that drives the whole app from the user’s point of view.
Instrumented testA test that runs on a device or emulator, in the app’s process.
Test doubleAny stand-in used in place of a real dependency in a test.
FakeA simple working implementation, such as an in-memory repository.
MockA stand-in that records calls so you can verify them.
Flaky testA test that sometimes passes and sometimes fails on the same code.
Idling resourceA signal that tells Espresso the app is still busy and it should wait.
Semantics treeThe description of a Compose UI used by tests and accessibility services.
Robot / page objectA helper class that hides locators so tests read like a story.
WebDriverThe standard protocol for driving a UI from outside, used by Selenium and Appium.
TDDTest-driven development: write a failing test, make it pass, then refactor.
RegressionA bug in something that used to work.
CoverageThe share of code that tests ran. It does not prove the tests check anything.
Mutation testingChanging the code on purpose to see whether a test notices.
Snapshot / screenshot testA test that compares a rendered screen to a saved image.
← Back to all writing