kotlinx-coroutines-testAssignmentNTest.kt clicks through your app on the emulatorViewModel with a fake repository on the JVM, the UI with a Compose
test on the device, Room with an in-memory database| 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 |
ViewModel + repository + in-memory Room, or a screen with a fake ViewModelMainActivity, click through the app like a user. Slow, need an
emulator, but the only tests that prove the app actually worksViewModel/repository so it can be unit tested, the UI test only checks that
the screens are wired correctly@Test, @Before, @Rule, assertEquals)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))
}
button, movieItem, openSearchButton, textField, searchButton)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() |
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./gradlew connectedAndroidTest
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=de.hdmstuttgart.movietracker.Assignment4Test
app/build/reports/androidTests/connected/ (HTML) and app/build/outputs/androidTest-results/ (JUnit XML)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())
}
}
methodUnderTest_expectedBehaviour, one assertion idea per testContext, Log, ...) are not available on the JVM, keep them out of the code you unit testThe 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)
}
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())
}
}
runTest runs a suspend block and skips delays. Docs: Testing Kotlin coroutines on Android,
codelab: test a ViewModelandroidx.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 permissionrememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> ... } lets
the user choose an image or video without any storage permission; show the Uri with Coil's AsyncImageActivityResultContracts.TakePicture() opens the system camera app and stores
the photo at a Uri you provide (FileProvider)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 permissionSensorManager.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 ViewModelFlow, relations with @Relation, migrations when
the schema changes (version, Migration), Room.inMemoryDatabaseBuilder() for testspreferencesDataStore delegate, dataStore.data is a Flow<Preferences>,
dataStore.edit { it[KEY] = value }. Replaces SharedPreferences, works with coroutinescontext.filesDir for app-private files, MediaStore for images the user should see in the gallery,
ActivityResultContracts.CreateDocument() to export a file the user choosessuspend functions in the API interface,
try/catch for IOException and HttpException, the result as UiState in the ViewModel@POST with the query as JSON body, parsed with Gson, no extra library needed.
XML: XmlPullParser from the frameworkOkHttpClient().newWebSocket(request, listener), OkHttp is already in the project as
dependency of RetrofitINTERNET permission and HTTPS (cleartext HTTP is blocked since API 28)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 ScanResultWifiP2pManager for device-to-device connections, or a plain ServerSocket in the
same Wi-Fi with NsdManager for discoveryanimateFloatAsState/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 LazyColumnCanvas(modifier) { drawLine(...); drawArc(...); drawPath(...) } with
DrawScope, animate the progress value with animateFloatAsState for an animated chart. No chart library neededmaps-compose library, GoogleMap(cameraPositionState = ...) { Marker(state = ...) }, API key in
local.properties like the OMDb keyIn 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)
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:
<uses-feature android:name="android.hardware.type.automotive" /> and the app category in the manifestCarUxRestrictionsManagerFurther reading: Test using the Android Automotive OS emulator, Add Automotive OS support to your app
src/test) run on the JVM in milliseconds, instrumented tests (src/androidTest) run on the
emulator; both use JUnit 4testTag, act with performClick/performTextInput, assert with assertTextContains
and wait for asynchronous data with waitUntil, exactly like the AssignmentNTest classesViewModels are unit tested with fake repository/API implementations and Dispatchers.setMain +
runTest from kotlinx-coroutines-testFusedLocationProviderClient and
SensorManager, Room and DataStore, Retrofit, Bluetooth LE and Nearby, Compose animations and CanvastestTags instead of the button text?Thread.sleep a bad idea in a UI test and what do you use instead?ViewModel that uses viewModelScope be tested on the JVM?