|
| 1 | +# Compose Multiplatform UI Tests |
| 2 | + |
| 3 | +This directory contains Compose Multiplatform UI tests that can run across multiple platforms (Android, iOS, Desktop, Web). |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The tests use the **Compose Multiplatform UI Testing framework** which provides a platform-agnostic way to test Compose UI components. Unlike platform-specific tests (e.g., Android's `createComposeRule()`), these tests use `runComposeUiTest` which works across all supported platforms. |
| 8 | + |
| 9 | +## Test Files |
| 10 | + |
| 11 | +### 1. `ComposeMultiplatformUiTests.kt` |
| 12 | +Basic UI component tests demonstrating: |
| 13 | +- Testing simple composables (`CoordinateDisplay`) |
| 14 | +- Verifying text content is displayed |
| 15 | +- Testing with different input values |
| 16 | +- Basic assertions (`assertIsDisplayed`, `assertExists`) |
| 17 | + |
| 18 | +### 2. `ISSPositionUiTests.kt` |
| 19 | +Tests for ISS position display components: |
| 20 | +- Testing with realistic data from fake repository |
| 21 | +- Testing edge cases (zero coordinates, negative values, extremes) |
| 22 | +- Demonstrating data-driven testing patterns |
| 23 | + |
| 24 | +### 3. `ViewModelUiTests.kt` |
| 25 | +Advanced tests showing ViewModel integration: |
| 26 | +- Testing UI components connected to ViewModels |
| 27 | +- Managing coroutine test dispatchers |
| 28 | +- Testing state flow updates |
| 29 | +- Verifying UI reflects ViewModel state changes |
| 30 | + |
| 31 | +### 4. `TestTagExampleTests.kt` |
| 32 | +Best practices for using test tags: |
| 33 | +- Finding elements by test tag |
| 34 | +- Testing element hierarchies |
| 35 | +- Combining test tags with text matching |
| 36 | +- Constants for test tag names |
| 37 | + |
| 38 | +### 5. `PeopleInSpaceRepositoryFake.kt` |
| 39 | +Test double providing consistent test data for UI tests. |
| 40 | + |
| 41 | +## Key Differences from Platform-Specific Tests |
| 42 | + |
| 43 | +### Android Tests (app module) |
| 44 | +```kotlin |
| 45 | +class MyTest { |
| 46 | + @get:Rule |
| 47 | + val composeTestRule = createComposeRule() |
| 48 | + |
| 49 | + @Test |
| 50 | + fun myTest() { |
| 51 | + composeTestRule.setContent { /* ... */ } |
| 52 | + composeTestRule.onNodeWithText("Hello").assertIsDisplayed() |
| 53 | + } |
| 54 | +} |
| 55 | +``` |
| 56 | + |
| 57 | +### Multiplatform Tests (common module) |
| 58 | +```kotlin |
| 59 | +class MyTest { |
| 60 | + @Test |
| 61 | + fun myTest() = runComposeUiTest { |
| 62 | + setContent { /* ... */ } |
| 63 | + onNodeWithText("Hello").assertIsDisplayed() |
| 64 | + } |
| 65 | +} |
| 66 | +``` |
| 67 | + |
| 68 | +## Running the Tests |
| 69 | + |
| 70 | +### Run all common tests |
| 71 | +```bash |
| 72 | +./gradlew :common:test |
| 73 | +``` |
| 74 | + |
| 75 | +### Run tests for specific platform |
| 76 | +```bash |
| 77 | +# Android |
| 78 | +./gradlew :common:testDebugUnitTest |
| 79 | + |
| 80 | +# iOS (requires macOS) |
| 81 | +./gradlew :common:iosSimulatorArm64Test |
| 82 | + |
| 83 | +# JVM/Desktop |
| 84 | +./gradlew :common:jvmTest |
| 85 | + |
| 86 | +# WebAssembly |
| 87 | +./gradlew :common:wasmJsTest |
| 88 | +``` |
| 89 | + |
| 90 | +### Run tests in IDE |
| 91 | +- IntelliJ IDEA/Android Studio: Right-click on test file or test method and select "Run" |
| 92 | +- Tests will run on the JVM by default when run from IDE |
| 93 | +- To run on specific platform, use Gradle commands |
| 94 | + |
| 95 | +## Test Structure |
| 96 | + |
| 97 | +All tests follow this pattern: |
| 98 | + |
| 99 | +```kotlin |
| 100 | +@Test |
| 101 | +fun testName() = runComposeUiTest { |
| 102 | + // 1. Setup (if needed) |
| 103 | + val viewModel = MyViewModel(fakeRepository) |
| 104 | + |
| 105 | + // 2. Set content |
| 106 | + setContent { |
| 107 | + MaterialTheme { |
| 108 | + MyComposable(viewModel) |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + // 3. Advance time (for async operations) |
| 113 | + testDispatcher.scheduler.advanceUntilIdle() |
| 114 | + waitForIdle() |
| 115 | + |
| 116 | + // 4. Assert |
| 117 | + onNodeWithText("Expected Text").assertIsDisplayed() |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +## Common Test Assertions |
| 122 | + |
| 123 | +### Existence |
| 124 | +```kotlin |
| 125 | +onNodeWithTag("myTag").assertExists() |
| 126 | +onNodeWithTag("myTag").assertDoesNotExist() |
| 127 | +``` |
| 128 | + |
| 129 | +### Visibility |
| 130 | +```kotlin |
| 131 | +onNodeWithText("Hello").assertIsDisplayed() |
| 132 | +onNodeWithText("Hidden").assertIsNotDisplayed() |
| 133 | +``` |
| 134 | + |
| 135 | +### Text Content |
| 136 | +```kotlin |
| 137 | +onNodeWithTag("title").assertTextEquals("Hello, World!") |
| 138 | +onNodeWithText("Hello", substring = true).assertExists() |
| 139 | +``` |
| 140 | + |
| 141 | +### Hierarchy |
| 142 | +```kotlin |
| 143 | +onNodeWithTag("container") |
| 144 | + .onChildren() |
| 145 | + .assertCountEquals(5) |
| 146 | + |
| 147 | +onNodeWithTag("list") |
| 148 | + .onChildAt(0) |
| 149 | + .assertTextContains("First Item") |
| 150 | +``` |
| 151 | + |
| 152 | +### Interactions |
| 153 | +```kotlin |
| 154 | +onNodeWithTag("button").performClick() |
| 155 | +onNodeWithTag("textField").performTextInput("Hello") |
| 156 | +onNodeWithTag("scrollable").performScrollTo() |
| 157 | +``` |
| 158 | + |
| 159 | +## Best Practices |
| 160 | + |
| 161 | +### 1. Use Test Tags |
| 162 | +Always add test tags to key UI elements: |
| 163 | +```kotlin |
| 164 | +LazyColumn( |
| 165 | + modifier = Modifier.testTag("PersonList") |
| 166 | +) { |
| 167 | + items(people) { person -> |
| 168 | + PersonView(person, modifier = Modifier.testTag("person_${person.id}")) |
| 169 | + } |
| 170 | +} |
| 171 | +``` |
| 172 | + |
| 173 | +### 2. Define Test Tag Constants |
| 174 | +```kotlin |
| 175 | +object TestTags { |
| 176 | + const val PERSON_LIST = "PersonList" |
| 177 | + const val ISS_MAP = "ISSMap" |
| 178 | + const val REFRESH_BUTTON = "RefreshButton" |
| 179 | +} |
| 180 | +``` |
| 181 | + |
| 182 | +### 3. Use Fake Repositories |
| 183 | +Create test doubles that provide consistent, predictable data: |
| 184 | +```kotlin |
| 185 | +class PeopleInSpaceRepositoryFake : PeopleInSpaceRepositoryInterface { |
| 186 | + val peopleList = listOf(/* test data */) |
| 187 | + override fun fetchPeopleAsFlow() = flowOf(peopleList) |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +### 4. Test State Changes |
| 192 | +Verify UI responds to state updates: |
| 193 | +```kotlin |
| 194 | +@Test |
| 195 | +fun testLoadingState() = runComposeUiTest { |
| 196 | + setContent { MyScreen(uiState = UiState.Loading) } |
| 197 | + onNodeWithTag("loadingIndicator").assertExists() |
| 198 | +} |
| 199 | + |
| 200 | +@Test |
| 201 | +fun testSuccessState() = runComposeUiTest { |
| 202 | + setContent { MyScreen(uiState = UiState.Success(data)) } |
| 203 | + onNodeWithTag("content").assertExists() |
| 204 | +} |
| 205 | +``` |
| 206 | + |
| 207 | +### 5. Test User Interactions |
| 208 | +```kotlin |
| 209 | +@Test |
| 210 | +fun testButtonClick() = runComposeUiTest { |
| 211 | + var clicked = false |
| 212 | + setContent { |
| 213 | + Button(onClick = { clicked = true }) { |
| 214 | + Text("Click Me") |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + onNodeWithText("Click Me").performClick() |
| 219 | + // Assert on state change |
| 220 | +} |
| 221 | +``` |
| 222 | + |
| 223 | +## Testing ViewModels |
| 224 | + |
| 225 | +When testing components with ViewModels: |
| 226 | + |
| 227 | +1. **Setup test dispatcher**: |
| 228 | +```kotlin |
| 229 | +private val testDispatcher = StandardTestDispatcher() |
| 230 | + |
| 231 | +@BeforeTest |
| 232 | +fun setup() { |
| 233 | + Dispatchers.setMain(testDispatcher) |
| 234 | +} |
| 235 | + |
| 236 | +@AfterTest |
| 237 | +fun tearDown() { |
| 238 | + Dispatchers.resetMain() |
| 239 | +} |
| 240 | +``` |
| 241 | + |
| 242 | +2. **Advance time for coroutines**: |
| 243 | +```kotlin |
| 244 | +testDispatcher.scheduler.advanceUntilIdle() |
| 245 | +waitForIdle() |
| 246 | +``` |
| 247 | + |
| 248 | +3. **Use fake repositories**: |
| 249 | +```kotlin |
| 250 | +val viewModel = MyViewModel(fakeRepository) |
| 251 | +``` |
| 252 | + |
| 253 | +## Platform-Specific Considerations |
| 254 | + |
| 255 | +### Android |
| 256 | +- Tests run on JVM by default (Robolectric) |
| 257 | +- Can run on emulator/device with `testDebugUnitTest` |
| 258 | + |
| 259 | +### iOS |
| 260 | +- Requires macOS to run |
| 261 | +- Uses iOS Simulator |
| 262 | + |
| 263 | +### Desktop (JVM) |
| 264 | +- Runs natively on JVM |
| 265 | +- Fastest platform for local testing |
| 266 | + |
| 267 | +### Web (WASM) |
| 268 | +- Requires WebAssembly setup |
| 269 | +- May have limitations with certain APIs |
| 270 | + |
| 271 | +## Limitations |
| 272 | + |
| 273 | +### Current Limitations of Multiplatform UI Testing: |
| 274 | + |
| 275 | +1. **Platform-specific components**: `expect/actual` composables (like `ISSMapView`) may need platform-specific tests or mock implementations |
| 276 | + |
| 277 | +2. **Some APIs**: Certain platform-specific APIs may not be available in common tests |
| 278 | + |
| 279 | +3. **Screenshots**: Screenshot testing requires platform-specific implementations |
| 280 | + |
| 281 | +### Workarounds: |
| 282 | + |
| 283 | +1. **Mock expect functions**: Create test implementations of expect functions |
| 284 | +2. **Test interfaces**: Test against interfaces rather than implementations |
| 285 | +3. **Separate platform tests**: Keep platform-specific UI tests in platform modules |
| 286 | + |
| 287 | +## Examples from Project |
| 288 | + |
| 289 | +### Testing CoordinateDisplay |
| 290 | +```kotlin |
| 291 | +@Test |
| 292 | +fun testCoordinateDisplay() = runComposeUiTest { |
| 293 | + setContent { |
| 294 | + CoordinateDisplay(label = "Latitude", value = "53.27") |
| 295 | + } |
| 296 | + onNodeWithText("Latitude").assertIsDisplayed() |
| 297 | + onNodeWithText("53.27").assertIsDisplayed() |
| 298 | +} |
| 299 | +``` |
| 300 | + |
| 301 | +### Testing with ViewModel |
| 302 | +```kotlin |
| 303 | +@Test |
| 304 | +fun testWithViewModel() = runComposeUiTest { |
| 305 | + val viewModel = ISSPositionViewModel(fakeRepository) |
| 306 | + |
| 307 | + setContent { |
| 308 | + ISSPositionContent(viewModel) |
| 309 | + } |
| 310 | + |
| 311 | + testDispatcher.scheduler.advanceUntilIdle() |
| 312 | + waitForIdle() |
| 313 | + |
| 314 | + onNodeWithText("53.2743394").assertIsDisplayed() |
| 315 | +} |
| 316 | +``` |
| 317 | + |
| 318 | +## Resources |
| 319 | + |
| 320 | +- [Compose Multiplatform Testing Docs](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-test.html) |
| 321 | +- [Compose Testing Cheatsheet](https://developer.android.com/jetpack/compose/testing-cheatsheet) |
| 322 | +- [Testing State in Compose](https://developer.android.com/jetpack/compose/testing#test-state) |
| 323 | + |
| 324 | +## Contributing |
| 325 | + |
| 326 | +When adding new UI tests: |
| 327 | +1. Follow the existing naming conventions |
| 328 | +2. Add test tags to new composables |
| 329 | +3. Create test composables for complex scenarios |
| 330 | +4. Document any platform-specific limitations |
| 331 | +5. Keep tests fast and focused |
0 commit comments