Skip to content
All posts
March 9, 20264 min read

Test-Driven Development in Practice: A Realistic Take for Android Devs

TDD is either praised as the only correct way to develop or dismissed as impractical. The reality is more nuanced. Here's how to apply TDD where it adds value in Android development without dogma.

AndroidTestingKotlinEngineering
Share:

TDD has two camps: the true believers who write tests for everything before a single line of production code, and the pragmatists who see it as too slow for "real" development. Both are wrong in interesting ways.

Here's the honest picture.


What TDD Actually Is

The TDD loop:

  1. Red — Write a failing test for behavior you haven't implemented
  2. Green — Write the minimum code to make the test pass
  3. Refactor — Improve the code while keeping tests green

The key insight: you're using the test as a design tool, not just a verification tool. Writing the test first forces you to think about the interface before the implementation.


Where TDD Works Exceptionally Well

Business logic with clear rules. Tax calculations, discount rules, validation logic, state machines. The rules are precise and testable. TDD is natural here.

kotlin
// TDD for a discount rule:

// Step 1: Write the test (Red)
@Test
fun `order over 100 gets 10 percent discount`() {
    val order = Order(total = 120.0)
    val discount = discountService.calculate(order)
    assertEquals(12.0, discount, 0.001)
}

// Step 2: Write minimum code (Green)
fun calculate(order: Order): Double {
    return if (order.total > 100) order.total * 0.10 else 0.0
}

// Step 3: Add edge cases (more Red)
@Test
fun `order exactly 100 does not get discount`() {
    val order = Order(total = 100.0)
    assertEquals(0.0, discountService.calculate(order), 0.001)
}

Data transformations and mappers. Mapping network DTOs to domain models is pure function territory. TDD fits perfectly.

Algorithms. Sorting, parsing, complex calculations. The expected output is knowable before you write the algorithm.

State management in ViewModels. Given this action, the state should be X. Very testable with Turbine.


Where TDD Is Awkward

UI development. You can't easily TDD a composable layout. You'd have to run the app to see what you're building. Write the composable first, add a screenshot test after.

Exploratory code. When you're not sure what you're building yet, tests lock you into a design you might discard. Explore first, then extract testable logic and TDD that.

External integrations. Figuring out how a third-party SDK works requires experimentation. TDD doesn't help you discover an API — it helps you specify behavior you already understand.

Database schema changes. Room migrations are hard to TDD — you learn the constraints by running them. Write migration tests after the migration is defined.

[!NOTE] TDD is a design technique, not a testing technique. It works best when you already understand the design you're implementing. When you're figuring out the design, explore first.


A Practical ViewModel TDD Example

kotlin
// Feature: Filter tasks by status

// Step 1: Red — test behavior we want
@Test
fun `filtering by active shows only incomplete tasks`() = runTest {
    val repository = FakeTaskRepository().apply {
        addTask(Task("1", "Buy milk", completed = false))
        addTask(Task("2", "Write tests", completed = true))
        addTask(Task("3", "Deploy app", completed = false))
    }
    val viewModel = TaskViewModel(repository)
    
    viewModel.setFilter(TaskFilter.ACTIVE)
    
    val state = viewModel.uiState.value
    assertEquals(2, state.tasks.size)
    assertTrue(state.tasks.all { !it.completed })
}

// Step 2: Green — add filter support to ViewModel
class TaskViewModel(private val repository: TaskRepository) : ViewModel() {
    private val _filter = MutableStateFlow(TaskFilter.ALL)
    
    val uiState: StateFlow<TaskUiState> = combine(
        repository.getAllTasks(),
        _filter
    ) { tasks, filter ->
        TaskUiState(
            tasks = when (filter) {
                TaskFilter.ALL -> tasks
                TaskFilter.ACTIVE -> tasks.filter { !it.completed }
                TaskFilter.COMPLETED -> tasks.filter { it.completed }
            }
        )
    }.stateIn(viewModelScope, SharingStarted.Eagerly, TaskUiState())
    
    fun setFilter(filter: TaskFilter) {
        _filter.value = filter
    }
}

// Step 3: Add more cases (Red → Green → Refactor)
@Test
fun `filtering by completed shows only completed tasks`() = runTest { ... }

@Test
fun `filter all shows all tasks`() = runTest { ... }

The "Test After" Alternative

For code you write without TDD:

  1. Write the implementation
  2. Immediately write tests for the behavior
  3. Use the test-writing process to find edge cases you missed
  4. Go back and fix any gaps

This isn't TDD, but it achieves most of the same goals if you do it immediately — not "later."

"Later" always becomes "never."


Applying TDD to a New Feature

When starting a new feature:

  1. Identify the pure logic. Which parts of this feature are business rules or data transformations? Those are TDD candidates.

  2. Identify the exploratory parts. Which parts require figuring out an API, UI layout, or architecture? Do those first.

  3. TDD the logic. Once you understand what you're building, write tests first for the business rules.

  4. Test after for the plumbing. Repository implementations, DB migration tests, UI tests — write after, immediately.


Takeaways

  • TDD works best for business logic, algorithms, and state management — not UI or exploratory code
  • The value of TDD is using tests to think through the design, not just verify it
  • "Test after" done immediately is 80% of the value of TDD with less friction
  • Don't TDD code you're still figuring out — explore first, then apply TDD to what you've learned
  • The refactor step is where TDD earns its keep — small, clear improvements with tests protecting you
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this