The test pyramid is a model for how to distribute tests across three levels: unit, integration, and end-to-end (E2E). Each level has a different speed/coverage trade-off. Getting the ratio right keeps your test suite fast and reliable.
The Three Levels
/\
/E2E\ ← Few: slow, fragile, but test real user flows
/------\
/Integr- \ ← Some: test component interactions
/ ation \
/------------\
/ Unit Tests \ ← Many: fast, isolated, cheap to maintain
/________________\
Unit Tests (Base of the Pyramid)
Test a single function, class, or module in isolation. No Android runtime needed — runs on JVM.
class PriceFormatterTest {
private val formatter = PriceFormatter(currency = "USD")
@Test
fun `formats integer price with dollar sign`() {
assertEquals("$5.00", formatter.format(500)) // cents
}
@Test
fun `formats zero as free`() {
assertEquals("Free", formatter.format(0))
}
@Test
fun `formats negative price throws`() {
assertThrows<IllegalArgumentException> { formatter.format(-1) }
}
}
Target: 70% of your tests. They run in milliseconds, give precise feedback, and are easy to fix.
Integration Tests (Middle of the Pyramid)
Test how components work together. May require a real database, repository + DAO interaction, or multiple classes working together.
@RunWith(AndroidJUnit4::class)
class ArticleRepositoryTest {
@get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var db: AppDatabase
private lateinit var repository: ArticleRepository
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
).build()
repository = ArticleRepository(db.articleDao(), FakeApi())
}
@After
fun teardown() = db.close()
@Test
fun `insert and retrieve article`() = runTest {
repository.saveArticle(testArticle)
val result = repository.getArticle(testArticle.id)
assertEquals(testArticle, result)
}
}
Target: 20% of your tests. Slower than unit tests but catch integration bugs unit tests miss.
E2E / UI Tests (Tip of the Pyramid)
Test full user flows through the real UI on a device or emulator.
@RunWith(AndroidJUnit4::class)
class LoginFlowTest {
@get:Rule val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun `successful login navigates to home`() {
composeTestRule.onNodeWithTag("email_field")
.performTextInput("user@example.com")
composeTestRule.onNodeWithTag("password_field")
.performTextInput("password123")
composeTestRule.onNodeWithTag("login_button")
.performClick()
composeTestRule.onNodeWithText("Welcome back!").assertIsDisplayed()
}
}
Target: 10% of your tests. Run before release, not on every commit. Slow and flaky — keep them minimal.
What to Test at Each Level
| Concern | Unit | Integration | E2E |
|---|---|---|---|
| Business logic | ✅ Primary | ||
| Data transformation | ✅ | ||
| DAO + Room | ✅ | ||
| Repository (API + Cache) | ✅ | ||
| ViewModel logic | ✅ (with fakes) | ||
| Navigation | ✅ | ||
| Full user flows | ✅ | ||
| Critical paths (login, checkout) | ✅ |
The Anti-Pyramid: What to Avoid
An "ice cream cone" (most tests are E2E, few unit tests) is a common mistake:
- E2E tests are slow → CI takes hours
- E2E tests are flaky → random failures erode confidence
- When a test fails, it's hard to diagnose which layer broke
Key Takeaways
| Level | Count | Speed | Feedback |
|---|---|---|---|
| Unit | ~70% | < 1s each | Precise — pinpoints the broken function |
| Integration | ~20% | 1–10s each | Moderate — catches component interaction bugs |
| E2E | ~10% | 30s–5min each | Coarse — confirms full flows work |
The pyramid ratio exists because unit tests give you the most value per minute of CI time. Start there — every business logic function should have unit tests before you write integration or E2E tests.