Skip to content
advanced Phase 10 · Testing

TDD on Android

Apply test-driven development: red-green-refactor cycle with Android-specific considerations.

45m
2 problems
Topic Progress 0%

The Red-Green-Refactor Cycle

The Red-Green-Refactor Cycle

TDD is a development discipline, not a testing technique. You write a failing test first, make it pass with the minimum code, then clean up.

The Three Steps

1. Red — Write a Failing Test

Write a test that describes the behavior you want. It must fail because the code does not exist yet.

@Test
fun `cart total updates when item added`() = runTest {
    val cart = ShoppingCart()
    cart.addItem(Product(id = "1", price = 9.99))
    assertThat(cart.total).isEqualTo(9.99)
}

This fails because ShoppingCart does not exist. Good.

2. Green — Make It Pass

Write the simplest code that makes the test pass. No more.

class ShoppingCart {
    val total: Double
        get() = items.sumOf { it.price }

    private val items = mutableListOf<Product>()

    fun addItem(product: Product) {
        items.add(product)
    }
}

The test passes. The code is ugly but correct.

3. Refactor — Clean Up

Now improve the code without changing behavior:

class ShoppingCart {
    private val items = mutableListOf<Product>()

    val total: Double
        get() = items.sumOf { it.price }

    val itemCount: Int
        get() = items.size

    fun addItem(product: Product) {
        items.add(product)
    }

    fun removeItem(productId: String) {
        items.removeAll { it.id == productId }
    }

    fun clear() {
        items.clear()
    }
}

Add more tests for the new methods. Repeat.

Why This Order Matters

  • Test-first forces you to think about the interface before the implementation.
  • Minimum code prevents gold-plating and over-engineering.
  • Refactor happens when you have tests to catch regressions.

TDD on Android: Practical Guide

TDD on Android: Practical Guide

Android has unique constraints that affect TDD. Activities depend on the framework, ViewModels depend on Dispatchers.Main, and UI depends on Compose or Views.

TDD for ViewModels

ViewModels are the easiest place to start TDD because they are plain Kotlin classes:

// Step 1: Write the failing test
@Test
fun `loadUser shows loading then success`() = runTest {
    coEvery { userRepository.getUser("1") } returns User("Alice")
    val viewModel = UserViewModel(userRepository)

    viewModel.loadUser("1")

    val state = viewModel.uiState.first()
    assertThat(state).isInstanceOf(UserUiState.Success::class.java)
    assertThat((state as UserUiState.Success).user.name).isEqualTo("Alice")
}
// Fails: UserViewModel does not exist

// Step 2: Implement minimal code to pass
class UserViewModel(private val repo: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState

    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Loading
            try {
                val user = repo.getUser(id)
                _uiState.value = UserUiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UserUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// Step 3: Refactor — extract error message, add test for error path

TDD for Repositories

// Step 1: Failing test
@Test
fun `getProducts returns mapped list`() = runTest {
    val repository = ProductRepository(dao)
    dao.insertAll(listOf(ProductEntity(id = "1", name = "Widget")))

    val products = repository.getProducts()

    assertThat(products).hasSize(1)
    assertThat(products[0].name).isEqualTo("Widget")
}
// Fails: getProducts does not map correctly

// Step 2: Make it pass
suspend fun getProducts(): List<Product> {
    return dao.getAll().map { it.toDomain() }
}

// Step 3: Refactor — add caching, error handling

TDD for Compose UI

// Step 1: Write a failing Compose test
@Test
fun loginScreen_showsErrorForEmptyEmail() {
    composeTestRule.setContent {
        MaterialTheme { LoginScreen() }
    }

    composeTestRule.onNodeWithTag("login_button").performClick()
    composeTestRule.onNodeWithTag("email_error")
        .assertIsDisplayed()
}
// Fails: error is not shown

// Step 2: Add validation to the composable
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
    var email by remember { mutableStateOf("") }
    var showEmailError by remember { mutableStateOf(false) }

    Column {
        TextField(
            value = email,
            onValueChange = { email = it },
            modifier = Modifier.testTag("email_input")
        )
        if (showEmailError) {
            Text("Email is required", modifier = Modifier.testTag("email_error"))
        }
        Button(
            onClick = { showEmailError = email.isBlank() },
            modifier = Modifier.testTag("login_button")
        ) {
            Text("Login")
        }
    }
}

Android-Specific TDD Tips

  1. Start with the ViewModel. It is pure Kotlin and easiest to test.
  2. Use Hilt for dependency injection. It makes swapping real and fake dependencies trivial.
  3. Write one test at a time. Resist the urge to write multiple failing tests.
  4. Keep tests small. Each test should verify one behavior.
  5. Refactor after every green. Do not let debt accumulate.
  6. Use the testing pyramid. TDD drives unit tests. Add integration and UI tests later for critical paths.

Quiz

1. What is the correct order of the TDD cycle?

Question 1 options

2. In TDD, what should the green step produce?

Question 2 options

3. Why is TDD particularly effective for ViewModels on Android?

Question 3 options

4. When should you add integration or UI tests in a TDD workflow?

Question 4 options

Flashcards

Question

What are the three steps of the TDD cycle?

Answer

Red: write a failing test. Green: make it pass with minimum code. Refactor: clean up while tests protect against regressions.

Question

What is the purpose of the refactor step in TDD?

Answer

To improve code structure, readability, and remove duplication without changing behavior, protected by the tests you already wrote.

Question

Where should you start TDD on an Android project?

Answer

ViewModels — they are pure Kotlin classes with no framework dependencies, making them the easiest place to apply TDD.

Question

What is the danger of writing too many failing tests at once in TDD?

Answer

You lose focus on the specific behavior and may implement more than necessary. Write one test, make it pass, then move to the next.

Revision Notes

Key Takeaways

  • 1. TDD is Red-Green-Refactor: failing test, minimum code to pass, clean up
  • 2. Start TDD with ViewModels because they are pure Kotlin classes
  • 3. Write the minimum code to pass — do not over-engineer in the green step
  • 4. Refactor confidently because tests catch regressions
  • 5. Add integration and UI tests after unit tests for critical paths

Interview Tips

  • Walk through a Red-Green-Refactor cycle with a concrete Android example
  • Explain why test-first design leads to better interfaces
  • Discuss how TDD helps with refactoring legacy Android code
  • Know the difference between TDD and writing tests after implementation

Cheat Sheet

TDD on Android Cheat Sheet

Red-Green-Refactor:

  1. Red: Write a failing test that describes desired behavior
  2. Green: Write minimum code to make the test pass
  3. Refactor: Clean up code while tests pass

Android TDD Order:

  1. ViewModel (pure Kotlin, no framework)
  2. Repository (uses fakes/in-memory DB)
  3. Use cases (business logic)
  4. Compose UI (testTag, assertions)

TDD Rules:

  • Never write production code without a failing test
  • Write the minimum code to pass
  • Refactor after every green step
  • One test at a time
  • Tests are your safety net for refactoring

Common Patterns:

  • Start with happy path, add error paths after
  • Use fake dependencies from the start
  • Keep tests small and focused on one behavior