androidinterview.com

Android Testing Interview Questions

15 questions

Tier
Difficulty
Level

Showing all 15 questions

Test Automation & Strategy

How do you write Compose UI tests?

Tier: EssentialDifficulty: Medium

You add a compose test rule, set the composable as content, then find a node, act on it, and assert on the result. There is no view hierarchy to query, so every test goes through the semantics tree instead, which is the parallel tree Compose builds to describe what the UI means rather than how it looks.

That semantics tree is the whole mental model. Accessibility services read it, and the test framework reads the same thing, so a screen that is easy to test is usually a screen that is easy to use with TalkBack.

The two rules

  • createComposeRule() hosts your composable in a bare activity that the test framework supplies. Use it whenever you are testing a composable in isolation, which should be most of the time, because there is no real activity to launch and no navigation graph in the way.
  • createAndroidComposeRule<MyActivity>() launches a real activity, so you get the activity, its intent extras, and anything the activity itself sets up. Reach for it only when the test genuinely needs the activity, for example a flow that crosses into a fragment or reads a deep link.
  • setContent is where you install the UI, and it is called once per test. Wrap the composable in your theme, because a missing theme changes colours and sizes and makes screenshot comparisons meaningless.

Finders, assertions and actions

  • Finders. onNodeWithText, onNodeWithTag, onNodeWithContentDescription, and onAllNodesWithTag when you expect several. onNode(matcher) composes matchers with and and or when a single attribute is not specific enough.
  • Assertions. assertIsDisplayed, assertIsEnabled, assertIsSelected, assertTextEquals, assertCountEquals on a collection, and assertDoesNotExist for the negative case, which is the one people forget.
  • Actions. performClick, performTextInput, performTextClearance, performScrollTo, performScrollToIndex on a lazy list, and performSemanticsAction for anything custom.

testTag versus content description

  • Content description first, when the element needs one anyway. An icon button, an image that carries meaning. Finding it by content description tests the accessibility label at the same time, so one line does two jobs.
  • Text next, for anything a user actually reads. Finding a button by its label is the closest a test gets to describing what a person sees, and it fails honestly when the copy changes in a way that matters.
  • Modifier.testTag("...") last, as an escape hatch. Use it for a container, a list, a chart, anything with no natural label, or when the visible text is dynamic and the test would be asserting on data rather than structure. A tag is invisible to users, so it proves nothing about accessibility, and a screen tagged everywhere is a screen nobody checked with TalkBack.

Synchronisation

  • Compose tests wait for idle automatically. Before every assertion and action, the rule drains the compose clock and the recomposition queue, so you do not need a sleep. This is why a well written Compose test is far less flaky than the equivalent view test.
  • The waiting only covers work Compose knows about. A coroutine on a background dispatcher, a real network call, an animation driven by something outside the composition, none of that is in the idling contract, which is exactly the same trap Espresso has with idling resources, described in what is Espresso.
  • waitUntil is the tool for asynchronous state. It polls a condition with a timeout, so you can wait for a node to appear after a fake repository resolves. Give it a real condition to check, never a bare timeout, and keep the timeout tight so a genuine failure fails fast.
  • mainClock lets you pause and advance the compose clock by hand, which is how you assert on a specific frame of an animation instead of waiting for it to settle.
class LoginScreenTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun `an invalid email shows the error and keeps submit disabled`() {
        val viewModel = LoginViewModel(FakeAuthRepository())
        rule.setContent { AppTheme { LoginScreen(viewModel) } }

        rule.onNodeWithText("Email").performTextInput("not-an-email")
        rule.onNodeWithTag("submit").performClick()

        // Waits for the fake to resolve rather than sleeping for a fixed time.
        rule.waitUntil(timeoutMillis = 2_000) {
            rule.onAllNodesWithText("Enter a valid email")
                .fetchSemanticsNodes().isNotEmpty()
        }
        rule.onNodeWithTag("submit").assertIsNotEnabled()

        // When a finder returns nothing, print the tree and look at it.
        rule.onRoot(useUnmergedTree = true).printToLog("LoginScreenTest")
    }
}

Wiring up the dependencies

  • A fake ViewModel or plain state is the default. Construct the real ViewModel with fake repositories, or better, make the composable take a state object and a lambda so the test can pass state directly and assert on what the lambda received. That version needs no ViewModel at all.
  • A Hilt test when the graph really is the point. Annotate with @HiltAndroidTest, add HiltAndroidRule at order = 0 and the compose rule at order = 1, run through a custom runner that installs HiltTestApplication, and swap bindings with @BindValue or a @TestInstallIn module. Rule ordering is the part people get wrong, Hilt has to inject before the activity starts.

The unmerged tree trap

By default the test framework reads the merged semantics tree, where a Button containing an icon and a label collapses into one node carrying the merged properties. That is usually what you want, onNodeWithText("Like").performClick() finds the button rather than the text inside it. It is also why a finder aimed at a child fails with a node not found error even though you can see the thing on screen. Passing useUnmergedTree = true keeps every node separate so you can reach the child, and the honest first move whenever a finder misses is onRoot(useUnmergedTree = true).printToLog(tag), which dumps the whole tree to logcat with every property on every node.

In the room, say semantics tree in the first sentence, because that is the word that separates someone who has written these tests from someone who has read about them. Then name the rule you use and why, describe find, act, assert in one breath, and finish on synchronisation, that Compose waits for idle for you but only for work inside the composition, so anything asynchronous outside it needs waitUntil.

Read more Test your Compose layout (opens in a new tab)Testing cheatsheet (opens in a new tab)Semantics (opens in a new tab)

What is your testing strategy for an Android app?

Tier: EssentialDifficulty: Medium

A pyramid, wide at the bottom and narrow at the top. Most of my tests are plain JVM tests that run in seconds on every commit, a thinner band of Robolectric and Compose tests sits above them, and a small set of real device tests at the top proves the critical journeys still work on an actual phone.

Google's own testing guidance now splits that pyramid into five layers, unit, component, feature, application and release candidate, and the one idea worth carrying out of it is that you always pick the lowest layer that still gives honest feedback. A bug caught by a unit test costs minutes. The same bug caught by an end to end test costs a day, and in production it costs weeks.

What goes in each layer

  • Local JVM unit tests, the base. ViewModels, use cases, mappers, validators, and repositories wired to fakes. These live in src/test/, need no emulator, and are where the bulk of my logic coverage sits. If a class needs Android to be tested, that is usually a design smell rather than a reason to move up a layer.
  • Robolectric, the fast middle. Code that genuinely touches the framework but does not need a real device, resource lookups, SharedPreferences round trips, a Parcelable, simple view inflation. It buys you Android APIs at JVM speed, at the cost of a simulation that can drift from the real OS. The caveats are in what are the disadvantages of using Robolectric.
  • Compose UI tests. One per screen, driving the real composable with a fake ViewModel or a hand built state object, asserting the states that matter, loading, empty, error, content. Written once, they run either on a device or under Robolectric, which is why they are cheap enough to have a lot of. The detail is in how do you write Compose UI tests.
  • Screenshot tests, the cheap UI regression layer. They catch what assertions never do, a broken margin, a colour that lost contrast, text clipped at a large font scale. One test asserts a hundred things about a screen at once. See what is screenshot testing.
  • Device backed integration tests. Room DAOs against a real in memory database, WorkManager workers, anything talking to a real system service. SQLite behaves differently enough that I want a real one under my queries and migrations.
  • End to end journeys, the tip. Three or four flows, sign in, the main task the app exists for, checkout or submit. They run on a device against fakes or a staging server, they are slow, and they are worth it only for the paths where a failure is a company problem.

The network layer

  • Fake the boundary, not the client. My repository takes an interface, and tests inject a fake that returns canned domain objects. That is where nine tests out of ten belong.
  • Keep a small contract test against the real parser. A MockWebServer style test that feeds a recorded JSON payload through the actual Retrofit and serialization setup, so a renamed field on the server side fails a test rather than a screen. This is the one place I want the real client in the loop.
  • Never hit the live API from a test. A test that depends on someone else's uptime is not a test, it is a monitor.

Fakes over mocks

  • A fake is a working implementation. An in memory repository backed by a map, an interface you wrote and can call for real. Google's own guidance names fakes as the preferred double, because they need no framework, they are lighter, and they read like the production object.
  • A mock asserts on interactions. It tells you a method was called, not that the behaviour is correct, so a suite full of verify calls locks in the shape of the code and breaks on every refactor even when nothing is broken.
  • The practical rule. Reach for a mock when the interaction really is the thing under test, like proving an analytics event fires exactly once. Everything else gets a fake.
// A fake is production shaped, so a refactor of the interface breaks it honestly.
class FakeUserRepository : UserRepository {
    var users = listOf(User("alice"))
    var failNext = false

    override suspend fun load(): Result<List<User>> =
        if (failNext) Result.failure(IOException()) else Result.success(users)
}

What I do not test

  • Framework code and libraries. Room's own SQL generation, Retrofit's own HTTP handling, the Compose runtime. Somebody already tests those.
  • Trivial getters, generated code, and data class equality. Nothing to break, so the test is pure maintenance cost.
  • Every visual permutation. Screenshot tests are chosen cases, not every combination of theme, font scale and screen size, or you drown in reference images.
  • The number itself. Coverage is a smoke alarm, not a target, which is the point made in describe code coverage.

The ratio, and the signal each layer gives

I aim for roughly seventy percent local unit tests, twenty percent Robolectric and Compose tests, and ten percent on a real device, and I care far more about the shape than the exact split. The base tells me a function is wrong. The middle tells me a screen renders the wrong state. The top tells me the app is broken. When the pyramid inverts and the device tests carry the coverage, the suite gets slow, flaky, and people start ignoring it, which is worse than having no suite at all.

How this becomes a gate

  • Every commit. ./gradlew test for the unit and Robolectric layers, plus lint and the screenshot verification task. Minutes, not tens of minutes.
  • Every pull request. The same, plus the Compose and instrumented tests for the modules the change actually touched, on a build managed emulator. Required to merge.
  • Nightly and pre release. The full instrumented suite across a device matrix, and the end to end journeys against a release build with R8 on, because that is the build users get.
  • No green, no merge. The gate only works if it is enforced by the branch rule rather than by good intentions, and if a flake gets quarantined the same day rather than retried forever.

In the room, lead with the pyramid and the reason for it, speed of feedback, then name what you actually put in each layer on your app. The thing an interviewer is listening for is whether you have a rule for deciding where a test goes, and whether the suite is wired into CI as a gate. Say fakes over mocks and say why, and if there is time, say what you deliberately do not test, because knowing where to stop is the part most candidates leave out.

Read more Testing strategies (opens in a new tab)Use test doubles in Android (opens in a new tab)

How do you deal with flaky tests?

Tier: CommonDifficulty: Medium

I treat a flaky test as a bug with a root cause, not as noise to be retried away. Almost every flake on Android comes down to the test depending on something it does not control, real time, an animation, a network, or state another test left behind, so the fix is to take that dependency away rather than to run it again and hope.

The reason this matters more than it sounds is trust. A suite that fails one time in ten trains the team to hit rerun without reading the failure, and once that habit forms a real regression sails straight through. A slightly smaller suite everyone believes is worth more than a large one nobody does.

Where flakiness comes from

  • Real time and real dispatchers. A test that calls delay, or lets production code run on Dispatchers.IO, is racing the machine. It passes on a quiet laptop and fails on a loaded CI runner.
  • Animations. A ripple, a shared element transition, a recycler item animator. The assertion runs while the view is still moving or partially transparent, so it sometimes finds the node and sometimes does not.
  • Network and time of day. Anything reaching a real server, and anything that formats a date, crosses a midnight boundary, or depends on a locale or a time zone.
  • Shared state between tests. A database row, a SharedPreferences value, a signed in session, a singleton left dirty. These flakes are order dependent, which is why they appear when someone adds an unrelated test.
  • Ordering and parallelism. Two tests writing the same file, or a test that only passes because an earlier one happened to log the user in.
  • Waiting the wrong way. A fixed sleep is a bet that the work finishes in that time. On a slow emulator it does not.

The fixes

  • Control time with a TestDispatcher. Run the body in runTest, inject a StandardTestDispatcher and set it as the main dispatcher, and coroutine delays are skipped through virtual time instead of really waiting. advanceUntilIdle then runs everything pending before you assert, which turns a race into a sequence. runTest also has its own sixty second timeout, so a hung coroutine fails loudly instead of hanging the build.
  • Use Turbine for flows, and know its clock. It gives you awaitItem, awaitComplete and cancelAndIgnoreRemainingEvents so a flow test reads sequentially, and the setup is in how do you unit test a ViewModel with Flow and StateFlow. One trap worth naming, Turbine's timeout is wall clock, three seconds by default, and it is not affected by the virtual time runTest is skipping. A test that looks correct can still time out on a slow machine.
  • Turn animations off on the test device. Set window animation scale, transition animation scale and animator duration scale to zero in developer options, or push them from the CI script before the run. Google names this as a flakiness fix in the Espresso setup guide, and a build managed device can be configured to do it for you.
  • Wait on a condition, never on a clock. waitUntil in Compose, an IdlingResource in Espresso for background work the framework cannot see, as covered in what is Espresso. Every Thread.sleep in an Android test is either too short and flaky or too long and slow, usually both across a fleet of devices.
  • Clear state between tests with Android Test Orchestrator. It runs each test in its own instrumentation invocation, so nothing leaks from one to the next, and a crash takes out one test instead of the rest of the run. With clearPackageData it runs the equivalent of a package clear after each test, which wipes files, databases and preferences.
  • Make every test hermetic. No live server. A fake repository, or a local mock web server serving recorded payloads. Inject the clock and the time zone rather than reading the system ones, and seed any randomness.
android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        // Wipes app state after every test, so nothing leaks between them.
        testInstrumentationRunnerArguments["clearPackageData"] = "true"
    }
    testOptions {
        execution = "ANDROIDX_TEST_ORCHESTRATOR"
    }
}

dependencies {
    androidTestUtil("androidx.test:orchestrator:1.6.1")
}

The cost of the orchestrator is real, restarting the app between tests makes the run slower, and that is a trade most teams take happily for isolation.

The process around it

  • Measure the flake rate. You cannot manage what you do not count. Record how often each test fails on a rerun of the same commit, and publish the worst offenders. A test failing more than about one run in a hundred is a problem.
  • Quarantine, do not delete. Move the test out of the blocking suite into a quarantined job that still runs and still reports. Deleting it throws away the coverage and the evidence. Leaving it in the gate destroys trust in the gate.
  • Give it an owner and a deadline. Every quarantined test gets a ticket and a name against it, and quarantine has a time limit. Otherwise the quarantine list becomes a graveyard and the coverage is gone anyway.
  • Retries are a smell, not a fix. Automatic retry is reasonable for infrastructure failures, an emulator that failed to boot or a checkout that lost the connection, because there is no test bug to find. Retrying an application test until it passes only hides the race, and Google's own CI guidance says to track and fix the root cause rather than lean on reruns.
  • Fix the cause, then write the regression down. When you find it, say what it was in the commit message. Most flakes in a codebase are the same three or four causes over and over, and naming them teaches the team to stop writing them.

In the room, start with the sentence that a flake is a real bug, then name the two or three causes you have actually debugged, usually real dispatchers, animations and leftover state. Give a concrete fix for each, TestDispatcher and virtual time, animations off on the device, the orchestrator with clearPackageData. Finish on process, quarantine with an owner rather than deletion, a tracked flake rate, and retries reserved for infrastructure. The process half is what separates a senior answer here.

Read more AndroidJUnitRunner (opens in a new tab)Espresso setup instructions (opens in a new tab)kotlinx-coroutines-test (opens in a new tab)

How do you run instrumented tests in CI?

Tier: CommonDifficulty: Medium

With build managed devices for the fast pull request run, and Firebase Test Lab for the wider device matrix on a schedule. The rule I follow is that CI must never depend on a device somebody plugged in, so the device definition lives in the build file and Gradle creates and destroys it as part of the task.

The old way was a shell script that booted an emulator, polled adb until it said the device was ready, ran connectedAndroidTest, and killed the emulator afterwards. It works and everybody has written one, and it is exactly the sort of glue that breaks silently.

Gradle Managed Devices

  • What they solve. You declare the device in build.gradle.kts, and the Android Gradle plugin creates it, installs the APKs, runs the tests and tears it down. The same task runs identically on your laptop and on CI, which kills the whole class of works on my machine failures.
  • What you get for free. Emulator snapshots so a device boots fast rather than cold every time, result caching so an unchanged module is not retested, and a consistent image rather than whatever version the runner happened to have. API level 27 and above.
  • Automated Test Devices. Setting systemImageSource to aosp-atd or google-atd picks a stripped down image that drops parts of the UI stack the tests do not need, which cuts CPU and memory noticeably. The catch is that anything relying on hardware rendering, screenshot tests especially, will not work on them.
  • Sharding. Setting android.experimental.androidTest.numManagedDeviceShards in gradle.properties splits the suite across several identical device instances running in parallel, which is the cheapest wall clock win available if the runner has the cores.
android {
    testOptions {
        managedDevices {
            localDevices {
                create("pixel6api34") {
                    device = "Pixel 6"
                    apiLevel = 34
                    // "aosp-atd" is leaner, "google" adds Play services.
                    systemImageSource = "aosp"
                }
            }
            groups {
                create("phoneAndTablet") {
                    targetDevices.add(devices["pixel6api34"])
                }
            }
        }
    }
}

// ./gradlew pixel6api34DebugAndroidTest
// ./gradlew phoneAndTabletGroupDebugAndroidTest

The emulator in a container, and the acceleration caveat

  • An emulator without hardware acceleration is unusable. On a Linux runner it needs KVM, which means the runner has to allow nested virtualisation. Plenty of hosted runners do not, and the emulator then falls back to full software emulation and takes minutes to boot before it does anything useful.
  • Graphics are a separate problem. A headless server has no GPU, so Google documents passing -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect to render in software. It works, it is slower, and it changes rendering enough that it is a poor place to record screenshot goldens.
  • Check before you design around it. The first thing to establish about a CI provider is whether its Linux runners expose KVM. If they do not, the honest answer is to stop running emulators there and send the instrumented suite to Firebase Test Lab instead.

Firebase Test Lab

  • Real devices in Google's data centres. You upload the app and test APKs and it runs them on physical phones and virtual devices, which is the only way to catch a bug that is specific to one manufacturer's skin or one Android version.
  • The matrix is the point. You pick devices, API levels, locales and orientations, and it runs every combination. That is also where the cost is, because a matrix is a product, and four devices across four API levels is sixteen runs of your whole suite.
  • It plugs into Gradle too. The Firebase Test Lab Gradle plugin lets you declare Test Lab devices in the same managedDevices style block, so a remote device is another Gradle task rather than a separate script.
  • Smart sharding. Rather than splitting by test count, Test Lab can use timing history to build shards that each take roughly the same wall clock time, and you tell it the shard duration you want. That is what makes a large suite finish in the time of its slowest shard instead of its unluckiest one.

The rest of the setup

  • Android Test Orchestrator, on. Each test in its own instrumentation invocation, with clearPackageData so nothing leaks between them. It costs run time and buys isolation, and it is the single biggest reduction in mystery failures. More on that in how do you deal with flaky tests.
  • Publish the reports as artifacts. The Gradle HTML report, the JUnit XML so the CI system can render failures inline, logcat for the failing test, and any screenshots or video the run captured. A failure you cannot see is a failure someone will rerun instead of fixing.
  • Cache the Gradle build. Build cache on, only build and test the modules a change actually touched, and skip the cache when the build scripts themselves change because that is when a stale cache lies to you.
  • Retry infrastructure, not tests. An emulator that failed to boot gets a rerun of the task. A test that failed gets a ticket.

What runs when

  • Every pull request. Unit and Robolectric tests, lint, screenshot verification, and the instrumented tests for the modules the change touched, on one build managed device with a single API level. Target ten to fifteen minutes total, because a gate slower than that gets bypassed.
  • On merge to main. The full instrumented suite on the same managed device, plus the end to end journeys. Nobody is waiting on it, so it can take longer.
  • Nightly. The Firebase Test Lab matrix, the oldest supported API level, the newest one, a tablet, a low end device, and a release build with R8 enabled, since minification breaks things nothing else catches.
  • Before a release. The widest matrix you are willing to pay for, against the actual release candidate.

Cost is the reason for that split, and it is worth saying out loud in an interview. Emulator minutes and Test Lab device minutes are billed, so the design is to keep the per commit gate small, cheap and fast, and to push breadth into runs where nobody is blocked and volume is predictable.

In the room, lead with build managed devices and why, the device is declared in the build so CI and your laptop run the identical thing. Then name the emulator acceleration caveat, because that is the practical detail that proves you have set this up rather than read about it. Finish with the pull request versus nightly split and the cost reasoning behind it.

Read more Scale your tests with build-managed devices (opens in a new tab)CI features (opens in a new tab)

What is screenshot testing, and how do you set it up on Android?

Tier: CommonDifficulty: Medium

A screenshot test renders a piece of UI, saves the image, and compares it against a previously approved image checked into the repository. If the pixels match the test passes. If they do not, the tool produces a report with the old image, the new image and the difference, and a human decides whether that change was intended.

The value is that it catches everything a normal UI test cannot be bothered to assert. A Compose test can tell you the button exists and is enabled, it will never tell you the button is now white on white, that a margin collapsed at a large font scale, that the error text is clipped on a small screen, or that a theme change silently broke the dark palette. One screenshot asserts on all of that at once, and it is far less code than the equivalent pile of assertions.

The review flow

  • Record. You run the record task, the tool renders every case and writes the images into the repository. These are the goldens, also called reference images.
  • Verify. On every build the tool renders again and compares. A mismatch fails the build and writes an HTML report with a side by side diff.
  • Accept or fix. This is the human step and it is the whole point. If you meant to change the spacing, you rerun record, and the new images land in the diff of your pull request where a reviewer can actually see the UI change. If you did not mean it, you have found a regression before it shipped.
  • Review the images, not the count. A pull request that updates four hundred goldens because a theme token moved is fine. One that updates a single screen nobody touched is the interesting one.

The tools, and which to pick

ToolRenders withNeeds a deviceWhere it stands
Compose Preview Screenshot Testinglayoutlib, on the JVMNoGoogle's own, still alpha, hooks your existing @Preview functions
RoborazziRobolectric native graphics, on the JVMNoRuns inside a Robolectric test, so it can interact before capturing
Paparazzilayoutlib, on the JVMNoLong established, but development has stalled through a slow 2.0 alpha
Device based toolsThe real Android rendererYesHighest fidelity, slowest, and needs an emulator in CI
  • Compose Preview Screenshot Testing is Google's tool and the one I would start a new project on. It renders host side with layoutlib, the same engine that draws previews in Android Studio, and it reuses the @Preview functions you already wrote, so the marginal cost of a new case is one annotation. As of September 2026 it is still an alpha, currently 0.0.1-alpha15, and Google says the APIs may change substantially, so plan for churn. It also wants a recent toolchain, AGP 9 or higher, Kotlin 2.2.10 or higher and JDK 17.
  • Roborazzi captures from inside a Robolectric test on the JVM, with Robolectric's native graphics mode turned on. That is its real advantage, because the test is a normal Robolectric test, you can click something, wait for state to settle, and then capture, which the preview based tools cannot do. It gives you record, compare and verify Gradle tasks per variant.
  • Paparazzi was the tool most teams used first. It also renders with layoutlib on the JVM with no device, and its record and verify tasks work the same way. Be honest about its current state in an interview though, the last stable release is old, the 2.0 line has been in alpha for a long time, and teams have hit trouble keeping it working on newer compile SDKs. It still works well on projects already on it, I would not start a new one there.
  • Device based tools like Shot, or the Screenshotbot fork of Meta's screenshot tests library after Meta archived the original in January 2026, capture on a real emulator or phone. You get the actual Android renderer, real fonts and real hardware acceleration, which matters for things layoutlib approximates badly. You pay for it with emulator time in CI.
// gradle.properties
// android.experimental.enableScreenshotTest=true

plugins {
    id("com.android.compose.screenshot") version "0.0.1-alpha15"
}

android {
    experimentalProperties["android.experimental.enableScreenshotTest"] = true
}

dependencies {
    screenshotTestImplementation("androidx.compose.ui:ui-tooling")
}

// Tests live in src/screenshotTest/, and a @Preview picks up @PreviewTest.
// ./gradlew updateDebugScreenshotTest    records the reference images
// ./gradlew validateDebugScreenshotTest  fails the build on a diff

Making the pixels reproducible

  • Different machines render differently. Google says this outright. macOS, Linux and Windows produce slightly different images because the low level rendering APIs underneath differ, so a golden recorded on a laptop will fail on a Linux CI runner.
  • Pin the environment. The reliable fix is to record and verify in the same place, a Docker image on CI with a fixed OS, a fixed JDK and a fixed toolchain. Record through a CI job rather than from whichever laptop is nearest.
  • Pin the fonts. Bundle the typeface in the app and reference it explicitly rather than leaning on a system font, because the system font differs between the host JVM, an emulator image and a real device. Set an explicit font scale and locale in each case instead of inheriting whatever the machine has.
  • Allow a tolerance, carefully. Every tool can accept a small percentage of differing pixels, and some can compare structurally rather than pixel by pixel. A tiny threshold absorbs antialiasing noise. A large one absorbs real bugs, so keep it near zero and fix the environment instead.

Storing the goldens

  • Start by committing the PNGs. They are small, they diff visually in the pull request, and the review flow depends on them being right there in the change.
  • Move to Git LFS when the repository feels it. Google recommends it, and it keeps the binary history out of the main pack file.
  • Cap the number of cases. Do not generate every combination of theme, font scale, locale and screen size. Pick the cases that give distinct feedback, light and dark, default and the largest font scale, phone and one large screen, and stop.

When a diff is a failure

A diff is a regression when nobody in the change intended a visual change, and it is an accepted change when the pull request is about that UI. That sounds obvious and it is the discipline that decides whether screenshot tests are useful or hated. Two habits keep it healthy. Never rerun record just to make the build green, look at the report first. And treat a golden update in a pull request as something a reviewer must actually look at, because that image is the review.

In the room, define it in one sentence, render, compare against a golden, a human approves the diff, then say what it catches that assertions do not. Name Google's Compose Preview Screenshot Testing tool and say it is still alpha, name one JVM alternative and say why, and finish on the thing interviewers are really probing, that rendering differs across machines so you record and verify in one pinned environment, usually CI.

Read more Screenshot testing (opens in a new tab)Compose Preview Screenshot Testing (opens in a new tab)

Unit Testing

How do you unit test a ViewModel with Kotlin Coroutines and LiveData?

Tier: EssentialDifficulty: Medium

Testing a ViewModel that mixes coroutines and LiveData needs two pieces of test infrastructure, one to make coroutines run synchronously and predictably, one to make LiveData work without the real main looper.

  • InstantTaskExecutorRule from androidx.arch.core:core-testing makes LiveData post its updates immediately instead of through the real Android main thread scheduler, which doesn't exist on a JVM test.
  • A TestDispatcher from kotlinx-coroutines-test, injected wherever the ViewModel launches coroutines, replaces Dispatchers.Main so viewModelScope.launch executes on the test thread instead of failing with no main looper available.
  • runTest replaces runBlocking as the coroutine builder for the test body itself, and it fast forwards any virtual time your code delays on.
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
    @get:Rule
    val instantTaskExecutorRule = InstantTaskExecutorRule()
    private val testDispatcher = StandardTestDispatcher()

    @Before
    fun setUp() = Dispatchers.setMain(testDispatcher)

    @After
    fun tearDown() = Dispatchers.resetMain()

    @Test
    fun `fetch success updates state to Success`() = runTest {
        val repository = mock<UserRepository>()
        whenever(repository.getUsers()).thenReturn(emptyList())
        val viewModel = UserViewModel(repository)

        viewModel.fetchUsers()
        advanceUntilIdle()

        assertEquals(UiState.Success(emptyList()), viewModel.state.value)
    }
}

Because LiveData.observeForever() doesn't suspend, the test has to advance the dispatcher itself, advanceUntilIdle() runs every pending coroutine to completion before the assertion checks the final state. Skip that call and you're asserting on a state the coroutine hasn't reached yet, which is the single most common way this style of test flakes.

How do you unit test a ViewModel with Kotlin Flow and StateFlow?

Tier: EssentialDifficulty: Medium

A ViewModel built on StateFlow tests more cleanly than one built on LiveData, because StateFlow is just a coroutine primitive, no InstantTaskExecutorRule or main looper workaround needed, only a TestDispatcher for Dispatchers.Main and a way to collect emissions.

Turbine is that collection tool. It turns a flow into a small sequential API inside a test, awaitItem() for the next emission, awaitComplete() when it finishes, cancelAndIgnoreRemainingEvents() when you're done asserting and want to stop collecting a flow that never completes on its own, like a StateFlow.

@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
    private val testDispatcher = StandardTestDispatcher()

    @Before
    fun setUp() = Dispatchers.setMain(testDispatcher)

    @After
    fun tearDown() = Dispatchers.resetMain()

    @Test
    fun `fetch success emits Success state`() = runTest {
        val repository = mock<UserRepository>()
        whenever(repository.getUsers()).thenReturn(flowOf(emptyList()))
        val viewModel = UserViewModel(repository)

        viewModel.state.test {
            assertEquals(UiState.Loading, awaitItem())
            viewModel.fetchUsers()
            assertEquals(UiState.Success(emptyList()), awaitItem())
            cancelAndIgnoreRemainingEvents()
        }
    }
}

The detail that trips people up is StateFlow conflation, it only ever holds the latest value, and it drops intermediate emissions a slow collector missed, so if your ViewModel flips through several states quickly, Turbine's awaitItem() calls need to match what actually reaches a collector, not necessarily every state the ViewModel briefly passed through. Reach for UnconfinedTestDispatcher instead of StandardTestDispatcher when you want coroutines to run eagerly without explicit advanceUntilIdle() calls, useful when a test only cares about the final emitted state.

Explain unit testing.

Tier: CommonDifficulty: Easy

A unit test verifies one small piece of logic in isolation, running directly on your local JVM instead of a device or emulator. That is what makes it fast, a suite of hundreds of them can run in seconds because there is no APK to build, no dexing, and no emulator to boot.

The test code lives in src/test/, separate from the src/androidTest/ folder used for instrumented tests. A local unit test cannot touch the real Android framework, calling something like TextUtils.isEmpty() throws an exception unless you stub it out, because the framework classes on the test classpath are stubs with the implementation stripped out.

  • Pure logic, like a use case, a mapper, or a regex validator, is the easiest target and needs nothing extra.
  • A ViewModel or presenter needs its Android or framework dependencies mocked, usually with MockK, and a Context dependency mocked or faked rather than touching the real one.
  • Anything that genuinely needs Android classes to behave correctly, like a SharedPreferences round trip, either gets faked by hand or runs under Robolectric instead of a bare JVM test.
class EmailValidatorTest {
    @Test
    fun `rejects a string with no at sign`() {
        assertFalse(EmailValidator.isValid("not-an-email"))
    }
}

The interview framing that matters here is speed versus fidelity. A unit test is fast but knows nothing about the real Android runtime, an instrumented test is slower but proves the code works on an actual device. Good coverage uses both, unit tests for the bulk of your logic, instrumented and Espresso tests for the parts that only make sense running on the platform itself.

Read more Local tests (opens in a new tab)

Why is Mockito used?

Tier: CommonDifficulty: Easy

Mockito is a mocking framework for Java that lets a unit test replace a real dependency with a fake one whose behavior you control, so you can test a class in isolation without a live network call, database, or the actual dependency graph behind it.

  • Mocking creates a stand-in object that implements the same interface as the real dependency but does nothing on its own.
  • Stubbing tells that stand-in what to return when a specific method is called, so whenever(api.getUser()) doReturn fakeUser behaves predictably every run.
  • Verification checks that a method was actually called, and with what arguments, which is how you assert on side effects rather than return values.
@Test
fun `emits success when the repository call succeeds`() {
    val repository = mock<UserRepository>()
    whenever(repository.getUser()).thenReturn(fakeUser)

    val viewModel = ProfileViewModel(repository)

    verify(repository).getUser()
    assertEquals(fakeUser, viewModel.state.value.user)
}

On a Kotlin codebase, the honest interview answer is that most teams have moved from Mockito to MockK, because Kotlin idioms like extension functions, object singletons, and coroutines are awkward for Mockito's Java-based proxying to intercept cleanly. The underlying reason to reach for a mocking framework at all doesn't change though, isolate the unit under test from everything around it, so a test failure means the logic is wrong, not that a database was unreachable.

Instrumentation & UI Testing

Explain instrumented testing.

Tier: CommonDifficulty: Easy

An instrumented test is a test that runs on a real device or emulator instead of your local JVM, which means it has full access to the actual Android framework, Context, and system services. The tradeoff is speed, each run needs an APK built, installed, and launched, so a suite that takes seconds as a local unit test can take minutes here.

The test code lives in src/androidTest/, and the Gradle plugin needs a runner configured to drive it.

android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}

You reach for an instrumented test when the thing under test cannot be faked convincingly on a JVM.

  • UI behavior, verified with Espresso or, for Compose, createComposeRule().
  • Anything touching real Android components, like a ContentProvider, a Service, or Parcelable round tripping through an actual Parcel.
  • Behavior that depends on the real OS version running underneath it, which a JVM stub can't reproduce.

The interview answer worth having ready is the tradeoff, not the definition. Instrumented tests give you real fidelity, actual device behavior, actual rendering, actual system services, but they are slow and need a connected device or emulator to run at all. A healthy test suite keeps most of its coverage in fast local unit tests and reserves instrumented tests for the slice of behavior that genuinely needs the platform underneath it.

Read more Instrumented tests (opens in a new tab)

What is Espresso?

Tier: CommonDifficulty: Easy

Espresso is Google's framework for writing UI tests that drive your app the way a user would, finding a view, acting on it, and asserting on the result, all inside a single instrumented test process. Its defining feature is automatic synchronization, it waits for the main thread to go idle before it lets your test proceed, so you almost never need a manual sleep() or Thread.sleep() to avoid flakiness.

The API is built around three kinds of objects that read almost like a sentence.

  • ViewMatchers find the view, withId(), withText(), withContentDescription().
  • ViewActions act on it, click(), typeText(), scrollTo().
  • ViewAssertions check the result, matches() combined with a matcher, isDisplayed().
onView(withId(R.id.email_input))
    .perform(typeText("[email protected]"), closeSoftKeyboard())

onView(withId(R.id.submit_button)).perform(click())

onView(withId(R.id.success_message))
    .check(matches(isDisplayed()))

For a list or spinner backed by an adapter, onData() replaces onView() since the item view doesn't exist in the hierarchy until it's scrolled into position. For Jetpack Compose screens, Espresso itself isn't the tool, createComposeRule() and composeTestRule.onNodeWithText() give you the equivalent find, act, assert API built for the Compose tree instead of the classic View hierarchy.

The gotcha worth naming in an interview is asynchronous work that Espresso's idling mechanism doesn't know about, a network call on a background executor, or a coroutine outside the main thread. Espresso only waits on the main looper and its own registered resources, so a test that touches something like that needs an IdlingResource registered, otherwise it either flakes or, worse, passes by accident because the assertion ran before the async work finished.

Read more Espresso (opens in a new tab)

What is Robolectric?

Tier: CommonDifficulty: Medium

Robolectric is a testing framework that runs Android code on your local JVM while simulating the Android framework underneath it, giving you something between a plain unit test and a real instrumented test. Inflating a layout, loading a resource, or calling into a system service normally needs a real device, Robolectric fakes all of that with what it calls shadow classes, so the test still runs in seconds without an emulator.

That combination is the whole pitch. You get to write a test against real Android APIs, Context, View inflation, SharedPreferences, resource lookups, but it executes as fast as a plain JVM unit test because there is no APK build, no install step, and no emulator boot.

@RunWith(RobolectricTestRunner::class)
class WelcomeTextTest {
    @Test
    fun `shows the localized welcome string`() {
        val activity = Robolectric.buildActivity(MainActivity::class.java)
            .create().get()
        val text = activity.findViewById<TextView>(R.id.welcome).text
        assertEquals("Welcome", text)
    }
}

The honest caveat, and the one interviewers want to hear, is that a shadow is an approximation, not the real OS. It can drift from actual device behavior on edge cases, which is why teams treat Robolectric as the fast middle tier, plain unit tests for pure logic, Robolectric for Android dependent logic that would otherwise force a slow instrumented test, and a smaller set of real instrumented or Espresso tests for anything where device fidelity actually matters.

Less common, worth knowing

These come up less often. Skim them once you are comfortable with everything above.

Instrumentation & UI Testing

What is UI Automator?

Tier: Less commonDifficulty: Easy

UI Automator is a testing framework for black box UI tests that reach outside your own app, it can see and interact with any app on screen, including system dialogs, the launcher, and notifications, not just your own view hierarchy. That's the core difference from Espresso, which only sees inside the app process it's instrumented into.

  • Espresso needs source level access to your app's views and synchronizes tightly with its main thread, which makes it fast and precise but scoped to one app.
  • UI Automator drives the device through the accessibility layer instead, so it can test a flow that crosses app boundaries, tapping a system permission dialog, switching to another app, then coming back.
  • Because it doesn't need your app's internals, it can also test a minified, obfuscated release build, something Espresso setups usually avoid.
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
device.pressHome()
device.findObject(By.text("Settings")).click()

The other place it comes up in a modern interview is Macrobenchmark. Compiling a Baseline Profile means driving a real user journey, cold start, scroll a list, from outside the app under measurement, and UI Automator is the mechanism the Macrobenchmark library uses to do that. So it's less a replacement for Espresso than a complementary tool, Espresso for detailed in-app interaction, UI Automator for cross app flows and system level automation.

Read more UI Automator (opens in a new tab)

What are the disadvantages of using Robolectric?

Tier: Less commonDifficulty: Medium

Robolectric's biggest weakness is exactly what makes it fast, it simulates the Android framework with shadow classes instead of running on the real thing, and a simulation can diverge from actual device behavior.

  • A shadow can lag behind a new Android API level, or model an edge case slightly differently than the real OS does, so a test can pass under Robolectric and still fail on a physical device.
  • Native code and anything backed by the NDK isn't really there, it's stubbed, so tests that exercise that path aren't testing much.
  • Rendering fidelity is limited. Robolectric can tell you a view was inflated and its text is correct, it's not a substitute for a real layout or visual regression check.
  • Version upgrades can be painful, a new Robolectric release sometimes changes shadow behavior in ways that quietly break existing tests.
  • It adds real weight to the test classpath and setup, more than a plain JVM unit test needs, even if it's still far lighter than an emulator.

The practical takeaway for an interview is where Robolectric sits in the pyramid. It's the fast middle ground between a pure unit test and a real instrumented test, useful for Android dependent logic you don't want to pay emulator time for, but it's not a replacement for genuine instrumented tests or Espresso tests on the screens and interactions where actual device fidelity matters.

Test Tooling

Describe code coverage.

Tier: Less commonDifficulty: Easy

Code coverage is the percentage of your source code that gets executed while your test suite runs, reported at the level of lines, branches, or methods. Android projects usually get this from JaCoco, wired into Gradle so a coverage report is generated as a build task.

  • Line coverage tells you which lines executed at least once.
  • Branch coverage is stricter, it tells you whether each side of an if or when was exercised, not just that the line ran.
  • Method or class coverage rolls the same idea up to a coarser level, useful for a quick scan of what's completely untested.
tasks.register<JacocoReport>("jacocoTestReport") {
    dependsOn("testDebugUnitTest")
    reports {
        xml.required.set(true)
        html.required.set(true)
    }
}

The number is a useful smoke alarm and a bad target. A high percentage tells you code ran during a test, it says nothing about whether the assertions in that test actually checked the right thing, a test with no assertions at all can still light up every line green. Chasing a coverage number as a goal in itself tends to produce tests that execute code without meaningfully verifying it. The better use is negative, a module sitting at very low coverage is a real signal that risky logic is going untested, and CI gates on a coverage threshold are best used to catch that regression, not as a proxy for quality.