Home / Blog / TDD on Android

TDD on Android: what I actually test.

"Test-driven" is one of the easiest things to write on a résumé and one of the hardest to do with any judgment. Chasing 100% coverage produces brittle tests of nothing in particular; skipping tests entirely produces apps that break in the field. Here's the middle I actually practise — starting with the more useful question: what I don't test.

What I don't test

  • Framework glue — lifecycle callbacks, the fact that a Compose screen shows a list. If it's just wiring, a test mostly re-asserts that the framework works.
  • Trivial code — getters, data-class equality, one-line mappers with no branching.
  • Third-party libraries — Room, Retrofit and Hilt have their own test suites. I test my use of them, not them.
  • Exact UI layout — pixel positions change constantly; asserting on them is a maintenance tax with little safety in return.

What I always test

I put tests exactly where a bug would be silent, expensive, or hard to reproduce:

  • Parsers — turning a raw Bluetooth LE byte payload into a typed reading. Pure input → output, and a mistake here corrupts data invisibly.
  • Validation & unit conversion — ranges, out-of-spec values, unit math. Wrong here means wrong numbers shown to someone making decisions.
  • ViewModel state transitions — given an event, assert the next UI state.
  • DAO queries — against an in-memory database, so I know my SQL and mappings actually behave.

The parser: a perfect TDD target

A sensor payload is deterministic: the same bytes must always produce the same reading. I can't conjure a misbehaving sensor on demand in the office — so the test is my field. I write it first, describing the payload I expect from the hardware, then make it pass:

@Test
fun `parses a well-formed soil reading`() {
    val raw = byteArrayOf(0x01, 0x2C, 0x00, 0xA4.toByte(), /* … */)

    val reading = ReadingParser.parse(raw)

    assertEquals(30.0, reading.moisture, 0.01)
    assertEquals(21.5, reading.soilTemp, 0.01)
}

@Test
fun `rejects a truncated payload instead of guessing`() {
    val raw = byteArrayOf(0x01, 0x2C)   // too short
    assertFailsWith<MalformedReading> { ReadingParser.parse(raw) }
}

That second test matters as much as the first. Hardware sends garbage sometimes, and the correct behaviour is to fail loudly, not store a plausible-looking wrong number.

DAO tests against a real (in-memory) database

For persistence I don't mock the database — I run a real Room instance in memory. It's fast, and it tests the thing that actually breaks: my queries and mappings.

db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
    .allowMainThreadQueries()
    .build()

@Test
fun `readings come back in chronological order`() = runTest {
    dao.insert(reading(takenAt = 200))
    dao.insert(reading(takenAt = 100))

    val out = dao.observeSession(sessionId).first()

    assertEquals(listOf(100L, 200L), out.map { it.takenAt })
}
Test where correctness is load-bearing. Skip where a test only proves the framework still works.

The takeaway

TDD, to me, isn't a coverage number. It's a habit of writing the failing test first for the logic that would hurt to get wrong — parsers, math, state, queries — and having the discipline to leave the rest alone. That's how the test suite stays fast, honest, and worth running on every commit.