Testing Strategy & Pyramid Explained
TESTING › Strategy
The **test pyramid** is a shape, not a rulebook: many fast, isolated **unit tests** at the base, fewer **integration tests** in the middle (a repository wired to a real database), and the fewest, slowest **UI or end-to-end tests** at the top. The shape follows directly from cost. Unit tests are cheap, fast, and stable, so you can afford hundreds of them. UI tests are slow, need a device, and are prone to flakiness (timing, animations, real IO), so you keep them focused on the handful of flows where nothing less would catch a real regression.
Interviewers care less about the exact ratio and more about whether you can explain *why* the shape looks that way, it's a direct consequence of speed, cost, and reliability trading off against fidelity.
Two axes describe every Android test: where it runs, and what it costs you. **Local tests** (src/test) run on your machine's plain JVM, fast, no device, but no real Android framework underneath. **Instrumented tests** (src/androidTest) run on a device or emulator with the real framework, higher fidelity, much slower.
// Local, no device, runs in milliseconds
class MathTest {
@Test fun addition() = assertEquals(4, 2 + 2)
}
// Instrumented, needs a device, real Context available
@RunWith(AndroidJUnit4::class)
class ContextTest {
@Test fun packageName() {
val ctx = ApplicationProvider.getApplicationContext<Context>()
assertEquals("com.example.app", ctx.packageName)
}
}
Default to local wherever the logic doesn't genuinely need the real framework, that's most business logic, and it's exactly why decoupled architecture matters so much for a healthy test suite.
Robolectric is worth knowing as the middle ground: it simulates the Android framework **on the JVM**, so tests that touch SDK classes (an Activity, a Context) can run fast, as local tests, without a device or emulator at all.
@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 doesn't replace instrumented tests for everything (some framework behavior is only faithfully reproduced on a real device), but for a lot of framework-touching code it gets you most of the fidelity of an instrumented test at close to the speed of a local one.
Not everything deserves a test. Target your own **business logic, state transformations, and edge cases**, that's where a bug is a real bug. Skip framework code you don't own (the Android SDK, Room and Retrofit internals), trivial getters or setters, and plain data classes with no behavior, a test there just restates the implementation and adds maintenance cost for nothing.
// 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
}
A related trap: **line coverage** only tells you code *ran* during a test, not that anything meaningful was asserted about it. 95% coverage with weak assertions can hide just as many bugs as no tests at all.
Fakes versus mocks again, but from the strategy angle: a **fake** is a real, simplified implementation you call normally, in-memory storage, actual logic. A **mock** is a framework-generated object where you script return values and verify interactions happened. Prefer fakes for **state-based** testing of collaborators you own, prefer mocks when you specifically need to verify an **interaction** occurred, especially at a boundary you don't control.
// FAKE, real working, simplified
class FakeUserRepo : UserRepository {
val saved = mutableListOf<User>()
override suspend fun getUser(id: String) = saved.first { it.id == id }
}
// MOCK, scripted, verifies a specific call happened
val mockRepo = mockk<UserRepository>()
coEvery { mockRepo.getUser("1") } returns User("1", "Alice")
coVerify { mockRepo.getUser("1") }
One Kotlin-specific wrinkle worth knowing: classes and methods are **final by default**, so plain Mockito can't mock them without the inline mock-maker extension. MockK was built Kotlin-first and handles final classes out of the box.
Testable architecture isn't a style preference, it's a prerequisite. Business logic inside an Activity or Fragment is glued to the Android framework, so verifying it forces you into a slow instrumented test. Move that logic into a plain class, a ViewModel with dependencies passed in via constructor, and you can swap in fakes and run it as a fast local test.
// BAD, logic trapped in the Activity, needs an instrumented test
class LoginActivity : AppCompatActivity() {
fun onLoginClicked() {
if (emailField.text.isBlank()) showError() else doLogin()
}
}
// GOOD, logic in a plain class, fast local JVM test, no device
class LoginViewModel(private val repo: AuthRepo) : ViewModel() {
fun login(email: String) {
_error.value = if (email.isBlank()) "Required" else null
}
}
Decoupling from Context and framework classes is what lets ViewModels and use cases live at the base of the pyramid instead of being forced to the top.
The middle of the pyramid, **integration tests**, verifies that several of your own layers actually cooperate, not one function in isolation, and not the whole app end to end. A classic example is a repository wired to a real (often in-memory) Room database:
@RunWith(AndroidJUnit4::class)
class UserRepositoryIntegrationTest {
private lateinit var db: AppDatabase
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(), AppDatabase::class.java
).allowMainThreadQueries().build()
}
@Test
fun `save and retrieve user`() = runTest {
val repo = UserRepositoryImpl(db.userDao())
repo.save(User("1", "Alice"))
assertEquals("Alice", repo.getUser("1").name)
}
}
This catches bugs a pure unit test with a fake DAO can't, real SQL, real query behavior, while still being cheaper than a full UI flow.