Testing Strategy & Pyramid Explained

TESTING › Strategy

The test pyramid describes a shape, not a rulebook: many fast, isolated unit tests at the base, fewer integration tests in the middle, and the smallest number of slow UI or end to end tests at the top. That shape is not arbitrary, it falls straight out of cost. A unit test runs in milliseconds, needs no device, and fails for exactly one reason. A UI test needs a real device or emulator, waits on animations and real IO, and can fail for a dozen unrelated reasons having nothing to do with the bug you actually care about. So you write hundreds of unit tests because you can afford to, and you write a handful of UI tests because each one is expensive and a little bit unreliable.

You still write a few UI or end to end tests, but reserve them for the critical journeys, login, checkout, core navigation, where only exercising the real, wired together app would catch a regression that unit and integration tests cannot see on their own.

When an interviewer asks about the pyramid, they are rarely testing whether you know the shape. They are testing whether you can explain why it is shaped that way, and whether you would notice a suite that has inverted it, lots of brittle UI tests and almost no unit tests, which is a classic sign of an untestable architecture rather than a deliberate choice.

Every Android test lives on one of two axes: where it runs, and what that costs you. Local tests, the ones under src/test, run on your machine's plain JVM. There is no device, no emulator, and no real Android framework underneath them, just your code and the JVM, so they start and finish in milliseconds. Instrumented tests, under src/androidTest, run on a real device or emulator with the actual Android framework loaded, so a Context behaves like a real Context and a Bundle behaves like a real Bundle, but that fidelity costs you seconds or minutes per run instead of milliseconds.

// Local, JVM only, milliseconds
class MathTest {
    @Test fun addition() = assertEquals(4, 2 + 2)
}

// Instrumented, needs a device, real framework underneath
@RunWith(AndroidJUnit4::class)
class ContextTest {
    @Test fun packageName() {
        val ctx = ApplicationProvider.getApplicationContext<Context>()
        assertEquals("com.example.app", ctx.packageName)
    }
}

The default should be local wherever your logic does not genuinely need the real framework, and for well decoupled business logic that is most of your code. Reaching for instrumented tests by default is usually a sign the logic underneath is too tightly coupled to Android classes to test any other way. The properties you are really optimizing for in any unit test, scope, speed, and fidelity, trade off against each other: a narrowly scoped, fast, deterministic test gives up some fidelity to the real framework, and that trade is almost always worth it for logic that does not need the framework at all.

Not everything in a codebase deserves a dedicated test. Spend your test budget on your own business logic, state transitions, and edge cases, the places where a bug is a real bug that would embarrass you in production. Skip framework code you do not own, the Android SDK itself, Room's internals, Retrofit's internals, because you are not the one maintaining it and a passing test there just proves the library vendor did their job. Skip trivial getters, setters, and plain data classes with no behavior of their own too, a test that just restates a generated equals or copy method adds maintenance cost without ever catching a real regression.

// Trivial, nothing of yours to verify
data class Coordinate(val lat: Double, val lng: Double)

// Worth testing, contains your own logic
data class Temperature(val celsius: Double) {
    val fahrenheit: Double get() = celsius * 9.0 / 5.0 + 32.0
}

The rule of thumb interviewers want to hear is simple: test behavior you wrote, skip behavior you merely declared.

Line coverage is a seductive number because it looks precise, ninety five percent sounds like a grade. But coverage only measures whether a line of code executed while a test was running, it says nothing about whether the test actually asserted anything meaningful about the result. You can call a function, ignore the return value, and never check that an error path threw the right exception, and the coverage tool will happily count every one of those lines as covered.

That gap matters most in code with important edge cases: a function can be one hundred percent covered and still be wrong for negative numbers, empty lists, or a null that was never exercised. Treat coverage as a floor that tells you what was never even run, not a ceiling that tells you the code is correct.

Fake and mock get used interchangeably in casual conversation, but they are different tools with different purposes. A fake is a real, working implementation, just a simplified one, an in-memory map standing in for a database, a repository that returns seeded values instead of hitting the network. You call a fake the same way you would call the real thing, and it actually does the work. A mock is not a working implementation at all, it is an object generated by a mocking framework where you script the exact return value for a specific call and can later verify that the call actually happened.

// FAKE, a real, simplified implementation
class FakeUserRepo : UserRepository {
    val saved = mutableListOf<User>()
    override suspend fun getUser(id: String) = saved.first { it.id == id }
}

// MOCK, scripted stub, verifies a specific interaction
val mockRepo = mockk<UserRepository>()
coEvery { mockRepo.getUser("1") } returns User("1", "Alice")
coVerify { mockRepo.getUser("1") }

Reach for a fake when you want state based testing of a collaborator you own, does the ViewModel end up in the right state. Reach for a mock when you specifically need to verify an interaction happened at a boundary you do not control, did we actually call save exactly once.

Kotlin classes and their methods are final by default, you have to opt in with the open keyword to allow subclassing. That single language decision has a direct consequence for mocking. Traditional mocking frameworks like plain Mockito work by generating a subclass of the type you want to mock at runtime, and a subclass of a final class simply is not legal without special tooling. So on a fresh Kotlin project, calling mock() on an ordinary class silently throws or refuses to compile the mock correctly unless you have enabled Mockito's inline mock maker.

// Fails on an ordinary final Kotlin class without extra setup
val mock = mock(UserService::class.java)

// MockK is Kotlin aware and handles final classes out of the box
val mock = mockk<UserService>()
every { mock.getUser("1") } returns User("1", "Alice")

MockK was built for Kotlin from the start and mocks final classes without any extra configuration, which is why it has become the default choice on Kotlin codebases even though Mockito still works once you turn the right flag on.

Testable architecture is not a style preference, it is what determines whether your logic can live at the fast base of the pyramid or gets dragged up to the slow, flaky top. The moment business logic sits directly inside an Activity or a Fragment, calling a Context, reading a View, it is welded to the Android framework, and the only way to exercise it is an instrumented test on a device. Move that same logic into a plain class, a ViewModel or a use case that receives its dependencies through the constructor instead of reaching out and grabbing them, and you can swap in a fake for anything slow, then run the whole thing as a local JVM test.

// BAD, logic trapped inside the Activity, forces an instrumented test
class LoginActivity : AppCompatActivity() {
    fun onLoginClicked() {
        if (emailField.text.isBlank()) showError() else doLogin()
    }
}

// GOOD, logic in a plain class, fast local test, no device involved
class LoginViewModel(private val repo: AuthRepo) : ViewModel() {
    fun login(email: String) {
        _error.value = if (email.isBlank()) "Required" else null
    }
}

This is why interviewers keep circling back to architecture questions inside a testing conversation, decoupling is not an abstract virtue, it is the mechanism that makes a large, fast, unit heavy test suite possible at all.

Once you have decided a dependency should be swappable, the pattern for testing a ViewModel is almost always the same. Construct the ViewModel directly in the test, by hand, no dependency injection framework needed, and pass it a fake implementation of whatever repository interface it depends on instead of the real one. The fake behaves like the real repository from the ViewModel's point of view, same interface, same suspend functions, but it returns data you seeded yourself instead of talking to a network or a database.

interface UserRepository {
    suspend fun getUser(id: String): User
}

class FakeUserRepository : UserRepository {
    private val users = mutableMapOf<String, User>()
    fun seed(user: User) { users[user.id] = user }
    override suspend fun getUser(id: String) = users.getValue(id)
}

@Test fun `shows user name`() = runTest {
    val repo = FakeUserRepository().also { it.seed(User("1", "Alice")) }
    val vm = UserViewModel(repo)
    assertEquals("Alice", vm.userName.value)
}

There is no I/O anywhere in this test, no device, and no timing dependent on a real network call, which is exactly why it belongs at the base of the pyramid.

A ViewModel that launches coroutines on Dispatchers.Main becomes a problem in a plain JUnit test, because there is no real main thread looper available on the JVM, and the coroutine would either crash or never run at all. The fix is to inject a TestDispatcher and point Dispatchers.Main at it before the test runs, then wrap the test body in runTest, which gives you a coroutine scope with virtual time you fully control instead of real wall clock time.

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

    @Before fun setUp() = Dispatchers.setMain(testDispatcher)
    @After fun tearDown() = Dispatchers.resetMain()

    @Test fun `loads data`() = runTest {
        val vm = MyViewModel(fakeRepository, testDispatcher)
        advanceUntilIdle()
        assertEquals(expectedData, vm.uiState.value)
    }
}

Nothing here waits on a real clock, advanceUntilIdle runs every coroutine that is currently queued until none are left pending, deterministically and instantly, which is what makes coroutine heavy ViewModel tests fast and reliable instead of flaky.

StandardTestDispatcher and UnconfinedTestDispatcher both give you virtual time, but they start coroutines differently, and that difference regularly surprises people the first time they hit it. StandardTestDispatcher queues a newly launched coroutine rather than running it immediately, it sits there until you call advanceUntilIdle or advanceTimeBy, which mirrors how a real dispatcher schedules work rather than running it inline. UnconfinedTestDispatcher does the opposite, it starts a newly launched coroutine eagerly, right where launch is called, and runs it up to its first suspension point before your test code continues.

That difference matters because a test written against one dispatcher and then switched to the other can quietly start asserting a value one line too early or too late, which is a classic source of confusing, hard to explain coroutine test failures.

Asserting on a Flow with nothing but the latest value throws away information, a Flow that emits Loading and then Success looks identical to one that jumped straight to Success if you only ever check the final state. Turbine fixes that by letting you collect a Flow inside runTest and pull emissions off one at a time, in order, asserting on each one before moving to the next, so a test can actually prove the sequence of states was correct and not just the ending.

@Test fun `emits loading then success`() = runTest {
    val vm = MyViewModel(fakeRepo)
    vm.uiState.test {
        assertEquals(UiState.Loading, awaitItem())
        assertEquals(UiState.Success(data), awaitItem())
        cancelAndIgnoreRemainingEvents()
    }
}

Each call to awaitItem suspends until the next value arrives, so the test stays deterministic without ever touching a real clock, and cancelAndIgnoreRemainingEvents cleans up the collection once you are done asserting.

Integration tests occupy the middle of the pyramid because they check something a unit test cannot: that your own layers actually cooperate correctly when wired together for real, not through a fake standing in for one of them. The classic example is a repository tested against a real, usually in-memory, Room database instead of a fake DAO. A fake DAO can never catch a bug in your actual SQL, a broken query, a wrong column, a migration that silently drops data, an in-memory Room database can.

@RunWith(AndroidJUnit4::class)
class UserRepositoryIntegrationTest {
    private lateinit var db: AppDatabase
    private lateinit var repo: UserRepositoryImpl

    @Before fun setUp() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(), AppDatabase::class.java
        ).allowMainThreadQueries().build()
        repo = UserRepositoryImpl(db.userDao())
    }

    @After fun tearDown() = db.close()

    @Test fun `save and retrieve user`() = runTest {
        repo.save(User("1", "Alice"))
        assertEquals("Alice", repo.getUser("1").name)
    }
}

It costs more than a pure unit test, it needs the AndroidJUnit4 runner and a real database engine underneath, but far less than driving the same behavior through the actual UI.

Robolectric occupies an unusual middle ground: it simulates the Android framework directly on the JVM, so a test that touches an Activity, a Context, or a View can run as a local test, no device, no emulator, and still get framework behavior close to the real thing instead of the bare JVM having no idea what a Context even is.

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class MyActivityTest {
    @Test fun `activity title is set`() {
        val activity = Robolectric
            .buildActivity(MyActivity::class.java)
            .setup()
            .get()
        assertEquals("My App", activity.title)
    }
}

It is not a full replacement for instrumented tests, some framework behavior is only faithfully reproduced on a real device or emulator, but for a lot of framework touching code it gets you close to instrumented fidelity at close to local speed, which is why teams reach for it before paying the cost of a device.

Compose does not have a View hierarchy for a test to walk, it has a semantics tree, a description of what is on screen in terms a test, or an accessibility service, can actually query: text, roles, click actions, content descriptions. A Compose UI test sets content through a compose test rule, then finds nodes in that semantics tree with finders like onNodeWithText, and chains assertions and actions onto whatever node matched.

@get:Rule val composeTestRule = createComposeRule()

@Test fun `submit button shows confirmation`() {
    composeTestRule.setContent { MyScreen() }

    composeTestRule
        .onNodeWithText("Submit")
        .performClick()

    composeTestRule
        .onNodeWithText("Done!")
        .assertIsDisplayed()
}

Because it queries semantics rather than a rendered pixel grid, this kind of test survives visual restyling that would break a screenshot based test, as long as the meaning of the screen, its text and roles, stays the same.

Back to Testing Strategy & Pyramid