Open menu with table of contents Android Excursion
Logo of Stuttgart Media University for light theme Logo of Stuttgart Media University for dark theme
Android Development

Android Excursion

Stuttgart Media University

1 Agenda

  • Testing in Android: why, test pyramid, where tests live
  • Compose UI tests with the assignment test as example
  • Unit tests with JUnit and kotlinx-coroutines-test
  • Pointers for the project categories
  • Popular 3rd party libraries
  • Android Automotive

2 Testing in Android: Why

  • The project is graded on "Automated Testing (Unit Test, UI Test)", 5 of 70 points
  • Tests document what the code is supposed to do and tell you immediately when a refactoring breaks it
  • The assignments are graded by exactly such a test: AssignmentNTest.kt clicks through your app on the emulator
  • In MVVM the layers are testable in isolation: a ViewModel with a fake repository on the JVM, the UI with a Compose test on the device, Room with an in-memory database
  • Android distinguishes two kinds of tests by where they run:
Kind Folder Runs on Speed Typical target
Local unit test app/src/test/ JVM of your computer milliseconds Kotlin functions, ViewModel, repository
Instrumented test app/src/androidTest/ Emulator or device seconds Compose UI, Room, Android framework code

3 Testing in Android: Test Pyramid

  • Many small unit tests (base of the pyramid): fast, no Android dependency, run on every build and in CI
  • Some integration tests: ViewModel + repository + in-memory Room, or a screen with a fake ViewModel
  • Few end-to-end UI tests (top): start the real MainActivity, click through the app like a user. Slow, need an emulator, but the only tests that prove the app actually works
  • Rule of thumb: logic belongs into the ViewModel/repository so it can be unit tested, the UI test only checks that the screens are wired correctly
  • Both kinds use JUnit 4 as the test runner (@Test, @Before, @Rule, assertEquals)
  • Docs: Fundamentals of testing Android apps

4 Compose UI Tests: Setup

Dependencies in app/build.gradle.kts (already in the starter projects):

dependencies {
    androidTestImplementation(libs.androidx.junit)                 // AndroidJUnitRunner
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)        // createAndroidComposeRule, onNodeWithTag, ...
    debugImplementation(libs.androidx.ui.test.manifest)            // lets the test start an Activity
}

The test finds UI elements by their semantics. Give the elements a stable testTag in the UI code (ui/home/HomeScreen.kt):

Button(
    onClick = onSearchClick,
    modifier = Modifier
        .padding(8.dp)
        .testTag("openSearchButton")
) {
    Text(text = stringResource(R.string.open_search_button))
}
  • Tags are invisible to the user and survive text changes and translations, that is why the assignments prescribe them (button, movieItem, openSearchButton, textField, searchButton)

5 Compose UI Tests: Finders, Actions, Assertions

app/src/androidTest/java/de/hdmstuttgart/movietracker/Assignment4Test.kt (excerpt):

class Assignment4Test {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<MainActivity>()   // starts MainActivity for every test

    private fun searchAndSaveFirstResult(query: String) {
        composeTestRule.onNodeWithTag("openSearchButton").performClick()
        composeTestRule.onNodeWithTag("textField").performTextInput(query)
        composeTestRule.onNodeWithTag("searchButton").performClick()
        composeTestRule.onAllNodesWithTag("movieItem")[0].performClick()
    }

    @Test
    fun test1_saveMovies() {
        searchAndSaveFirstResult("Gold")
        waitForMovieItems(1)
        composeTestRule.onAllNodesWithTag("movieItem")[0].assertTextContains("Goldfinger")
    }
}
Step API
Find onNodeWithTag("…"), onAllNodesWithTag("…")[i], onNodeWithText("…"), onNode(hasTestTag("…") and hasText("…"))
Act performClick(), performTextInput("…"), performScrollTo()
Assert assertIsDisplayed(), assertTextContains("…"), assertExists(), assertDoesNotExist()

6 Compose UI Tests: Waiting for Asynchronous Data

Room and Retrofit deliver asynchronously, the list is empty for a moment. Do not use Thread.sleep, wait for a condition:

/** Waits until exactly [count] movie items are shown (the database delivers asynchronously). */
private fun waitForMovieItems(count: Int) {
    composeTestRule.waitUntil(timeoutMillis = 5_000L) {
        composeTestRule.onAllNodesWithTag("movieItem").fetchSemanticsNodes().size == count
    }
}
  • waitUntil polls the condition and fails with a timeout, so a broken app does not block the test run
  • @FixMethodOrder(MethodSorters.NAME_ASCENDING) on the class makes test1_, test2_, ... run in order, used in Assignment4Test because test2_moviesArePersisted expects what test1_saveMovies stored in Room
  • Run in Android Studio: green arrow in the gutter next to the class or method, select the emulator
  • Run from the command line (emulator must be running):
./gradlew connectedAndroidTest
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=de.hdmstuttgart.movietracker.Assignment4Test

7 Unit Tests: JUnit 4 on the JVM

Local unit tests live in app/src/test/, need no emulator and run with ./gradlew test. Dependency:

dependencies {
    testImplementation(libs.junit)       // junit:junit:4.13.2 in gradle/libs.versions.toml
}

Test a pure Kotlin function, e.g. an extension that the UI uses to decide whether a poster exists (OMDb sends "N/A"):

fun Movie.hasPoster(): Boolean = poster.startsWith("http")
class MovieTest {
    private val matrix = Movie(imdbId = "tt0133093", title = "The Matrix", year = "1999", poster = "N/A")

    @Test
    fun hasPoster_isFalseForNotAvailable() {
        assertFalse(matrix.hasPoster())
    }

    @Test
    fun hasPoster_isTrueForUrl() {
        assertTrue(matrix.copy(poster = "https://m.media-amazon.com/x.jpg").hasPoster())
    }
}
  • Naming: methodUnderTest_expectedBehaviour, one assertion idea per test
  • Android classes (Context, Log, ...) are not available on the JVM, keep them out of the code you unit test

8 Unit Tests: A ViewModel with Fakes

The ViewModels only depend on the interfaces MovieRepository and MovieApi, so a test can replace them by fakes:

class FakeMovieRepository : MovieRepository {
    private val movies = MutableStateFlow<List<Movie>>(emptyList())
    override fun getAllMovies(): Flow<List<Movie>> = movies
    override suspend fun insert(movie: Movie) { movies.value += movie }
    override suspend fun delete(movie: Movie) { movies.value -= movie }
}

class FakeMovieApi(private val result: List<Movie>) : MovieApi {
    override suspend fun searchMovies(searchString: String, apiKey: String) =
        SearchResponse(search = result, totalResults = "${result.size}", response = "True", error = null)
}
  • A fake is a small working implementation for tests, no network, no database, deterministic
  • This is the pay-off of the repository interface and the manual dependency injection from assignment 4

9 Unit Tests: A ViewModel with Coroutines

viewModelScope launches on Dispatchers.Main, which does not exist on the JVM. kotlinx-coroutines-test replaces it:

# gradle/libs.versions.toml
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.10.2" }
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
    private val matrix = Movie(imdbId = "tt0133093", title = "The Matrix", year = "1999", poster = "N/A")
    private val repository = FakeMovieRepository()
    private lateinit var viewModel: SearchViewModel

    @Before
    fun setUp() {
        Dispatchers.setMain(UnconfinedTestDispatcher())  // coroutines run immediately in the test
        viewModel = SearchViewModel(repository, FakeMovieApi(listOf(matrix)))
    }

    @After
    fun tearDown() = Dispatchers.resetMain()

    @Test
    fun searchMovies_showsResultsFromApi() = runTest {
        viewModel.searchMovies("Matrix")
        assertEquals(listOf(matrix), viewModel.searchUiState.movieList)
    }

    @Test
    fun saveMovie_insertsIntoRepository() = runTest {
        viewModel.saveMovie(matrix)
        assertEquals(listOf(matrix), repository.getAllMovies().first())
    }
}

10 Pointers: Media and Camera

  • CameraX (androidx.camera:camera-camera2, camera-lifecycle, camera-view, camera-compose): bind a Preview and an ImageCapture use case to the LifecycleOwner, CameraXViewfinder/PreviewView shows the preview, takePicture() writes a JPEG to the MediaStore. Needs the CAMERA runtime permission
  • Photo Picker: rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> ... } lets the user choose an image or video without any storage permission; show the Uri with Coil's AsyncImage
  • Simple alternative without CameraX: ActivityResultContracts.TakePicture() opens the system camera app and stores the photo at a Uri you provide (FileProvider)
  • Docs: CameraX, Photo picker

11 Pointers: Location and Sensors

  • Location: FusedLocationProviderClient from Google Play services (play-services-location, counts as Google library like Maps) combines GPS, Wi-Fi and cell data: getCurrentLocation() or requestLocationUpdates(). Pure framework alternative: LocationManager.requestLocationUpdates(). Both need ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION as runtime permission
  • Sensors: SensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) (also TYPE_GYROSCOPE, TYPE_PRESSURE for barometer/altimeter, TYPE_STEP_COUNTER), register a SensorEventListener in a DisposableEffect and unregister it in onDispose, push the values into a StateFlow in the ViewModel
  • Test on a real device, the emulator only simulates a few sensors (Extended controls > Virtual sensors)
  • Docs: Get the current location, Sensors overview

12 Pointers: Data Storage

  • Room for structured data (assignment 4): entities, DAO with Flow, relations with @Relation, migrations when the schema changes (version, Migration), Room.inMemoryDatabaseBuilder() for tests
  • DataStore for key/value settings: preferencesDataStore delegate, dataStore.data is a Flow<Preferences>, dataStore.edit { it[KEY] = value }. Replaces SharedPreferences, works with coroutines
  • Files: context.filesDir for app-private files, MediaStore for images the user should see in the gallery, ActivityResultContracts.CreateDocument() to export a file the user chooses
  • Graded as "Store app local data using SQL, Key/Value", cloud databases are not allowed
  • Docs: Room, DataStore

13 Pointers: Networking

  • REST + JSON: Retrofit with the Gson converter as in assignment 5. suspend functions in the API interface, try/catch for IOException and HttpException, the result as UiState in the ViewModel
  • GraphQL: a REST @POST with the query as JSON body, parsed with Gson, no extra library needed. XML: XmlPullParser from the framework
  • WebSocket (live data, chat): OkHttpClient().newWebSocket(request, listener), OkHttp is already in the project as dependency of Retrofit
  • Ktor client is the Kotlin multiplatform alternative to Retrofit, mentioned here for completeness, it is not allowed in the project (not AndroidX, not in the exception list)
  • Remember the INTERNET permission and HTTPS (cleartext HTTP is blocked since API 28)
  • Docs: Connect to the network

14 Pointers: Connectivity

  • Bluetooth Low Energy: BluetoothLeScanner.startScan() finds devices, device.connectGatt() opens a connection, BluetoothGattCallback delivers characteristics. Runtime permissions BLUETOOTH_SCAN and BLUETOOTH_CONNECT (Android 12+). Beacons are BLE advertisements, read them from the ScanResult
  • Wi-Fi Direct / local network: WifiP2pManager for device-to-device connections, or a plain ServerSocket in the same Wi-Fi with NsdManager for discovery
  • Nearby Connections (Google Play services): discovery and payload exchange over Bluetooth and Wi-Fi without handling the radios yourself, well suited for the "Party Music Player" or "Live Vote" ideas
  • Two real devices are needed, the emulator has no Bluetooth radio
  • Docs: Bluetooth Low Energy, Wi-Fi Direct, Nearby Connections

15 Pointers: Animation and Graphics

  • Compose animations: animateFloatAsState/animateColorAsState/animateDpAsState animate a value whenever the state changes, AnimatedVisibility for enter/exit, animateContentSize() for growing cards, updateTransition for several values at once, Modifier.animateItem() in a LazyColumn
  • Charts and custom drawing: Canvas(modifier) { drawLine(...); drawArc(...); drawPath(...) } with DrawScope, animate the progress value with animateFloatAsState for an animated chart. No chart library needed
  • Google Maps: maps-compose library, GoogleMap(cameraPositionState = ...) { Marker(state = ...) }, API key in local.properties like the OMDb key
  • Docs: Compose animation, Graphics in Compose, Maps Compose

In the project only Retrofit, Gson, Coil and Google Maps are allowed (plus AndroidX). The rest is what you will meet in real code bases:

Logging Libraries:

Dependency Injection Libraries:

Code Analysis / Linter Libraries:

API Libraries:

Serialization Libraries:

Image Loading Libraries:

Other Libraries:

Not libraries, but part of the build: R8 shrinks and obfuscates release builds (successor of ProGuard)

17 Android Automotive: Getting Started

Android Automotive OS runs directly in the car's head unit (not to be confused with Android Auto, which projects a phone app). Compose apps can be adapted with a few manifest and layout changes.

Codelab Build and test a parked app for Android Automotive OS:

  • Install an Automotive system image and create an Automotive virtual device in the Device Manager
  • Declare <uses-feature android:name="android.hardware.type.automotive" /> and the app category in the manifest
  • Handle fixed orientation, window insets and display cutouts of car screens
  • Pause media while driving with CarUxRestrictionsManager

Further reading: Test using the Android Automotive OS emulator, Add Automotive OS support to your app

18 Summary

  • Local unit tests (src/test) run on the JVM in milliseconds, instrumented tests (src/androidTest) run on the emulator; both use JUnit 4
  • Compose UI tests find nodes by testTag, act with performClick/performTextInput, assert with assertTextContains and wait for asynchronous data with waitUntil, exactly like the AssignmentNTest classes
  • ViewModels are unit tested with fake repository/API implementations and Dispatchers.setMain + runTest from kotlinx-coroutines-test
  • Every project category has a Jetpack or framework API: CameraX and Photo Picker, FusedLocationProviderClient and SensorManager, Room and DataStore, Retrofit, Bluetooth LE and Nearby, Compose animations and Canvas
  • Know the popular libraries, but in the project stick to AndroidX plus Retrofit, Gson, Coil and Google Maps
  • Android Automotive OS apps are Android apps with car specific manifest entries and UX constraints

19 Recap Questions

  • What is the difference between a local unit test and an instrumented test? Where do they live in the project?
  • Why do the assignment tests use testTags instead of the button text?
  • Why is Thread.sleep a bad idea in a UI test and what do you use instead?
  • How can a ViewModel that uses viewModelScope be tested on the JVM?
  • Which API would you use in your project for: picking a photo, reading the accelerometer, storing a setting, connecting to another phone?
  • Why is R8 not a library, and what does it do?

Questions?