Testing & Quality Assurance
Tests aren't an afterthought — they're how senior engineers refactor without fear, ship without panic, and onboard new teammates without breaking production. This module gives you the layered testing strategy every production Android app needs.
The testing pyramid
▲
╱ ╲ Few, slow, expensive
UI / E2E (Espresso, Compose UI tests)
─────────
Integration tests
(Hilt + Robolectric, real DB)
─────────────────────────────
Unit tests
(JUnit + MockK, no Android)
Many, fast, cheap
The pyramid says: most tests should be unit tests, fewer integration tests, and very few full UI/E2E tests. Inverting the pyramid produces slow, flaky test suites.
Topic 1 · Testing
Unit testing with JUnit 5 + MockK
Unit tests run on your laptop's JVM — no emulator, no Android framework. They target pure logic: ViewModels, use cases, mappers, validators.
class GetUserUseCaseTest {
private val repo = mockk<UserRepository>()
private val useCase = GetUserUseCase(repo)
@Test
fun `returns user when repository succeeds`() = runTest {
coEvery { repo.fetch("u1") } returns User("u1", "Aarav", "a@x.com")
val result = useCase("u1")
assertEquals("Aarav", result.name)
coVerify(exactly = 1) { repo.fetch("u1") }
}
@Test
fun `propagates exception from repository`() = runTest {
coEvery { repo.fetch(any()) } throws IOException("boom")
assertThrows<IOException> { useCase("u1") }
}
}
Testing ViewModels with runTest and Turbine
ViewModels expose StateFlow/SharedFlow. Use Turbine to assert the
sequence of emissions deterministically:
class ProfileViewModelTest {
private val repo = mockk<UserRepository>()
private val dispatcher = StandardTestDispatcher()
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
@AfterEach fun tearDown() = Dispatchers.resetMain()
@Test
fun `loads user successfully`() = runTest(dispatcher) {
coEvery { repo.fetch("u1") } returns User("u1", "Aarav", "a@x.com")
val savedState = SavedStateHandle(mapOf("userId" to "u1"))
val vm = ProfileViewModel(repo, savedState)
vm.state.test {
assertTrue(awaitItem().isLoading) // initial
val success = awaitItem()
assertFalse(success.isLoading)
assertEquals("Aarav", success.user?.name)
cancelAndIgnoreRemainingEvents()
}
}
}
UI testing with the Compose Testing API
Compose UI tests run on a device or emulator. They interact with composables via semantics nodes — the accessibility tree.
@HiltAndroidTest
class ProfileScreenTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val compose = createAndroidComposeRule<HiltTestActivity>()
@BindValue @JvmField val repo: UserRepository = mockk(relaxed = true)
@Test
fun showsUserNameAndEmail() {
coEvery { repo.fetch("u1") } returns User("u1", "Aarav", "a@x.com")
compose.setContent { ProfileScreen() }
compose.onNodeWithText("Aarav").assertIsDisplayed()
compose.onNodeWithText("a@x.com").assertIsDisplayed()
}
@Test
fun retryButtonReloads() {
coEvery { repo.fetch(any()) } throws IOException()
compose.setContent { ProfileScreen() }
compose.onNodeWithText("Retry").performClick()
coVerify(atLeast = 2) { repo.fetch(any()) }
}
}
Espresso for legacy XML screens
Use Espresso when you have View-based screens. Its API is verbose but battle-tested:
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@get:Rule val rule = ActivityScenarioRule(LoginActivity::class.java)
@Test fun `shows error on empty email`() {
onView(withId(R.id.loginBtn)).perform(click())
onView(withId(R.id.emailLayout))
.check(matches(hasErrorText("Email required")))
}
}
Topic 2 · Quality
TDD methodology — Red, Green, Refactor
Test-Driven Development is a workflow:
- 01
Red
Write a failing test that describes the next small bit of behavior you want.
- 02
Green
Write the simplest production code that makes the test pass — no more.
- 03
Refactor
Improve the code (and test) without changing behavior. Tests stay green.
TDD pays off most when you're building non-trivial logic (validators, state machines, parsers). For UI tweaks, it's overkill. Choose deliberately.
Code coverage
Use JaCoCo or Kover for coverage reports. Aim for high coverage on domain/use cases (~80–90%) and relax for UI (~30–50%). Coverage is a diagnostic, not a goal — 100% coverage of trivial getters is meaningless.
// Apply Kover (Kotlin-aware coverage)
plugins { id("org.jetbrains.kotlinx.kover") version "0.9.0" }
koverReport {
filters {
excludes {
classes("*Hilt_*", "*_Factory", "*_MembersInjector")
packages("*.di.*", "*.databinding.*")
}
}
}
Static analysis
| Tool | What it catches |
|---|---|
| Detekt | Kotlin code smells, complexity, magic numbers |
| Android Lint | Android-specific issues, deprecated APIs, perf |
| Spotless | Auto-format Kotlin (ktlint), XML, Gradle |
| Konsist | Architectural rules — enforce module boundaries |
Wire them into ./gradlew check so they run with every test:
plugins {
id("io.gitlab.arturbosch.detekt") version "1.23.7"
id("com.diffplug.spotless") version "6.25.0"
}
spotless {
kotlin {
target("**/*.kt")
ktlint("1.3.1")
}
}
CI/CD pipeline
A minimal GitHub Actions workflow:
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '17', distribution: 'temurin' }
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew spotlessCheck detekt lint test koverXmlReport
- uses: actions/upload-artifact@v4
with: { name: reports, path: '**/build/reports/**' }
What to test, what not to test
Test these aggressively
- ViewModels and their state transitions
- Use cases and business rules
- Mappers (DTO ↔ Entity ↔ Domain)
- Repository caching logic
- Form validators and parsers
- Critical user journeys (login, checkout)
Low-value tests
- Generated code (Hilt, Room, data classes)
- Trivial Compose previews
- Third-party library internals
- Simple getters/setters
- Constants files
- XML layouts (let lint catch issues)
Key takeaways
Practice exercises
- 01
Test a ViewModel
Take ProfileViewModel from Module 04 and write tests covering loading, success, and error transitions with Turbine.
- 02
Compose UI test
Verify that tapping a button toggles a Switch and updates a Text in your Compose screen.
- 03
Set up CI
Add a GitHub Actions workflow that runs spotlessCheck, detekt, lint, and test on every push.
Next module
Continue to Module 10 — Performance Optimization to profile, measure, and ship a fast app.