Testing

Android Development โ€” Production & Publishing
Course 3 ยท Chapter 2 ยท Testing

๐Ÿงช Testing

Every architectural decision back in Course 2 Chapter 8 โ€” a ViewModel depending on a repository interface, not Room or Retrofit directly โ€” was set up specifically to make this chapter possible. This chapter covers unit tests (fast, no device needed), instrumented tests (need a real or virtual device), and Compose's own UI testing tools, which replace the older Espresso API for Compose screens.

โšก Unit Tests โ€” Fast, No Device Required

Unit tests live in src/test/ and run directly on the local JVM โ€” no emulator, no real device, seconds instead of minutes. They're the right tool for anything that's pure logic: a repository, a use case, a ViewModel, none of which genuinely need actual Android framework code to test.

// A fake implementing the SAME interface from Course 2, Chapter 8 class FakeNoteRepository : NoteRepository { private val _notes = MutableStateFlow<List<Note>>(emptyList()) override val notes: Flow<List<Note>> = _notes.asStateFlow() var refreshCalled = false private set override suspend fun refresh() { refreshCalled = true _notes.value = listOf(Note(id = 1, title = "Test Note", content = "...")) } }

This is exactly why Course 2 Chapter 8's interface-plus-@Binds pattern mattered: NoteViewModel's constructor only ever demanded a NoteRepository, never NoteRepositoryImpl specifically โ€” so a test can hand it this FakeNoteRepository instead, with zero Room, zero Retrofit, zero network or database access anywhere in the test.

@Test fun `refresh updates uiState with notes`() = runTest { val fakeRepo = FakeNoteRepository() val viewModel = NoteViewModel(fakeRepo) viewModel.refresh() advanceUntilIdle() // runs all pending coroutines to completion assertEquals(1, viewModel.uiState.value.notes.size) assertEquals("Test Note", viewModel.uiState.value.notes.first().title) assertTrue(fakeRepo.refreshCalled) }

runTest (from kotlinx-coroutines-test) provides a special coroutine scope built for testing โ€” it runs suspending code in a controlled, virtual-time environment, so delay(2000) anywhere in the code under test doesn't actually make the test wait 2 real seconds. Kotlin function names can be arbitrary strings inside backticks (a Kotlin syntax feature, not test-specific) โ€” a common convention for making test names read as full sentences.

๐Ÿ“ฑ Instrumented Tests โ€” Need a Real (or Virtual) Device

Instrumented tests live in src/androidTest/ and run on an actual Android environment โ€” an emulator or physical device โ€” because they exercise real framework behavior a plain JVM can't fake: launching a real Activity, real Views, actual touch/click dispatch.

Unit Tests vs Instrumented Tests

Unit Tests (src/test/)Instrumented Tests (src/androidTest/)
Runs onLocal JVMEmulator or real device
SpeedFast โ€” secondsSlow โ€” needs a booted device
Good forViewModels, repositories, pure logicReal UI interaction, Activity lifecycle, actual rendering
Android framework accessNone (or a limited fake via Robolectric)Full, real

๐Ÿ‘† Espresso โ€” Testing the View System (Course 1)

Espresso drives and asserts against real Views, for apps (or screens) still using Course 1's XML/View system:

@Test fun clickingButtonUpdatesText() { ActivityScenario.launch(MainActivity::class.java) onView(withId(R.id.submitButton)).perform(click()) onView(withId(R.id.nameLabel)).check(matches(withText("Hello, Philip!"))) }

onView(withId(...)) locates a real View by its resource ID (Course 1, Chapter 3), .perform(click()) genuinely dispatches a click, and .check(matches(...)) asserts on the resulting state โ€” the same click-then-verify pattern behind every UI test, regardless of framework.

๐Ÿ–Œ๏ธ Compose UI Testing โ€” The Modern Equivalent

For Compose screens (Course 2 onward), a parallel API replaces Espresso's View-based lookups with node-based ones:

@get:Rule val composeTestRule = createComposeRule() @Test fun clickingButtonUpdatesText() { composeTestRule.setContent { MyAppTheme { CounterScreen() } } composeTestRule.onNodeWithText("Increment").performClick() composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed() }

Espresso vs Compose Testing

EspressoCompose Testing
Finding an elementonView(withId(R.id.x))onNodeWithText(...) / onNodeWithTag(...)
Action.perform(click()).performClick()
Assertion.check(matches(withText(...))).assertTextEquals(...) / .assertIsDisplayed()
SetupActivityScenario.launch(...)composeTestRule.setContent { ... }

onNodeWithTag(...) (paired with a Modifier.testTag("...") on the composable itself) is the more robust lookup when text alone isn't unique or reliable enough โ€” the Compose-testing equivalent of Espresso's ID-based lookup, since Compose has no R.id resource system to key off of the way the View system does.

Android Testing vs JavaScript Testing

JavaScript (Jest / Testing Library)Android
Pure logic testsJest unit testsJUnit unit tests (src/test/)
UI interaction testsTesting Library (render, screen.getByText, fireEvent)Compose UI tests (onNodeWithText, performClick)
Faking dependenciesjest.mock(...)A fake implementation of an interface (this chapter's FakeNoteRepository)

๐Ÿ’ป Coding Challenges

Challenge 1: A Fake Repository and ViewModel Unit Test

Write a FakeTaskRepository implementing Course 2's TaskRepository interface with a controllable in-memory list, then a unit test for TaskViewModel confirming that calling addTask("Buy milk") results in that task appearing in uiState.value.tasks, using runTest and advanceUntilIdle().

Goal: Practice the fake-dependency unit-testing pattern for a ViewModel.

โ†’ Solution

Challenge 2: A Compose UI Test

Write a Compose UI test for Course 2's CounterScreen composable, using createComposeRule() to set its content, performing two clicks on the Increment button, and asserting the displayed count text reads "Count: 2".

Goal: Practice the full Compose testing setup: rule, setContent, node lookup, action, assertion.

โ†’ Solution

Challenge 3: Choosing the Right Test Type

For each of the following, state whether a unit test or an instrumented/Compose UI test is appropriate, with a one-sentence reason: (a) NoteRepository's refresh() logic with a fake API, (b) confirming a Button's click actually navigates to a new screen, (c) a pure Kotlin function calculating tax on a price, (d) confirming a TextView's text updates after a ViewModel state change.

Goal: Practice the judgment call between test types, not just the syntax of either.

โ†’ Solution

๐Ÿ’ก Testability Was a Design Decision, Not an Afterthought

Every "why does this ViewModel take an interface instead of a concrete class" moment from Course 2 pays off directly in this chapter โ€” a codebase built around concrete classes and singletons everywhere would make this chapter's fakes far harder, or impossible, to write cleanly. Good architecture and testability aren't separate concerns; they're the same decisions, viewed from two angles.

๐ŸŽฏ What's Next

Next chapter: Background Work โ€” WorkManager, foreground services, and scheduling tasks.