Course outline

Android development / Intermediate: state, data, and inclusive features / Lesson 11

Testing and evidence-based debugging

Build confidence through focused tests and isolate failures using reproducible evidence rather than timing guesses.

About 4 min readContent reviewed
On this page

Lesson goals

  • Choose local, integration, and UI tests by the risk under investigation.
  • Use deterministic coroutine tests and meaningful UI assertions.

Test at the smallest useful boundary

A pure validation function can run in a local JVM test without Android. A DAO needs an appropriate database integration environment, and actual permission or lifecycle behavior requires Android-aware testing. A UI test checks user-observable behavior through semantics. These tests answer different questions: a passing mapper test cannot prove that a manifest is correct, and a screenshot cannot prove a repository preserves records after a failed refresh. Choose a small number of representative end-to-end journeys and many focused checks where business rules can be isolated reliably.

Assert a behavior with a concrete example

A useful test names a rule, supplies input that distinguishes correct behavior from a plausible mistake, and checks the relevant result. Avoid mirroring implementation line by line or asserting incidental private details. For validation, include a boundary value and an invalid value rather than only an easy happy path. Keep synthetic data small enough to inspect. When a bug is reproduced, first write or identify the narrowest failing behavior; that evidence prevents a broad refactor from accidentally hiding the symptom without repairing its cause.

kotlin Local tests for the earlier pure Kotlin parser
class ReadingValidationTest {
    @Test fun negativePagesAreRejected() {
        val result = parseItem("book-1", "Kotlin", "-1")
        assertNull(result)
    }

    @Test fun zeroPagesAndTrimmedTitleAreAccepted() {
        val result = parseItem("book-1", "  Kotlin  ", "0")
        assertEquals(ReadingItem("book-1", "Kotlin", 0), result)
    }
}

Place under src/test with the project’s configured JUnit dependency and Test/assert imports. Include ReadingItem and parseItem from the Kotlin-modeling lesson. These assertions test validation policy rather than Android behavior.

Control asynchronous time

Coroutine tests should inject test dispatchers that share a scheduler and run work through runTest. Advancing virtual time is more reliable than sleeping and hoping a callback arrives. A ViewModel using Main needs a test Main dispatcher installed and reset with the project’s supported test rule. Test cancellation and reversed completion order explicitly. A fake dependency should expose the scenario the test needs, not reimplement the real service in miniature. Keep one real integration test for important protocol or database assumptions that a fake cannot validate.

Test the user contract through semantics

Compose test APIs locate nodes through text, roles, content descriptions, or explicit test tags. Prefer meaningful semantics and observable outcomes over fragile coordinates. The example verifies that editing reports the proposed value; it does not need a backend. In a full screen test, verify that the new value appears and that validation errors are communicated. Be aware of merged semantics when searching for nested nodes. A test that cannot find a visually obvious control may reveal an accessibility problem, not merely a locator problem.

kotlin An instrumented Compose event-contract test
class TitleFieldTest {
    @get:Rule val compose = createComposeRule()

    @Test fun editingReportsProposedTitle() {
        var reported = ""
        compose.setContent {
            TitleField(title = "", onTitleChange = { reported = it })
        }
        compose.onNode(hasSetTextAction()).performTextInput("Kotlin")
        compose.runOnIdle { assertEquals("Kotlin", reported) }
    }
}

Use compatible Compose UI test and Android test dependencies under src/androidTest, plus JUnit and Compose test imports. The content contains one text field. This is an event test; a stateful editor test should additionally assert the displayed text.

Collect evidence before changing code

For a crash, record the exception type, first relevant application frame, device API, variant, and reproduction steps. Read causes in the exception chain and distinguish the original failure from cleanup noise. Use breakpoints and structured redacted logs to inspect ownership and state transitions. For a visual defect, capture window size, font scale, locale, and input method. After a fix, repeat the original reproduction and a nearby boundary case. A successful rebuild proves compilation; it does not prove that the runtime behavior under investigation changed.

Common errors and fixes

A test passes only with a long sleep

Why it happens
Wall-clock timing hides uncontrolled asynchronous work.
How to fix it
Use a shared test scheduler or explicit synchronization.
Prevent it next time
Make dispatchers and completion signals injectable.

A fake-backed test misses a broken query

Why it happens
The fake never executes actual SQL.
How to fix it
Add a focused DAO integration test.
Prevent it next time
Match each risk to a boundary that can observe it.

A UI test taps the wrong control after resizing

Why it happens
It relies on hardcoded screen coordinates.
How to fix it
Locate controls through stable semantics.
Prevent it next time
Test multiple sizes and inspect accessibility meaning.

Check your understanding

1. A mapper unit test passes but the app crashes only after an upgrade. Which additional test addresses the likely missing boundary?

Show worked solution

Use a database migration integration test starting from the previous exported schema and representative records. Verify the migration reaches the new schema and retains required values. A fresh in-memory database cannot reveal an upgrade path defect because it never executes the old-to-new transformation.

2. A bug report says that saving sometimes shows an old title. Define evidence that distinguishes stale network ordering from a UI observation problem.

Show worked solution

Record redacted operation ids and state versions at request start, repository commit, ViewModel publication, and UI observation. Reproduce with reversed completion order using a fake. If durable state is correct but UI never observes its version, inspect state mutation and collection; otherwise inspect ordering and conflict policy.

Key takeaways

  • A test is useful only for behavior it can actually observe.
  • Controlled time and dependencies make failures reproducible.
  • Debugging proceeds from evidence to a narrow hypothesis.

Official references