Skip to content

Commit 484fba6

Browse files
committed
Add Compose Multiplatform UI tests in commonTest
This commit adds comprehensive UI tests for Compose Multiplatform components that can run across Android, iOS, Desktop, and Web platforms. Changes: - Add compose.uiTest dependency to commonTest in build.gradle.kts - Create PeopleInSpaceRepositoryFake for consistent test data - Add ComposeMultiplatformUiTests.kt: Basic UI component tests for CoordinateDisplay - Add ISSPositionUiTests.kt: Tests for ISS position display with realistic data - Add ViewModelUiTests.kt: Advanced tests demonstrating ViewModel integration - Add TestTagExampleTests.kt: Best practices for using test tags in UI tests - Add comprehensive README.md documenting the testing approach and patterns The tests use runComposeUiTest instead of platform-specific createComposeRule, allowing them to run on multiple platforms. They demonstrate: - Testing simple composables and complex UI components - Integration with ViewModels and StateFlow - Test tag usage and best practices - Coroutine test dispatcher management - Data-driven testing patterns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 356f08b commit 484fba6

File tree

7 files changed

+870
-0
lines changed

7 files changed

+870
-0
lines changed

common/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ kotlin {
7979
implementation(libs.koin.test)
8080
implementation(libs.kotlinx.coroutines.test)
8181
implementation(kotlin("test"))
82+
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
83+
implementation(compose.uiTest)
8284
}
8385

8486
androidMain.dependencies {
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dev.johnoreilly.peopleinspace
2+
3+
import dev.johnoreilly.common.remote.Assignment
4+
import dev.johnoreilly.common.remote.IssPosition
5+
import dev.johnoreilly.common.repository.PeopleInSpaceRepositoryInterface
6+
import kotlinx.coroutines.flow.Flow
7+
import kotlinx.coroutines.flow.flowOf
8+
9+
class PeopleInSpaceRepositoryFake: PeopleInSpaceRepositoryInterface {
10+
val peopleList = listOf(
11+
Assignment("ISS", "Chris Cassidy", "https://example.com/cassidy.jpg", "American astronaut", "USA"),
12+
Assignment("ISS", "Anatoly Ivanishin", "https://example.com/ivanishin.jpg", "Russian cosmonaut", "Russia"),
13+
Assignment("ISS", "Ivan Vagner", "https://example.com/vagner.jpg", "Russian cosmonaut", "Russia")
14+
)
15+
16+
val issPosition = IssPosition(53.2743394, -9.0514163)
17+
18+
override fun fetchPeopleAsFlow(): Flow<List<Assignment>> {
19+
return flowOf(peopleList)
20+
}
21+
22+
override fun pollISSPosition(): Flow<IssPosition> {
23+
return flowOf(issPosition)
24+
}
25+
26+
override suspend fun fetchAndStorePeople() {
27+
// No-op for fake
28+
}
29+
}

0 commit comments

Comments
 (0)