Skip to main content
Module: 09 of 13Duration: 2 weeksTopics: 2 · 4 subtopicsPrerequisites: Modules 01–08

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:

  1. 01

    Red

    Write a failing test that describes the next small bit of behavior you want.

  2. 02

    Green

    Write the simplest production code that makes the test pass — no more.

  3. 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

ToolWhat it catches
DetektKotlin code smells, complexity, magic numbers
Android LintAndroid-specific issues, deprecated APIs, perf
SpotlessAuto-format Kotlin (ktlint), XML, Gradle
KonsistArchitectural 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

Worth testing

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)
Skip or minimize

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

  1. 01

    Test a ViewModel

    Take ProfileViewModel from Module 04 and write tests covering loading, success, and error transitions with Turbine.

  2. 02

    Compose UI test

    Verify that tapping a button toggles a Switch and updates a Text in your Compose screen.

  3. 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.