Introduction to Jetpack Compose Testing
Jetpack Compose revolutionized Android UI development with its declarative approach, and testing Compose UIs requires a fundamentally different mindset compared to traditional View-based testing. Unlike Espresso (as discussed in Espresso & XCUITest: Mastering Native Mobile Testing Frameworks) tests that rely on View hierarchies, Compose testing leverages the semantics tree—a parallel structure that describes UI elements for accessibility and testing purposes.
The Compose testing framework provides a powerful, intuitive API that integrates seamlessly with JUnit and allows you to write tests that are both readable and maintainable. Whether you’re testing simple composables or complex navigation flows, understanding Compose testing fundamentals is essential for ensuring UI reliability.
Setting Up the Testing Environment
Dependencies Configuration
Add the necessary testing dependencies to your build.gradle.kts
:
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
(as discussed in [Mobile Testing in 2025: iOS, Android and Beyond](/blog/mobile-testing-2025-ios-android-beyond)) (as discussed in [Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing](/blog/appium-2-architecture-cloud)) }
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM for version alignment
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose UI Testing
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
// Testing infrastructure
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
Test Structure Overview
Component | Purpose | Location |
---|---|---|
ui-test-junit4 | Core testing APIs and ComposeTestRule | androidTest |
ui-test-manifest | Required for debug builds to launch composables | debug |
JUnit 4 | Test runner and assertions | androidTest |
Espresso | Optional, for View interop testing | androidTest |
Understanding the Semantics Tree
The semantics tree is the foundation of Compose testing. Every composable can provide semantic information through modifiers:
@Composable
fun LoginButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier
.testTag("login_button")
.semantics {
contentDescription = "Login to your account"
role = Role.Button
}
) {
Text("Login")
}
}
Key Semantic Properties
- testTag: Primary identifier for finding nodes
- contentDescription: Accessibility description
- text: Text content of the composable
- role: Semantic role (Button, Checkbox, etc.)
- stateDescription: Current state description
Finding Nodes with Matchers
// By test tag
composeTestRule.onNodeWithTag("login_button")
// By text
composeTestRule.onNodeWithText("Login")
// By content description
composeTestRule.onNodeWithContentDescription("Login to your account")
// Multiple nodes
composeTestRule.onAllNodesWithTag("list_item")
// Complex matchers
composeTestRule.onNode(
hasText("Login") and hasClickAction() and isEnabled()
)
ComposeTestRule and Test Lifecycle
Creating Test Rules
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginButton_whenClicked_triggersCallback() {
var loginClicked = false
composeTestRule.setContent {
LoginButton(onClick = { loginClicked = true })
}
composeTestRule.onNodeWithTag("login_button")
.performClick()
assert(loginClicked)
}
}
Test Rule Variants
Rule Type | Use Case | Creation Method |
---|---|---|
createComposeRule() | Testing individual composables | Manual content setting |
createAndroidComposeRule<Activity>() | Testing with Activity context | Automatic Activity launch |
createEmptyComposeRule() | Advanced scenarios with custom setup | Manual Activity control |
Controlling Test Timing
@Test
fun animation_completesSuccessfully() {
composeTestRule.setContent {
AnimatedComponent()
}
// Wait for idle (animations complete)
composeTestRule.waitForIdle()
// Wait for specific condition
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithTag("animated_item")
.fetchSemanticsNodes().size == 5
}
}
Testing UI Components with Compose APIs
Basic Assertions
@Test
fun userProfile_displaysCorrectInformation() {
val user = User("John Doe", "john@example.com")
composeTestRule.setContent {
UserProfileCard(user = user)
}
composeTestRule.onNodeWithTag("user_name")
.assertExists()
.assertIsDisplayed()
.assertTextEquals("John Doe")
composeTestRule.onNodeWithTag("user_email")
.assertTextContains("john@example.com")
}
Testing Visibility and Enabled State
@Test
fun submitButton_isDisabledWhenFormIncomplete() {
composeTestRule.setContent {
RegistrationForm(
email = "",
password = ""
)
}
composeTestRule.onNodeWithTag("submit_button")
.assertIsNotEnabled()
// Fill form fields
composeTestRule.onNodeWithTag("email_field")
.performTextInput("user@example.com")
composeTestRule.onNodeWithTag("password_field")
.performTextInput("SecurePass123")
composeTestRule.onNodeWithTag("submit_button")
.assertIsEnabled()
}
Testing Lists and Collections
@Test
fun productList_displaysAllItems() {
val products = listOf(
Product("Laptop", "$999"),
Product("Mouse", "$29"),
Product("Keyboard", "$79")
)
composeTestRule.setContent {
ProductList(products = products)
}
composeTestRule.onAllNodesWithTag("product_item")
.assertCountEquals(3)
composeTestRule.onAllNodesWithTag("product_item")[0]
.assertTextContains("Laptop")
.assertTextContains("$999")
}
Testing State Changes and Recomposition
State-Driven UI Testing
@Test
fun counter_incrementsCorrectly() {
composeTestRule.setContent {
CounterScreen()
}
// Initial state
composeTestRule.onNodeWithTag("counter_text")
.assertTextEquals("Count: 0")
// Trigger state change
composeTestRule.onNodeWithTag("increment_button")
.performClick()
// Verify recomposition
composeTestRule.onNodeWithTag("counter_text")
.assertTextEquals("Count: 1")
}
Testing ViewModel Integration
@Test
fun loadingState_showsProgressIndicator() {
val viewModel = ProfileViewModel().apply {
_uiState.value = UiState.Loading
}
composeTestRule.setContent {
ProfileScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithTag("loading_indicator")
.assertIsDisplayed()
// Simulate data loaded
viewModel._uiState.value = UiState.Success(userData)
composeTestRule.onNodeWithTag("loading_indicator")
.assertDoesNotExist()
composeTestRule.onNodeWithTag("user_content")
.assertIsDisplayed()
}
Testing User Interactions
Click and Tap Interactions
@Test
fun favoriteButton_togglesState() {
var isFavorite by mutableStateOf(false)
composeTestRule.setContent {
FavoriteButton(
isFavorite = isFavorite,
onToggle = { isFavorite = !isFavorite }
)
}
composeTestRule.onNodeWithTag("favorite_button")
.assertHasContentDescription("Add to favorites")
.performClick()
composeTestRule.onNodeWithTag("favorite_button")
.assertHasContentDescription("Remove from favorites")
}
Text Input Testing
@Test
fun searchField_filtersResults() {
composeTestRule.setContent {
SearchableList(items = testItems)
}
composeTestRule.onNodeWithTag("search_field")
.performTextInput("Android")
composeTestRule.waitForIdle()
composeTestRule.onAllNodesWithTag("search_result")
.assertCountEquals(3)
// Clear and verify
composeTestRule.onNodeWithTag("search_field")
.performTextClearance()
composeTestRule.onAllNodesWithTag("search_result")
.assertCountEquals(testItems.size)
}
Gesture Testing
@Test
fun imageGallery_supportsSwipeGestures() {
composeTestRule.setContent {
ImageGallery(images = imageList)
}
// Current image indicator
composeTestRule.onNodeWithTag("image_indicator")
.assertTextEquals("1 of 5")
// Perform swipe left
composeTestRule.onNodeWithTag("image_pager")
.performTouchInput {
swipeLeft()
}
composeTestRule.onNodeWithTag("image_indicator")
.assertTextEquals("2 of 5")
}
Testing Navigation in Compose
NavController Testing
@Test
fun navigation_navigatesToDetailScreen() {
lateinit var navController: NavHostController
composeTestRule.setContent {
navController = rememberNavController()
AppNavGraph(navController = navController)
}
// Click on item to navigate
composeTestRule.onNodeWithTag("item_1")
.performClick()
// Verify navigation occurred
val currentRoute = navController.currentBackStackEntry?.destination?.route
assert(currentRoute == "detail/1")
// Verify detail screen displayed
composeTestRule.onNodeWithTag("detail_screen")
.assertIsDisplayed()
}
Back Navigation Testing
@Test
fun backButton_navigatesToPreviousScreen() {
lateinit var navController: NavHostController
composeTestRule.setContent {
navController = rememberNavController()
AppNavGraph(
navController = navController,
startDestination = "detail/123"
)
}
composeTestRule.onNodeWithTag("back_button")
.performClick()
assert(navController.currentDestination?.route == "home")
}
Mocking and Dependency Injection
Repository Mocking
class ProductScreenTest {
private lateinit var fakeRepository: FakeProductRepository
@Before
fun setup() {
fakeRepository = FakeProductRepository()
}
@Test
fun errorState_displaysErrorMessage() {
fakeRepository.setError("Network error")
val viewModel = ProductViewModel(fakeRepository)
composeTestRule.setContent {
ProductScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithTag("error_message")
.assertIsDisplayed()
.assertTextContains("Network error")
}
}
class FakeProductRepository : ProductRepository {
private var error: String? = null
private var products: List<Product> = emptyList()
fun setError(message: String) {
error = message
}
override suspend fun getProducts(): Result<List<Product>> {
return error?.let { Result.failure(Exception(it)) }
?: Result.success(products)
}
}
Hilt Testing Integration
@HiltAndroidTest
class DashboardScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var repository: TestRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun dashboard_loadsDataSuccessfully() {
repository.setTestData(dashboardData)
composeTestRule.onNodeWithTag("dashboard_content")
.assertIsDisplayed()
}
}
Screenshot Testing for Compose
Paparazzi Integration
class ButtonScreenshotTest {
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_5,
theme = "android:Theme.Material3.DayNight"
)
@Test
fun primaryButton_defaultState() {
paparazzi.snapshot {
PrimaryButton(text = "Click Me", onClick = {})
}
}
@Test
fun primaryButton_disabledState() {
paparazzi.snapshot {
PrimaryButton(
text = "Click Me",
onClick = {},
enabled = false
)
}
}
}
Shot Library for Android
@RunWith(ScreenshotTestRunner::class)
class ProductCardScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun productCard_lightTheme() {
composeTestRule.setContent {
MyAppTheme(darkTheme = false) {
ProductCard(product = sampleProduct)
}
}
compareScreenshot(composeTestRule)
}
}
Performance Testing
Recomposition Tracking
@Test
fun lazyList_minimizesRecompositions() {
var recompositionCount = 0
composeTestRule.setContent {
LaunchedEffect(Unit) {
snapshotFlow { recompositionCount }
.collect { println("Recompositions: $it") }
}
LazyColumn {
items(100) { index ->
RecomposeHighlighter {
recompositionCount++
ListItem(index = index)
}
}
}
}
// Scroll and verify minimal recompositions
composeTestRule.onNodeWithTag("lazy_column")
.performScrollToIndex(50)
composeTestRule.waitForIdle()
// Only visible items should recompose
assert(recompositionCount < 120) // ~20 visible items buffer
}
Benchmark Testing
@RunWith(AndroidJUnit4::class)
class ComposeBenchmarkTest {
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun complexList_scrollPerformance() {
benchmarkRule.measureRepeated {
composeTestRule.setContent {
ComplexProductList(items = testData)
}
runWithTimingDisabled {
composeTestRule.waitForIdle()
}
composeTestRule.onNodeWithTag("product_list")
.performScrollToIndex(100)
}
}
}
CI/CD Integration
GitHub Actions Configuration
name: Compose UI Tests
on:
pull_request:
branches: [ main, develop ]
push:
branches: [ main ]
jobs:
instrumentation-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run Compose tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
script: ./gradlew connectedAndroidTest
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v3
with:
name: test-reports
path: app/build/reports/androidTests/
Test Sharding
# Shard tests across multiple devices
./gradlew connectedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.numShards=4 \
-Pandroid.testInstrumentationRunnerArguments.shardIndex=0
Best Practices and Common Patterns
1. Semantic Content Over Test Tags
Avoid:
Button(
onClick = onClick,
modifier = Modifier.testTag("submit_button")
) {
Text("Submit")
}
composeTestRule.onNodeWithTag("submit_button").performClick()
Prefer:
Button(
onClick = onClick,
modifier = Modifier.semantics {
contentDescription = "Submit form"
}
) {
Text("Submit")
}
composeTestRule.onNodeWithContentDescription("Submit form").performClick()
2. Extract Test Helpers
object ComposeTestHelpers {
fun SemanticsNodeInteraction.assertHasTextAndIsEnabled(text: String) {
assertTextEquals(text)
assertIsEnabled()
}
fun ComposeTestRule.waitForNodeWithTag(
tag: String,
timeoutMillis: Long = 3000
) {
waitUntil(timeoutMillis) {
onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
}
}
}
3. Test Isolation
@Test
fun isolatedTest_resetsState() {
composeTestRule.setContent {
// Fresh state for each test
var count by remember { mutableStateOf(0) }
CounterComponent(
count = count,
onIncrement = { count++ }
)
}
// Test specific behavior without side effects
}
4. Meaningful Assertions
Bad Practice | Good Practice |
---|---|
assertExists() alone | assertIsDisplayed() + content verification |
Multiple performClick() without waits | performClick() + waitForIdle() |
Testing implementation details | Testing user-visible behavior |
Hard-coded delays | waitUntil() with conditions |
5. Accessibility-First Testing
@Test
fun form_isAccessible() {
composeTestRule.setContent {
RegistrationForm()
}
// Verify semantic properties
composeTestRule.onNodeWithTag("email_field")
.assertHasContentDescription("Email address")
.assert(hasImeAction(ImeAction.Next))
composeTestRule.onNodeWithTag("password_field")
.assertHasContentDescription("Password")
.assert(hasImeAction(ImeAction.Done))
.assert(hasPasswordSemantics())
}
6. Test Data Management
object TestData {
val sampleUser = User(
id = "test_123",
name = "Test User",
email = "test@example.com"
)
val productList = List(20) { index ->
Product(
id = "product_$index",
name = "Product $index",
price = (index + 1) * 10.0
)
}
}
Conclusion
Jetpack Compose testing provides a robust framework for ensuring UI quality in modern Android applications. By leveraging the semantics tree, ComposeTestRule, and comprehensive testing APIs, you can write maintainable tests that verify both functionality and user experience. Key takeaways:
- Understand the semantics tree as the foundation of Compose testing
- Use appropriate matchers for finding UI elements reliably
- Test state changes and recomposition to ensure reactive UI behavior
- Implement comprehensive interaction testing for user flows
- Integrate screenshot testing for visual regression detection
- Optimize for CI/CD with proper test sharding and reporting
- Follow accessibility-first practices for inclusive testing
As Compose continues to evolve, staying updated with testing best practices ensures your Android applications remain reliable, performant, and accessible to all users.