The Testing Pyramid
The Testing Pyramid
The testing pyramid is a model for balancing test types in a project. It has three layers:
/\
/ UI \
/ Tests \
/----------\
/ Integration \
/ Tests \
/------------------\
/ Unit Tests \
/----------------------\
Unit Tests form the base. They run on the JVM, are fast, and cover individual classes in isolation. You write the most of these.
Integration Tests sit in the middle. They verify that multiple components work together — a ViewModel calling a repository, a repository hitting a local database, or a UseCase coordinating multiple repositories.
UI Tests sit at the top. They simulate user interaction through Espresso or Compose testing. They are slow and flaky, so you write fewer of them.
Why the Shape Matters
A project with only UI tests is fragile and slow. A project with only unit tests misses integration bugs. The pyramid guides you toward the right mix: many fast unit tests, a moderate number of integration tests, and a thin layer of UI tests for critical paths.
Android-Specific Considerations
Android testing differs from backend testing because of the framework itself. Activities, fragments, and ViewModels have lifecycle callbacks. Resources like strings, drawables, and layouts live in the APK. Context-dependent code like SharedPreferences or SQLite cannot run on a plain JVM.
This is why Android has two test directories:
src/test/— Unit tests that run on the JVM. No Android framework needed. Fast.src/androidTest/— Instrumented tests that run on a device or emulator. Android framework available. Slow.
Knowing which directory a test belongs in is the first decision you make.
Test Dependencies and Setup
Test Dependencies and Setup
Every Android project needs testing dependencies. Here is a typical Gradle configuration:
// build.gradle.kts (app module)
dependencies {
// Unit testing
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.3.1")
testImplementation("io.mockk:mockk:1.13.10")
testImplementation("app.cash.turbine:turbine:1.1.0")
// Instrumented testing
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.test:runner:1.6.2")
androidTestImplementation("androidx.test:rules:1.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
// Shared test utilities
testImplementation("androidx.arch.core:core-testing:2.2.0")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Test Runner
Android uses a test runner to execute instrumented tests. Configure it in build.gradle.kts:
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
For Compose tests, swap the runner:
androidTest {
testInstrumentationRunner = "androidx.compose.ui.testing.junit4.AndroidComposeTestRuleCreatorRunner"
}
Coroutine Testing
Coroutines are everywhere in modern Android. Testing them requires Dispatchers.setMain:
@Before
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
This replaces the Main dispatcher so tests do not crash when code uses withContext(Dispatchers.Main). The kotlinx-coroutines-test library provides TestDispatcher, advanceUntilIdle, and runTest to control coroutine execution in tests.
Running Tests
From the command line:
# Unit tests
./gradlew test
# Instrumented tests
./gradlew connectedAndroidTest
# Single test class
./gradlew testDebugUnitTest --tests "com.example.MyViewModelTest"
From Android Studio, right-click a test class or method and select Run. The IDE handles building and deploying automatically.
Building a Testing Strategy
Building a Testing Strategy
A testing strategy answers two questions: what do we test, and how do we test it?
Feature-Level Strategy
For each feature, identify the layers:
- Data layer — Repositories, data sources, mappers. Test with unit tests using mocks or fakes.
- Domain layer — Use cases, business logic. Test with unit tests. These should be the simplest tests.
- Presentation layer — ViewModels, UI state. Test with unit tests for ViewModel logic, instrumented tests for UI rendering.
- Navigation — Screen transitions, deep links. Test with UI tests for critical paths only.
What Not to Test
Testing everything is a trap. Skip these:
- Generated code — Hilt modules, data classes with
copy(), Parcelable implementations. - Framework code — Android SDK classes, third-party library internals.
- Trivial getters/setters — No logic, no value.
- UI layout details — Margins, padding, colors. These belong in design review, not tests.
Test Naming Convention
Use a consistent pattern:
`methodName`_`condition`_`expectedResult`
Examples:
loadUser_success_returnsUserStateloadUser_networkError_returnsErrorStateaddToCart_emptyCart_addsItem
The First Test
When starting a new feature, write one integration test that walks through the happy path. This test will fail because the code does not exist yet. That failure guides your implementation. This is the essence of TDD, covered in a later topic.
Quiz
1. Which layer of the testing pyramid should have the most tests?
2. Where do instrumented tests live in an Android project?
3. Why must you call Dispatchers.setMain in unit tests?
4. Which of these should you generally NOT test?
Flashcards
Question
What are the three layers of the testing pyramid?
Click to reveal answer
Answer
Unit tests (base, most numerous), Integration tests (middle), and UI tests (top, fewest).
Question
What is the difference between src/test/ and src/androidTest/?
Click to reveal answer
Answer
src/test/ contains JVM unit tests (fast, no Android framework). src/androidTest/ contains instrumented tests (slow, run on device/emulator, full Android framework access).
Question
Why do you need kotlinx-coroutines-test?
Click to reveal answer
Answer
It provides TestDispatcher, runTest, and advanceUntilIdle to control coroutine execution in tests and replace the Main dispatcher.
Question
What is a common test naming convention in Android?
Click to reveal answer
Answer
methodName_condition_expectedResult, e.g. loadUser_success_returnsUserState.
Revision Notes
Key Takeaways
- 1. Follow the testing pyramid: many unit tests, fewer integration tests, few UI tests
- 2. Unit tests go in src/test/, instrumented tests in src/androidTest/
- 3. Always set Dispatchers.setMain when testing coroutine code
- 4. Skip testing generated code, framework code, and trivial getters/setters
- 5. Use consistent test naming: methodName_condition_expectedResult
Interview Tips
- • Explain the testing pyramid and why UI-only testing is risky
- • Know the difference between unit and instrumented tests on Android
- • Be ready to discuss what you would and would not test in a feature
- • Understand why coroutine testing requires special setup
Cheat Sheet
Testing Overview Cheat Sheet
Testing Pyramid:
- Unit tests: Fast, JVM-based, most numerous
- Integration tests: Component interaction, moderate count
- UI tests: User simulation, slow, fewest
Test Directories:
src/test/— Unit tests (JVM)src/androidTest/— Instrumented tests (device/emulator)
Key Dependencies:
- JUnit 4 for test structure
- MockK or Mockito for mocking
- Coroutines-test for coroutine testing
- Turbine for Flow testing
- Espresso for View-based UI tests
- Compose UI testing for Compose
Coroutine Setup:
@Before fun setup() { Dispatchers.setMain(UnconfinedTestDispatcher()) }
@After fun tearDown() { Dispatchers.resetMain() }
Run Commands:
./gradlew test— Unit tests./gradlew connectedAndroidTest— Instrumented tests