@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/docs/facts.json +6 -6
- package/manifest.json +55 -34
- package/package.json +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +7 -3
- package/pipeline/schemas/prefs.schema.json +4 -0
- package/pipeline/scripts/usage-register.mjs +2 -0
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -10,483 +10,189 @@ ViewModel tests with Turbine and TestDispatcher, and screenshot tests with
|
|
|
10
10
|
Roborazzi/Paparazzi. Targets 2024-2025 testing libraries and Kotlin coroutines
|
|
11
11
|
test APIs.
|
|
12
12
|
|
|
13
|
+
Full, copyable code for every section below lives in
|
|
14
|
+
[references/patterns.md](references/patterns.md). Load it when you need a
|
|
15
|
+
complete example; this guide keeps the decisions and short snippets.
|
|
16
|
+
|
|
13
17
|
## Contents
|
|
14
18
|
|
|
15
19
|
- [ComposeTestRule Setup](#composetestrule-setup)
|
|
16
20
|
- [Finding Nodes](#finding-nodes)
|
|
21
|
+
- [Test Tags from a Generated Registry](#test-tags-from-a-generated-registry)
|
|
17
22
|
- [Performing Actions](#performing-actions)
|
|
18
23
|
- [Assertions](#assertions)
|
|
19
24
|
- [Testing State Changes](#testing-state-changes)
|
|
25
|
+
- [ViewModel Test Base Class](#viewmodel-test-base-class)
|
|
20
26
|
- [ViewModel Testing with Turbine](#viewmodel-testing-with-turbine)
|
|
21
27
|
- [TestDispatcher and runTest](#testdispatcher-and-runtest)
|
|
22
28
|
- [Screenshot Testing](#screenshot-testing)
|
|
29
|
+
- [Compose Preview Screenshot Testing](#compose-preview-screenshot-testing)
|
|
23
30
|
- [Do's and Don'ts](#dos-and-donts)
|
|
24
31
|
- [Troubleshooting](#troubleshooting)
|
|
25
32
|
- [Review Checklist](#review-checklist)
|
|
26
33
|
|
|
27
34
|
## ComposeTestRule Setup
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
Use `createComposeRule()` for pure composable tests with no activity. Use
|
|
37
|
+
`createAndroidComposeRule<MainActivity>()` for instrumented tests, and pair it
|
|
38
|
+
with `HiltAndroidRule` (ordered rules, `hiltRule.inject()` in `@Before`) when
|
|
39
|
+
the screen needs injected dependencies.
|
|
30
40
|
|
|
31
41
|
```kotlin
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
val composeTestRule = createComposeRule()
|
|
36
|
-
|
|
37
|
-
@Test
|
|
38
|
-
fun primaryButton_displaysLabel() {
|
|
39
|
-
composeTestRule.setContent {
|
|
40
|
-
AppTheme {
|
|
41
|
-
PrimaryButton(label = "Book Flight", onClick = {})
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
composeTestRule
|
|
46
|
-
.onNodeWithText("Book Flight")
|
|
47
|
-
.assertIsDisplayed()
|
|
48
|
-
}
|
|
49
|
-
}
|
|
42
|
+
@get:Rule
|
|
43
|
+
val composeTestRule = createComposeRule()
|
|
44
|
+
// composeTestRule.setContent { AppTheme { PrimaryButton(label = "Book", onClick = {}) } }
|
|
50
45
|
```
|
|
51
46
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
```kotlin
|
|
55
|
-
@HiltAndroidTest
|
|
56
|
-
class HomeScreenTest {
|
|
57
|
-
|
|
58
|
-
@get:Rule(order = 0)
|
|
59
|
-
val hiltRule = HiltAndroidRule(this)
|
|
60
|
-
|
|
61
|
-
@get:Rule(order = 1)
|
|
62
|
-
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
|
63
|
-
|
|
64
|
-
@Before
|
|
65
|
-
fun setup() {
|
|
66
|
-
hiltRule.inject()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
@Test
|
|
70
|
-
fun homeScreen_showsTitle() {
|
|
71
|
-
composeTestRule
|
|
72
|
-
.onNodeWithText("Home")
|
|
73
|
-
.assertIsDisplayed()
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
```
|
|
47
|
+
Full unit and instrumented (Hilt) setups: references/patterns.md#composetestrule-setup
|
|
77
48
|
|
|
78
49
|
## Finding Nodes
|
|
79
50
|
|
|
80
|
-
|
|
51
|
+
Select nodes by semantics, never by implementation details. Set
|
|
52
|
+
`Modifier.testTag("tag")` on key composables so tests find them reliably.
|
|
81
53
|
|
|
82
54
|
```kotlin
|
|
83
|
-
// By text content
|
|
84
55
|
composeTestRule.onNodeWithText("Book Flight")
|
|
85
|
-
composeTestRule.onNodeWithText("book flight", ignoreCase = true)
|
|
86
|
-
composeTestRule.onNodeWithText("Flight", substring = true)
|
|
87
|
-
|
|
88
|
-
// By test tag (set via Modifier.testTag("tag"))
|
|
89
56
|
composeTestRule.onNodeWithTag("flight_list")
|
|
90
|
-
|
|
91
|
-
// By content description (accessibility)
|
|
92
57
|
composeTestRule.onNodeWithContentDescription("Search flights")
|
|
93
|
-
|
|
94
|
-
// Multiple nodes
|
|
95
|
-
composeTestRule.onAllNodesWithText("Select")
|
|
96
|
-
composeTestRule.onAllNodesWithTag("flight_card")
|
|
97
|
-
|
|
98
|
-
// Combined matchers
|
|
99
|
-
composeTestRule.onNode(
|
|
100
|
-
hasText("Book") and hasClickAction()
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
// Parent/child traversal
|
|
104
|
-
composeTestRule
|
|
105
|
-
.onNodeWithTag("flight_card")
|
|
106
|
-
.onChildren()
|
|
107
|
-
.filterToOne(hasText("IST"))
|
|
108
58
|
```
|
|
109
59
|
|
|
110
|
-
|
|
60
|
+
Combined matchers (`hasText and hasClickAction`), `onAllNodesWith*`, parent/child
|
|
61
|
+
traversal, and tag placement: references/patterns.md#finding-nodes
|
|
111
62
|
|
|
112
|
-
|
|
113
|
-
@Composable
|
|
114
|
-
fun FlightCard(flight: Flight, modifier: Modifier = Modifier) {
|
|
115
|
-
Card(
|
|
116
|
-
modifier = modifier.testTag("flight_card_${flight.id}"),
|
|
117
|
-
) {
|
|
118
|
-
Text(
|
|
119
|
-
text = flight.origin,
|
|
120
|
-
modifier = Modifier.testTag("flight_origin"),
|
|
121
|
-
)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
```
|
|
63
|
+
## Test Tags from a Generated Registry
|
|
125
64
|
|
|
126
|
-
|
|
65
|
+
Do not scatter tag strings as literals. A production rename then silently orphans
|
|
66
|
+
every test that still passes the old string, and nothing fails to compile. Drive
|
|
67
|
+
the `Modifier.testTag` and the matching `onNodeWithTag` from one generated
|
|
68
|
+
constant so a rename is a compile error. The registry is generated from the
|
|
69
|
+
shared identifier source; the codegen side lives in the
|
|
70
|
+
`android-design-tokens-codegen` skill.
|
|
127
71
|
|
|
128
72
|
```kotlin
|
|
129
|
-
|
|
130
|
-
composeTestRule.
|
|
131
|
-
|
|
132
|
-
// Text input
|
|
133
|
-
composeTestRule.onNodeWithTag("search_field").performTextInput("Istanbul")
|
|
134
|
-
|
|
135
|
-
// Clear and replace text
|
|
136
|
-
composeTestRule.onNodeWithTag("search_field").performTextClearance()
|
|
137
|
-
composeTestRule.onNodeWithTag("search_field").performTextReplacement("Ankara")
|
|
73
|
+
Modifier.testTag(TestTags.FlightCard)
|
|
74
|
+
composeTestRule.onNodeWithTag(TestTags.FlightCard).assertIsDisplayed()
|
|
75
|
+
```
|
|
138
76
|
|
|
139
|
-
|
|
140
|
-
composeTestRule.onNodeWithText("Last Item").performScrollTo()
|
|
77
|
+
Full generated-registry and usage example: references/patterns.md#test-tags-from-a-generated-registry
|
|
141
78
|
|
|
142
|
-
|
|
143
|
-
composeTestRule.onNodeWithTag("flight_list").performScrollToIndex(15)
|
|
79
|
+
## Performing Actions
|
|
144
80
|
|
|
145
|
-
|
|
146
|
-
|
|
81
|
+
Actions run on a resolved node: `performClick()`, `performTextInput(text)`,
|
|
82
|
+
`performTextClearance()` / `performTextReplacement(text)`, `performScrollTo()`,
|
|
83
|
+
`performScrollToIndex(i)` / `performScrollToKey(k)`, and gestures via
|
|
84
|
+
`performTouchInput { swipeLeft(); longClick() }`.
|
|
147
85
|
|
|
148
|
-
|
|
149
|
-
composeTestRule.onNodeWithTag("card").performTouchInput {
|
|
150
|
-
swipeLeft()
|
|
151
|
-
swipeRight()
|
|
152
|
-
swipeUp()
|
|
153
|
-
swipeDown()
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Long click
|
|
157
|
-
composeTestRule.onNodeWithTag("item").performTouchInput {
|
|
158
|
-
longClick()
|
|
159
|
-
}
|
|
160
|
-
```
|
|
86
|
+
Full action reference: references/patterns.md#performing-actions
|
|
161
87
|
|
|
162
88
|
## Assertions
|
|
163
89
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
composeTestRule.onNodeWithText("Hidden").assertIsNotDisplayed()
|
|
90
|
+
Assert through the UI semantics, not internal state:
|
|
91
|
+
`assertIsDisplayed()` / `assertExists()` / `assertDoesNotExist()`,
|
|
92
|
+
`assertTextEquals(...)` / `assertTextContains(...)`,
|
|
93
|
+
`assertIsEnabled()`, `assertIsSelected()`, `assertIsOn()`,
|
|
94
|
+
`assertCountEquals(n)`, `assertIsFocused()`.
|
|
170
95
|
|
|
171
|
-
|
|
172
|
-
composeTestRule.onNodeWithTag("price").assertTextEquals("$299")
|
|
173
|
-
composeTestRule.onNodeWithTag("price").assertTextContains("299")
|
|
96
|
+
Full assertion reference: references/patterns.md#assertions
|
|
174
97
|
|
|
175
|
-
|
|
176
|
-
composeTestRule.onNodeWithText("Submit").assertIsEnabled()
|
|
177
|
-
composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
|
|
98
|
+
## Testing State Changes
|
|
178
99
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
100
|
+
Drive the UI, then assert the visible result. For async updates use
|
|
101
|
+
`waitUntil(timeoutMillis) { ... }` on fetched semantics nodes instead of
|
|
102
|
+
`Thread.sleep`.
|
|
182
103
|
|
|
183
|
-
|
|
184
|
-
composeTestRule.onNodeWithTag("wifi_toggle").assertIsOn()
|
|
185
|
-
composeTestRule.onNodeWithTag("wifi_toggle").assertIsOff()
|
|
104
|
+
Full counter and async-filter examples: references/patterns.md#testing-state-changes
|
|
186
105
|
|
|
187
|
-
|
|
188
|
-
composeTestRule.onAllNodesWithTag("flight_card").assertCountEquals(5)
|
|
106
|
+
## ViewModel Test Base Class
|
|
189
107
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
108
|
+
Centralize the coroutine setup in one base class every ViewModel test extends:
|
|
109
|
+
`Dispatchers.setMain(UnconfinedTestDispatcher())` in `@Before`, and
|
|
110
|
+
`Dispatchers.resetMain()` plus `unmockkAll()` in `@After`. Add a
|
|
111
|
+
`SavedStateHandle` helper so tests inject route arguments the same way. For a
|
|
112
|
+
type-safe `@Serializable` route, back the handle with the `navigation-testing`
|
|
113
|
+
artifact and read it via `savedStateHandle.toRoute<Route>()`.
|
|
193
114
|
|
|
194
|
-
|
|
115
|
+
MockK inline mocking (`mockkObject`, `mockkStatic`, mocking `final` classes)
|
|
116
|
+
loads a Byte Buddy agent at runtime. On JDK 21+ dynamic agent attachment is
|
|
117
|
+
restricted, so self-attach fails with an opaque initialization error. Attach the
|
|
118
|
+
agent explicitly with a `-javaagent` JVM arg on the `Test` task instead of
|
|
119
|
+
relying on self-attach.
|
|
195
120
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
fun counter_incrementsOnClick() {
|
|
199
|
-
composeTestRule.setContent {
|
|
200
|
-
var count by remember { mutableIntStateOf(0) }
|
|
201
|
-
Column {
|
|
202
|
-
Text(text = "Count: $count", modifier = Modifier.testTag("count"))
|
|
203
|
-
Button(onClick = { count++ }) {
|
|
204
|
-
Text("Increment")
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 0")
|
|
210
|
-
composeTestRule.onNodeWithText("Increment").performClick()
|
|
211
|
-
composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 1")
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
@Test
|
|
215
|
-
fun searchField_filtersResults() {
|
|
216
|
-
composeTestRule.setContent {
|
|
217
|
-
AppTheme {
|
|
218
|
-
SearchScreen(viewModel = fakeViewModel)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
composeTestRule.onNodeWithTag("search_input").performTextInput("Istanbul")
|
|
223
|
-
|
|
224
|
-
// Wait for async results
|
|
225
|
-
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
226
|
-
composeTestRule
|
|
227
|
-
.onAllNodesWithTag("search_result")
|
|
228
|
-
.fetchSemanticsNodes()
|
|
229
|
-
.isNotEmpty()
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
composeTestRule
|
|
233
|
-
.onAllNodesWithTag("search_result")
|
|
234
|
-
.assertCountEquals(3)
|
|
235
|
-
}
|
|
236
|
-
```
|
|
121
|
+
Full base class, route injection, and the `-javaagent` Gradle wiring:
|
|
122
|
+
references/patterns.md#viewmodel-test-base-class
|
|
237
123
|
|
|
238
124
|
## ViewModel Testing with Turbine
|
|
239
125
|
|
|
240
|
-
Turbine
|
|
126
|
+
Turbine's `test {}` extension on a `Flow`/`StateFlow` asserts emissions one at a
|
|
127
|
+
time (`awaitItem()`, `skipItems(n)`, `cancelAndIgnoreRemainingEvents()`). Back
|
|
128
|
+
the ViewModel with a fake repository rather than a mocking framework.
|
|
241
129
|
|
|
242
130
|
```kotlin
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
class
|
|
247
|
-
|
|
248
|
-
private val fakeRepository = FakeFlightRepository()
|
|
249
|
-
private lateinit var viewModel: HomeViewModel
|
|
250
|
-
|
|
251
|
-
@Before
|
|
252
|
-
fun setup() {
|
|
253
|
-
viewModel = HomeViewModel(
|
|
254
|
-
getFlightsUseCase = GetFlightsUseCase(fakeRepository),
|
|
255
|
-
)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
@Test
|
|
259
|
-
fun `loadFlights emits loading then success`() = runTest {
|
|
260
|
-
fakeRepository.setFlights(listOf(testFlight))
|
|
261
|
-
|
|
262
|
-
viewModel.uiState.test {
|
|
263
|
-
// Initial state
|
|
264
|
-
assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
|
|
265
|
-
|
|
266
|
-
// Trigger load
|
|
267
|
-
viewModel.loadFlights("IST", "JFK")
|
|
268
|
-
|
|
269
|
-
// Success state
|
|
270
|
-
val success = awaitItem()
|
|
271
|
-
assertThat(success).isInstanceOf(HomeUiState.Success::class.java)
|
|
272
|
-
assertThat((success as HomeUiState.Success).flights).hasSize(1)
|
|
273
|
-
|
|
274
|
-
cancelAndIgnoreRemainingEvents()
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
@Test
|
|
279
|
-
fun `loadFlights emits error on failure`() = runTest {
|
|
280
|
-
fakeRepository.setShouldFail(true)
|
|
281
|
-
|
|
282
|
-
viewModel.uiState.test {
|
|
283
|
-
skipItems(1) // skip Loading
|
|
284
|
-
|
|
285
|
-
viewModel.loadFlights("IST", "JFK")
|
|
286
|
-
|
|
287
|
-
val error = awaitItem()
|
|
288
|
-
assertThat(error).isInstanceOf(HomeUiState.Error::class.java)
|
|
289
|
-
|
|
290
|
-
cancelAndIgnoreRemainingEvents()
|
|
291
|
-
}
|
|
292
|
-
}
|
|
131
|
+
viewModel.uiState.test {
|
|
132
|
+
assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
|
|
133
|
+
viewModel.loadFlights("IST", "JFK")
|
|
134
|
+
assertThat(awaitItem()).isInstanceOf(HomeUiState.Success::class.java)
|
|
135
|
+
cancelAndIgnoreRemainingEvents()
|
|
293
136
|
}
|
|
294
137
|
```
|
|
295
138
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
```kotlin
|
|
299
|
-
class FakeFlightRepository : FlightRepository {
|
|
300
|
-
private val flights = MutableStateFlow<List<Flight>>(emptyList())
|
|
301
|
-
private var shouldFail = false
|
|
302
|
-
|
|
303
|
-
fun setFlights(list: List<Flight>) { flights.value = list }
|
|
304
|
-
fun setShouldFail(fail: Boolean) { shouldFail = fail }
|
|
305
|
-
|
|
306
|
-
override fun getFlights(origin: String, destination: String): Flow<List<Flight>> =
|
|
307
|
-
if (shouldFail) flow { throw IOException("Network error") }
|
|
308
|
-
else flights
|
|
309
|
-
|
|
310
|
-
override fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>> =
|
|
311
|
-
flights.map { it.filter { f -> f.origin.code == query } }
|
|
312
|
-
|
|
313
|
-
override suspend fun bookFlight(flightId: String): Result<Booking> =
|
|
314
|
-
if (shouldFail) Result.failure(IOException("Booking failed"))
|
|
315
|
-
else Result.success(Booking(id = "B1", confirmationCode = "CONF", status = BookingStatus.CONFIRMED))
|
|
316
|
-
}
|
|
317
|
-
```
|
|
139
|
+
Full ViewModel test and fake repository: references/patterns.md#viewmodel-testing-with-turbine
|
|
318
140
|
|
|
319
141
|
## TestDispatcher and runTest
|
|
320
142
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
```kotlin
|
|
325
|
-
class FlightRepositoryTest {
|
|
326
|
-
|
|
327
|
-
private val testDispatcher = StandardTestDispatcher()
|
|
328
|
-
|
|
329
|
-
@Before
|
|
330
|
-
fun setup() {
|
|
331
|
-
Dispatchers.setMain(testDispatcher)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
@After
|
|
335
|
-
fun tearDown() {
|
|
336
|
-
Dispatchers.resetMain()
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
@Test
|
|
340
|
-
fun `repository returns cached data`() = runTest {
|
|
341
|
-
val repo = FlightRepositoryImpl(
|
|
342
|
-
remoteDataSource = fakeRemote,
|
|
343
|
-
localDataSource = fakeLocal,
|
|
344
|
-
flightMapper = FlightMapper(),
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
val result = repo.getFlights("IST", "JFK").first()
|
|
348
|
-
assertThat(result).hasSize(2)
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
```
|
|
352
|
-
|
|
353
|
-
### TestDispatcher Comparison
|
|
143
|
+
Run coroutine tests inside `runTest`. Set `Dispatchers.setMain(testDispatcher)`
|
|
144
|
+
in `@Before` and `Dispatchers.resetMain()` in `@After`. Pick one dispatcher per
|
|
145
|
+
test; do not mix them.
|
|
354
146
|
|
|
355
147
|
| Dispatcher | Behavior | Use For |
|
|
356
148
|
|------------|----------|---------|
|
|
357
149
|
| `StandardTestDispatcher` | Pauses coroutines; advance manually with `advanceUntilIdle()` | Precise control over execution order |
|
|
358
150
|
| `UnconfinedTestDispatcher` | Runs coroutines eagerly | Simple tests where order does not matter |
|
|
359
151
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
fun `standard dispatcher requires manual advance`() = runTest(StandardTestDispatcher()) {
|
|
363
|
-
var value = 0
|
|
364
|
-
launch { value = 1 }
|
|
365
|
-
assertThat(value).isEqualTo(0) // not yet executed
|
|
366
|
-
advanceUntilIdle()
|
|
367
|
-
assertThat(value).isEqualTo(1) // now executed
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
@Test
|
|
371
|
-
fun `unconfined dispatcher runs eagerly`() = runTest(UnconfinedTestDispatcher()) {
|
|
372
|
-
var value = 0
|
|
373
|
-
launch { value = 1 }
|
|
374
|
-
assertThat(value).isEqualTo(1) // already executed
|
|
375
|
-
}
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
### Injectable DispatcherProvider for Tests
|
|
379
|
-
|
|
380
|
-
```kotlin
|
|
381
|
-
class TestDispatcherProvider(
|
|
382
|
-
testDispatcher: TestDispatcher = StandardTestDispatcher(),
|
|
383
|
-
) : DispatcherProvider {
|
|
384
|
-
override val main: CoroutineDispatcher = testDispatcher
|
|
385
|
-
override val io: CoroutineDispatcher = testDispatcher
|
|
386
|
-
override val default: CoroutineDispatcher = testDispatcher
|
|
387
|
-
}
|
|
388
|
-
```
|
|
152
|
+
Dispatcher behavior examples and an injectable `TestDispatcherProvider`:
|
|
153
|
+
references/patterns.md#testdispatcher-and-runtest
|
|
389
154
|
|
|
390
155
|
## Screenshot Testing
|
|
391
156
|
|
|
392
|
-
|
|
157
|
+
Both frameworks render on the JVM with no device. Roborazzi uses Robolectric and
|
|
158
|
+
captures via `captureRoboImage()`; Paparazzi uses Layoutlib and captures via
|
|
159
|
+
`paparazzi.snapshot { ... }`. Screenshot both light and dark themes and commit
|
|
160
|
+
the baselines.
|
|
393
161
|
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
// testImplementation(libs.roborazzi.compose)
|
|
398
|
-
|
|
399
|
-
@RunWith(RobolectricTestRunner::class)
|
|
400
|
-
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
401
|
-
@Config(sdk = [34])
|
|
402
|
-
class FlightCardScreenshotTest {
|
|
403
|
-
|
|
404
|
-
@get:Rule
|
|
405
|
-
val composeTestRule = createComposeRule()
|
|
406
|
-
|
|
407
|
-
@get:Rule
|
|
408
|
-
val roborazziRule = RoborazziRule(
|
|
409
|
-
options = RoborazziRule.Options(
|
|
410
|
-
captureType = RoborazziRule.CaptureType.LastImage(),
|
|
411
|
-
),
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
@Test
|
|
415
|
-
fun flightCard_default() {
|
|
416
|
-
composeTestRule.setContent {
|
|
417
|
-
AppTheme {
|
|
418
|
-
FlightCard(flight = previewFlight)
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
composeTestRule
|
|
423
|
-
.onNodeWithTag("flight_card")
|
|
424
|
-
.captureRoboImage()
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
@Test
|
|
428
|
-
fun flightCard_dark() {
|
|
429
|
-
composeTestRule.setContent {
|
|
430
|
-
AppTheme(darkTheme = true) {
|
|
431
|
-
FlightCard(flight = previewFlight)
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
composeTestRule
|
|
436
|
-
.onNodeWithTag("flight_card")
|
|
437
|
-
.captureRoboImage()
|
|
438
|
-
}
|
|
439
|
-
}
|
|
162
|
+
```bash
|
|
163
|
+
./gradlew recordRoborazziDebug # or recordPaparazziDebug
|
|
164
|
+
./gradlew verifyRoborazziDebug # or verifyPaparazziDebug
|
|
440
165
|
```
|
|
441
166
|
|
|
442
|
-
|
|
167
|
+
Full Roborazzi and Paparazzi test classes: references/patterns.md#screenshot-testing
|
|
443
168
|
|
|
444
|
-
|
|
445
|
-
// build.gradle.kts
|
|
446
|
-
// testImplementation(libs.paparazzi)
|
|
447
|
-
|
|
448
|
-
class FlightCardPaparazziTest {
|
|
449
|
-
|
|
450
|
-
@get:Rule
|
|
451
|
-
val paparazzi = Paparazzi(
|
|
452
|
-
deviceConfig = DeviceConfig.PIXEL_6,
|
|
453
|
-
theme = "android:Theme.Material3.Light",
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
@Test
|
|
457
|
-
fun flightCard_snapshot() {
|
|
458
|
-
paparazzi.snapshot {
|
|
459
|
-
AppTheme {
|
|
460
|
-
FlightCard(flight = previewFlight)
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
```
|
|
169
|
+
## Compose Preview Screenshot Testing
|
|
466
170
|
|
|
467
|
-
|
|
171
|
+
AGP's first-party Compose Preview screenshot-testing plugin
|
|
172
|
+
(`com.android.compose.screenshot`) renders existing `@Preview` composables and
|
|
173
|
+
diffs them against committed baselines, alongside Paparazzi/Roborazzi rather than
|
|
174
|
+
replacing them. It is still alpha -- pin the plugin version and expect API
|
|
175
|
+
changes. Enable it with `android.experimental.enableScreenshotTest=true`, mark
|
|
176
|
+
previews with `@PreviewTest` under the `screenshotTest` source set, set
|
|
177
|
+
`imageDifferenceThreshold` in the `screenshotTests` DSL, and keep per-flavor
|
|
178
|
+
baselines under `src/<variant>ScreenshotTest/reference/`.
|
|
468
179
|
|
|
469
180
|
```bash
|
|
470
|
-
#
|
|
471
|
-
./gradlew
|
|
472
|
-
|
|
473
|
-
# Roborazzi: verify against baselines
|
|
474
|
-
./gradlew verifyRoborazziDebug
|
|
475
|
-
|
|
476
|
-
# Paparazzi: record baselines
|
|
477
|
-
./gradlew recordPaparazziDebug
|
|
478
|
-
|
|
479
|
-
# Paparazzi: verify against baselines
|
|
480
|
-
./gradlew verifyPaparazziDebug
|
|
181
|
+
./gradlew updateDebugScreenshotTest # record/update baselines
|
|
182
|
+
./gradlew validateDebugScreenshotTest # validate against baselines
|
|
481
183
|
```
|
|
482
184
|
|
|
185
|
+
Full plugin config, `@PreviewTest` previews, and threshold DSL:
|
|
186
|
+
references/patterns.md#compose-preview-screenshot-testing
|
|
187
|
+
|
|
483
188
|
## Do's and Don'ts
|
|
484
189
|
|
|
485
190
|
### Do's
|
|
486
|
-
-
|
|
191
|
+
- Drive `testTag`/`onNodeWithTag` from a generated registry, not string literals
|
|
487
192
|
- Use `waitUntil` for async UI updates instead of `Thread.sleep`
|
|
488
193
|
- Use Turbine `test {}` for StateFlow/Flow assertions in ViewModel tests
|
|
489
|
-
-
|
|
194
|
+
- Extend one base class that swaps the main dispatcher and calls `resetMain()` + `unmockkAll()`
|
|
195
|
+
- Attach the Byte Buddy agent via `-javaagent` when MockK does inline mocking on JDK 21+
|
|
490
196
|
- Use fake repositories/data sources instead of mocking frameworks for data layer tests
|
|
491
197
|
- Use `createComposeRule()` (no activity) for pure composable tests
|
|
492
198
|
- Screenshot test both light and dark themes
|
|
@@ -494,6 +200,7 @@ class FlightCardPaparazziTest {
|
|
|
494
200
|
### Don'ts
|
|
495
201
|
- Do not use `Thread.sleep()` in Compose tests -- use `waitUntil` or `advanceUntilIdle()`
|
|
496
202
|
- Do not find nodes by implementation details (view IDs, class names)
|
|
203
|
+
- Do not hardcode `testTag` strings as literals in both production and tests
|
|
497
204
|
- Do not test internal state directly -- test through the UI semantics
|
|
498
205
|
- Do not use `Mockito.mock()` on final Kotlin classes without mockito-inline
|
|
499
206
|
- Do not forget `cancelAndIgnoreRemainingEvents()` in Turbine tests
|
|
@@ -512,15 +219,18 @@ class FlightCardPaparazziTest {
|
|
|
512
219
|
| Paparazzi `ClassNotFoundException` | Incompatible AGP version | Check Paparazzi compatibility matrix |
|
|
513
220
|
| Tests pass locally but fail on CI | Locale/timezone differences | Set locale and timezone in test setup or Gradle |
|
|
514
221
|
| `Dispatchers.Main` crash in unit test | Missing `setMain` | Add `Dispatchers.setMain(testDispatcher)` in `@Before` |
|
|
222
|
+
| MockK inline mock init fails on JDK 21+ | Byte Buddy self-attach restricted | Attach the agent via `-javaagent` on the `Test` task |
|
|
223
|
+
| `@PreviewTest` previews not collected | `screenshotTest` source set or flag missing | Set `enableScreenshotTest=true`, place previews under `src/screenshotTest` |
|
|
515
224
|
|
|
516
225
|
## Review Checklist
|
|
517
226
|
|
|
518
|
-
- [ ] Key composables have `Modifier.testTag()`
|
|
227
|
+
- [ ] Key composables have `Modifier.testTag()` from a generated registry, not literals
|
|
519
228
|
- [ ] No `Thread.sleep()` -- uses `waitUntil` or coroutine test APIs
|
|
520
229
|
- [ ] ViewModel tests use Turbine `test {}` for Flow assertions
|
|
521
|
-
- [ ]
|
|
230
|
+
- [ ] ViewModel tests extend a base class that swaps/resets `Dispatchers.Main` and calls `unmockkAll()`
|
|
231
|
+
- [ ] MockK inline mocking has the `-javaagent` Byte Buddy wiring for JDK 21+
|
|
522
232
|
- [ ] Fake implementations used for repositories and data sources
|
|
523
|
-
- [ ] Screenshot tests cover light and dark themes
|
|
233
|
+
- [ ] Screenshot tests cover light and dark themes (Paparazzi/Roborazzi and/or AGP `@PreviewTest`)
|
|
524
234
|
- [ ] Screenshot baselines are committed to version control
|
|
525
235
|
- [ ] Each test method tests one behavior/scenario
|
|
526
236
|
- [ ] Test names follow `function_scenario_expected` convention
|