@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0
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 +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
# Compose Testing Patterns
|
|
2
|
+
|
|
3
|
+
Full code examples for the Compose testing guide. Load this when you need a
|
|
4
|
+
complete, copyable example for a specific area: rule setup, node selection,
|
|
5
|
+
actions, assertions, state changes, ViewModel/Turbine tests, coroutine test
|
|
6
|
+
dispatchers, or screenshot tests. The top-level SKILL.md carries the guidance
|
|
7
|
+
and short snippets; this file carries the long examples.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [ComposeTestRule Setup](#composetestrule-setup)
|
|
12
|
+
- [Finding Nodes](#finding-nodes)
|
|
13
|
+
- [Test Tags from a Generated Registry](#test-tags-from-a-generated-registry)
|
|
14
|
+
- [Performing Actions](#performing-actions)
|
|
15
|
+
- [Assertions](#assertions)
|
|
16
|
+
- [Testing State Changes](#testing-state-changes)
|
|
17
|
+
- [ViewModel Test Base Class](#viewmodel-test-base-class)
|
|
18
|
+
- [ViewModel Testing with Turbine](#viewmodel-testing-with-turbine)
|
|
19
|
+
- [TestDispatcher and runTest](#testdispatcher-and-runtest)
|
|
20
|
+
- [Screenshot Testing](#screenshot-testing)
|
|
21
|
+
- [Compose Preview Screenshot Testing](#compose-preview-screenshot-testing)
|
|
22
|
+
|
|
23
|
+
## ComposeTestRule Setup
|
|
24
|
+
|
|
25
|
+
### Unit Tests (No Activity)
|
|
26
|
+
|
|
27
|
+
```kotlin
|
|
28
|
+
class ButtonComponentTest {
|
|
29
|
+
|
|
30
|
+
@get:Rule
|
|
31
|
+
val composeTestRule = createComposeRule()
|
|
32
|
+
|
|
33
|
+
@Test
|
|
34
|
+
fun primaryButton_displaysLabel() {
|
|
35
|
+
composeTestRule.setContent {
|
|
36
|
+
AppTheme {
|
|
37
|
+
PrimaryButton(label = "Book Flight", onClick = {})
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
composeTestRule
|
|
42
|
+
.onNodeWithText("Book Flight")
|
|
43
|
+
.assertIsDisplayed()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Instrumented Tests (With Activity)
|
|
49
|
+
|
|
50
|
+
```kotlin
|
|
51
|
+
@HiltAndroidTest
|
|
52
|
+
class HomeScreenTest {
|
|
53
|
+
|
|
54
|
+
@get:Rule(order = 0)
|
|
55
|
+
val hiltRule = HiltAndroidRule(this)
|
|
56
|
+
|
|
57
|
+
@get:Rule(order = 1)
|
|
58
|
+
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
|
59
|
+
|
|
60
|
+
@Before
|
|
61
|
+
fun setup() {
|
|
62
|
+
hiltRule.inject()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@Test
|
|
66
|
+
fun homeScreen_showsTitle() {
|
|
67
|
+
composeTestRule
|
|
68
|
+
.onNodeWithText("Home")
|
|
69
|
+
.assertIsDisplayed()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Finding Nodes
|
|
75
|
+
|
|
76
|
+
### Semantic Matchers
|
|
77
|
+
|
|
78
|
+
```kotlin
|
|
79
|
+
// By text content
|
|
80
|
+
composeTestRule.onNodeWithText("Book Flight")
|
|
81
|
+
composeTestRule.onNodeWithText("book flight", ignoreCase = true)
|
|
82
|
+
composeTestRule.onNodeWithText("Flight", substring = true)
|
|
83
|
+
|
|
84
|
+
// By test tag (set via Modifier.testTag("tag"))
|
|
85
|
+
composeTestRule.onNodeWithTag("flight_list")
|
|
86
|
+
|
|
87
|
+
// By content description (accessibility)
|
|
88
|
+
composeTestRule.onNodeWithContentDescription("Search flights")
|
|
89
|
+
|
|
90
|
+
// Multiple nodes
|
|
91
|
+
composeTestRule.onAllNodesWithText("Select")
|
|
92
|
+
composeTestRule.onAllNodesWithTag("flight_card")
|
|
93
|
+
|
|
94
|
+
// Combined matchers
|
|
95
|
+
composeTestRule.onNode(
|
|
96
|
+
hasText("Book") and hasClickAction()
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Parent/child traversal
|
|
100
|
+
composeTestRule
|
|
101
|
+
.onNodeWithTag("flight_card")
|
|
102
|
+
.onChildren()
|
|
103
|
+
.filterToOne(hasText("IST"))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Setting Test Tags
|
|
107
|
+
|
|
108
|
+
```kotlin
|
|
109
|
+
@Composable
|
|
110
|
+
fun FlightCard(flight: Flight, modifier: Modifier = Modifier) {
|
|
111
|
+
Card(
|
|
112
|
+
modifier = modifier.testTag("flight_card_${flight.id}"),
|
|
113
|
+
) {
|
|
114
|
+
Text(
|
|
115
|
+
text = flight.origin,
|
|
116
|
+
modifier = Modifier.testTag("flight_origin"),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Test Tags from a Generated Registry
|
|
123
|
+
|
|
124
|
+
String-literal tags drift: a rename in production silently orphans every test
|
|
125
|
+
that still passes the old string, and nothing fails to compile. Drive both the
|
|
126
|
+
`Modifier.testTag` in production and the `onNodeWithTag` in the test from one
|
|
127
|
+
generated constant. The registry is generated from the shared identifier source;
|
|
128
|
+
the codegen side lives in the `android-design-tokens-codegen` skill.
|
|
129
|
+
|
|
130
|
+
```kotlin
|
|
131
|
+
// Generated -- do not edit by hand
|
|
132
|
+
object TestTags {
|
|
133
|
+
const val FlightCard = "flight_card"
|
|
134
|
+
const val FlightOrigin = "flight_origin"
|
|
135
|
+
const val SearchField = "search_field"
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```kotlin
|
|
140
|
+
// Production
|
|
141
|
+
Card(modifier = Modifier.testTag(TestTags.FlightCard)) { /* ... */ }
|
|
142
|
+
|
|
143
|
+
// Test -- same constant, so a rename is a compile error, not a silent miss
|
|
144
|
+
composeTestRule.onNodeWithTag(TestTags.FlightCard).assertIsDisplayed()
|
|
145
|
+
composeTestRule.onNodeWithTag(TestTags.SearchField).performTextInput("Istanbul")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Performing Actions
|
|
149
|
+
|
|
150
|
+
```kotlin
|
|
151
|
+
// Click
|
|
152
|
+
composeTestRule.onNodeWithText("Book").performClick()
|
|
153
|
+
|
|
154
|
+
// Text input
|
|
155
|
+
composeTestRule.onNodeWithTag("search_field").performTextInput("Istanbul")
|
|
156
|
+
|
|
157
|
+
// Clear and replace text
|
|
158
|
+
composeTestRule.onNodeWithTag("search_field").performTextClearance()
|
|
159
|
+
composeTestRule.onNodeWithTag("search_field").performTextReplacement("Ankara")
|
|
160
|
+
|
|
161
|
+
// Scroll to node (in scrollable container)
|
|
162
|
+
composeTestRule.onNodeWithText("Last Item").performScrollTo()
|
|
163
|
+
|
|
164
|
+
// Scroll to index in LazyColumn
|
|
165
|
+
composeTestRule.onNodeWithTag("flight_list").performScrollToIndex(15)
|
|
166
|
+
|
|
167
|
+
// Scroll to key in LazyColumn
|
|
168
|
+
composeTestRule.onNodeWithTag("flight_list").performScrollToKey("flight-42")
|
|
169
|
+
|
|
170
|
+
// Swipe gestures
|
|
171
|
+
composeTestRule.onNodeWithTag("card").performTouchInput {
|
|
172
|
+
swipeLeft()
|
|
173
|
+
swipeRight()
|
|
174
|
+
swipeUp()
|
|
175
|
+
swipeDown()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Long click
|
|
179
|
+
composeTestRule.onNodeWithTag("item").performTouchInput {
|
|
180
|
+
longClick()
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Assertions
|
|
185
|
+
|
|
186
|
+
```kotlin
|
|
187
|
+
// Existence and visibility
|
|
188
|
+
composeTestRule.onNodeWithText("Title").assertIsDisplayed()
|
|
189
|
+
composeTestRule.onNodeWithText("Title").assertExists()
|
|
190
|
+
composeTestRule.onNodeWithText("Hidden").assertDoesNotExist()
|
|
191
|
+
composeTestRule.onNodeWithText("Hidden").assertIsNotDisplayed()
|
|
192
|
+
|
|
193
|
+
// Text content
|
|
194
|
+
composeTestRule.onNodeWithTag("price").assertTextEquals("$299")
|
|
195
|
+
composeTestRule.onNodeWithTag("price").assertTextContains("299")
|
|
196
|
+
|
|
197
|
+
// Enabled/disabled state
|
|
198
|
+
composeTestRule.onNodeWithText("Submit").assertIsEnabled()
|
|
199
|
+
composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()
|
|
200
|
+
|
|
201
|
+
// Selection state
|
|
202
|
+
composeTestRule.onNodeWithText("Economy").assertIsSelected()
|
|
203
|
+
composeTestRule.onNodeWithText("Business").assertIsNotSelected()
|
|
204
|
+
|
|
205
|
+
// Toggle state
|
|
206
|
+
composeTestRule.onNodeWithTag("wifi_toggle").assertIsOn()
|
|
207
|
+
composeTestRule.onNodeWithTag("wifi_toggle").assertIsOff()
|
|
208
|
+
|
|
209
|
+
// Count assertions
|
|
210
|
+
composeTestRule.onAllNodesWithTag("flight_card").assertCountEquals(5)
|
|
211
|
+
|
|
212
|
+
// Focused state
|
|
213
|
+
composeTestRule.onNodeWithTag("search_field").assertIsFocused()
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Testing State Changes
|
|
217
|
+
|
|
218
|
+
```kotlin
|
|
219
|
+
@Test
|
|
220
|
+
fun counter_incrementsOnClick() {
|
|
221
|
+
composeTestRule.setContent {
|
|
222
|
+
var count by remember { mutableIntStateOf(0) }
|
|
223
|
+
Column {
|
|
224
|
+
Text(text = "Count: $count", modifier = Modifier.testTag("count"))
|
|
225
|
+
Button(onClick = { count++ }) {
|
|
226
|
+
Text("Increment")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 0")
|
|
232
|
+
composeTestRule.onNodeWithText("Increment").performClick()
|
|
233
|
+
composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 1")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
@Test
|
|
237
|
+
fun searchField_filtersResults() {
|
|
238
|
+
composeTestRule.setContent {
|
|
239
|
+
AppTheme {
|
|
240
|
+
SearchScreen(viewModel = fakeViewModel)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
composeTestRule.onNodeWithTag("search_input").performTextInput("Istanbul")
|
|
245
|
+
|
|
246
|
+
// Wait for async results
|
|
247
|
+
composeTestRule.waitUntil(timeoutMillis = 5_000) {
|
|
248
|
+
composeTestRule
|
|
249
|
+
.onAllNodesWithTag("search_result")
|
|
250
|
+
.fetchSemanticsNodes()
|
|
251
|
+
.isNotEmpty()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
composeTestRule
|
|
255
|
+
.onAllNodesWithTag("search_result")
|
|
256
|
+
.assertCountEquals(3)
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## ViewModel Test Base Class
|
|
261
|
+
|
|
262
|
+
Centralize the coroutine main-dispatcher swap, teardown, and route/state
|
|
263
|
+
injection so every ViewModel test starts from the same clean setup. Use
|
|
264
|
+
`UnconfinedTestDispatcher()` as the main dispatcher (eager execution, no manual
|
|
265
|
+
`advanceUntilIdle()` for simple flows), reset it in teardown, and `unmockkAll()`
|
|
266
|
+
so MockK state never leaks between tests.
|
|
267
|
+
|
|
268
|
+
```kotlin
|
|
269
|
+
@OptIn(ExperimentalCoroutinesApi::class)
|
|
270
|
+
abstract class ViewModelTest {
|
|
271
|
+
|
|
272
|
+
@Before
|
|
273
|
+
fun setUpMainDispatcher() {
|
|
274
|
+
Dispatchers.setMain(UnconfinedTestDispatcher())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@After
|
|
278
|
+
fun tearDownMainDispatcher() {
|
|
279
|
+
Dispatchers.resetMain()
|
|
280
|
+
unmockkAll()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
protected fun savedStateHandleOf(vararg args: Pair<String, Any?>): SavedStateHandle =
|
|
284
|
+
SavedStateHandle(args.toMap())
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
For a type-safe `@Serializable` route, back the handle with the
|
|
289
|
+
`navigation-testing` artifact, whose `SavedStateHandle` builder decodes a route
|
|
290
|
+
object; the ViewModel then reads it via `savedStateHandle.toRoute<Route>()`.
|
|
291
|
+
|
|
292
|
+
```kotlin
|
|
293
|
+
class DetailViewModelTest : ViewModelTest() {
|
|
294
|
+
|
|
295
|
+
@Test
|
|
296
|
+
fun `loads item named in the route`() = runTest {
|
|
297
|
+
val viewModel = DetailViewModel(
|
|
298
|
+
savedStateHandle = savedStateHandleOf("itemId" to "42"),
|
|
299
|
+
repository = FakeRepository(),
|
|
300
|
+
)
|
|
301
|
+
assertThat(viewModel.itemId).isEqualTo("42")
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### MockK inline mocking on modern JDKs
|
|
307
|
+
|
|
308
|
+
MockK's inline mocking (`mockkObject`, `mockkStatic`, mocking `final` classes)
|
|
309
|
+
loads a Byte Buddy agent at runtime. On JDK 21+ dynamic agent attachment is
|
|
310
|
+
restricted and self-attach fails with a MockK/Byte Buddy initialization error --
|
|
311
|
+
a common, hard-to-diagnose test failure. Attach the agent explicitly via
|
|
312
|
+
`-javaagent` instead of relying on self-attach.
|
|
313
|
+
|
|
314
|
+
```kotlin
|
|
315
|
+
// build.gradle.kts
|
|
316
|
+
val mockkAgent: Configuration by configurations.creating
|
|
317
|
+
|
|
318
|
+
dependencies {
|
|
319
|
+
testImplementation(libs.mockk)
|
|
320
|
+
mockkAgent(libs.bytebuddy.agent)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
tasks.withType<Test>().configureEach {
|
|
324
|
+
val agentJar = mockkAgent.singleFile
|
|
325
|
+
jvmArgs("-javaagent:${agentJar.absolutePath}")
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## ViewModel Testing with Turbine
|
|
330
|
+
|
|
331
|
+
Turbine provides `test {}` extension on Flow for asserting emissions.
|
|
332
|
+
|
|
333
|
+
```kotlin
|
|
334
|
+
// build.gradle.kts
|
|
335
|
+
// testImplementation(libs.turbine)
|
|
336
|
+
|
|
337
|
+
class HomeViewModelTest {
|
|
338
|
+
|
|
339
|
+
private val fakeRepository = FakeFlightRepository()
|
|
340
|
+
private lateinit var viewModel: HomeViewModel
|
|
341
|
+
|
|
342
|
+
@Before
|
|
343
|
+
fun setup() {
|
|
344
|
+
viewModel = HomeViewModel(
|
|
345
|
+
getFlightsUseCase = GetFlightsUseCase(fakeRepository),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
@Test
|
|
350
|
+
fun `loadFlights emits loading then success`() = runTest {
|
|
351
|
+
fakeRepository.setFlights(listOf(testFlight))
|
|
352
|
+
|
|
353
|
+
viewModel.uiState.test {
|
|
354
|
+
// Initial state
|
|
355
|
+
assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
|
|
356
|
+
|
|
357
|
+
// Trigger load
|
|
358
|
+
viewModel.loadFlights("IST", "JFK")
|
|
359
|
+
|
|
360
|
+
// Success state
|
|
361
|
+
val success = awaitItem()
|
|
362
|
+
assertThat(success).isInstanceOf(HomeUiState.Success::class.java)
|
|
363
|
+
assertThat((success as HomeUiState.Success).flights).hasSize(1)
|
|
364
|
+
|
|
365
|
+
cancelAndIgnoreRemainingEvents()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@Test
|
|
370
|
+
fun `loadFlights emits error on failure`() = runTest {
|
|
371
|
+
fakeRepository.setShouldFail(true)
|
|
372
|
+
|
|
373
|
+
viewModel.uiState.test {
|
|
374
|
+
skipItems(1) // skip Loading
|
|
375
|
+
|
|
376
|
+
viewModel.loadFlights("IST", "JFK")
|
|
377
|
+
|
|
378
|
+
val error = awaitItem()
|
|
379
|
+
assertThat(error).isInstanceOf(HomeUiState.Error::class.java)
|
|
380
|
+
|
|
381
|
+
cancelAndIgnoreRemainingEvents()
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Fake Repository
|
|
388
|
+
|
|
389
|
+
```kotlin
|
|
390
|
+
class FakeFlightRepository : FlightRepository {
|
|
391
|
+
private val flights = MutableStateFlow<List<Flight>>(emptyList())
|
|
392
|
+
private var shouldFail = false
|
|
393
|
+
|
|
394
|
+
fun setFlights(list: List<Flight>) { flights.value = list }
|
|
395
|
+
fun setShouldFail(fail: Boolean) { shouldFail = fail }
|
|
396
|
+
|
|
397
|
+
override fun getFlights(origin: String, destination: String): Flow<List<Flight>> =
|
|
398
|
+
if (shouldFail) flow { throw IOException("Network error") }
|
|
399
|
+
else flights
|
|
400
|
+
|
|
401
|
+
override fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>> =
|
|
402
|
+
flights.map { it.filter { f -> f.origin.code == query } }
|
|
403
|
+
|
|
404
|
+
override suspend fun bookFlight(flightId: String): Result<Booking> =
|
|
405
|
+
if (shouldFail) Result.failure(IOException("Booking failed"))
|
|
406
|
+
else Result.success(Booking(id = "B1", confirmationCode = "CONF", status = BookingStatus.CONFIRMED))
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
## TestDispatcher and runTest
|
|
411
|
+
|
|
412
|
+
Use `runTest` with `StandardTestDispatcher` or `UnconfinedTestDispatcher` to
|
|
413
|
+
control coroutine execution in tests.
|
|
414
|
+
|
|
415
|
+
```kotlin
|
|
416
|
+
class FlightRepositoryTest {
|
|
417
|
+
|
|
418
|
+
private val testDispatcher = StandardTestDispatcher()
|
|
419
|
+
|
|
420
|
+
@Before
|
|
421
|
+
fun setup() {
|
|
422
|
+
Dispatchers.setMain(testDispatcher)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@After
|
|
426
|
+
fun tearDown() {
|
|
427
|
+
Dispatchers.resetMain()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
@Test
|
|
431
|
+
fun `repository returns cached data`() = runTest {
|
|
432
|
+
val repo = FlightRepositoryImpl(
|
|
433
|
+
remoteDataSource = fakeRemote,
|
|
434
|
+
localDataSource = fakeLocal,
|
|
435
|
+
flightMapper = FlightMapper(),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
val result = repo.getFlights("IST", "JFK").first()
|
|
439
|
+
assertThat(result).hasSize(2)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Dispatcher Behavior Examples
|
|
445
|
+
|
|
446
|
+
```kotlin
|
|
447
|
+
@Test
|
|
448
|
+
fun `standard dispatcher requires manual advance`() = runTest(StandardTestDispatcher()) {
|
|
449
|
+
var value = 0
|
|
450
|
+
launch { value = 1 }
|
|
451
|
+
assertThat(value).isEqualTo(0) // not yet executed
|
|
452
|
+
advanceUntilIdle()
|
|
453
|
+
assertThat(value).isEqualTo(1) // now executed
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@Test
|
|
457
|
+
fun `unconfined dispatcher runs eagerly`() = runTest(UnconfinedTestDispatcher()) {
|
|
458
|
+
var value = 0
|
|
459
|
+
launch { value = 1 }
|
|
460
|
+
assertThat(value).isEqualTo(1) // already executed
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Injectable DispatcherProvider for Tests
|
|
465
|
+
|
|
466
|
+
```kotlin
|
|
467
|
+
class TestDispatcherProvider(
|
|
468
|
+
testDispatcher: TestDispatcher = StandardTestDispatcher(),
|
|
469
|
+
) : DispatcherProvider {
|
|
470
|
+
override val main: CoroutineDispatcher = testDispatcher
|
|
471
|
+
override val io: CoroutineDispatcher = testDispatcher
|
|
472
|
+
override val default: CoroutineDispatcher = testDispatcher
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
## Screenshot Testing
|
|
477
|
+
|
|
478
|
+
### Roborazzi (JVM-based, no device required)
|
|
479
|
+
|
|
480
|
+
```kotlin
|
|
481
|
+
// build.gradle.kts
|
|
482
|
+
// testImplementation(libs.roborazzi)
|
|
483
|
+
// testImplementation(libs.roborazzi.compose)
|
|
484
|
+
|
|
485
|
+
@RunWith(RobolectricTestRunner::class)
|
|
486
|
+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
487
|
+
@Config(sdk = [34])
|
|
488
|
+
class FlightCardScreenshotTest {
|
|
489
|
+
|
|
490
|
+
@get:Rule
|
|
491
|
+
val composeTestRule = createComposeRule()
|
|
492
|
+
|
|
493
|
+
@get:Rule
|
|
494
|
+
val roborazziRule = RoborazziRule(
|
|
495
|
+
options = RoborazziRule.Options(
|
|
496
|
+
captureType = RoborazziRule.CaptureType.LastImage(),
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
@Test
|
|
501
|
+
fun flightCard_default() {
|
|
502
|
+
composeTestRule.setContent {
|
|
503
|
+
AppTheme {
|
|
504
|
+
FlightCard(flight = previewFlight)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
composeTestRule
|
|
509
|
+
.onNodeWithTag("flight_card")
|
|
510
|
+
.captureRoboImage()
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
@Test
|
|
514
|
+
fun flightCard_dark() {
|
|
515
|
+
composeTestRule.setContent {
|
|
516
|
+
AppTheme(darkTheme = true) {
|
|
517
|
+
FlightCard(flight = previewFlight)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
composeTestRule
|
|
522
|
+
.onNodeWithTag("flight_card")
|
|
523
|
+
.captureRoboImage()
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### Paparazzi (Layoutlib-based, no device required)
|
|
529
|
+
|
|
530
|
+
```kotlin
|
|
531
|
+
// build.gradle.kts
|
|
532
|
+
// testImplementation(libs.paparazzi)
|
|
533
|
+
|
|
534
|
+
class FlightCardPaparazziTest {
|
|
535
|
+
|
|
536
|
+
@get:Rule
|
|
537
|
+
val paparazzi = Paparazzi(
|
|
538
|
+
deviceConfig = DeviceConfig.PIXEL_6,
|
|
539
|
+
theme = "android:Theme.Material3.Light",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
@Test
|
|
543
|
+
fun flightCard_snapshot() {
|
|
544
|
+
paparazzi.snapshot {
|
|
545
|
+
AppTheme {
|
|
546
|
+
FlightCard(flight = previewFlight)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
### Gradle Commands
|
|
554
|
+
|
|
555
|
+
```bash
|
|
556
|
+
# Roborazzi: record baselines
|
|
557
|
+
./gradlew recordRoborazziDebug
|
|
558
|
+
|
|
559
|
+
# Roborazzi: verify against baselines
|
|
560
|
+
./gradlew verifyRoborazziDebug
|
|
561
|
+
|
|
562
|
+
# Paparazzi: record baselines
|
|
563
|
+
./gradlew recordPaparazziDebug
|
|
564
|
+
|
|
565
|
+
# Paparazzi: verify against baselines
|
|
566
|
+
./gradlew verifyPaparazziDebug
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
## Compose Preview Screenshot Testing
|
|
570
|
+
|
|
571
|
+
AGP ships a first-party Compose Preview screenshot-testing plugin that renders
|
|
572
|
+
existing `@Preview` composables and diffs them against committed baselines,
|
|
573
|
+
alongside (not instead of) Paparazzi/Roborazzi. It is still alpha, so pin the
|
|
574
|
+
plugin version and expect API changes.
|
|
575
|
+
|
|
576
|
+
Enable it and set the image-difference threshold:
|
|
577
|
+
|
|
578
|
+
```properties
|
|
579
|
+
# gradle.properties
|
|
580
|
+
android.experimental.enableScreenshotTest=true
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
```kotlin
|
|
584
|
+
// build.gradle.kts
|
|
585
|
+
plugins {
|
|
586
|
+
id("com.android.compose.screenshot")
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
android {
|
|
590
|
+
testOptions {
|
|
591
|
+
screenshotTests {
|
|
592
|
+
imageDifferenceThreshold = 0.001f // fraction of pixels allowed to differ
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Mark previews under the `screenshotTest` source set with `@PreviewTest`:
|
|
599
|
+
|
|
600
|
+
```kotlin
|
|
601
|
+
// src/screenshotTest/kotlin/...
|
|
602
|
+
class FlightCardScreenshots {
|
|
603
|
+
|
|
604
|
+
@PreviewTest
|
|
605
|
+
@Preview(showBackground = true)
|
|
606
|
+
@Composable
|
|
607
|
+
fun cardLight() {
|
|
608
|
+
AppTheme { FlightCard(flight = previewFlight) }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
@PreviewTest
|
|
612
|
+
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
|
613
|
+
@Composable
|
|
614
|
+
fun cardDark() {
|
|
615
|
+
AppTheme(darkTheme = true) { FlightCard(flight = previewFlight) }
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Reference images live per variant under
|
|
621
|
+
`src/<variant>ScreenshotTest/reference/` (for example
|
|
622
|
+
`src/freeDebugScreenshotTest/reference/`), so each flavor keeps its own
|
|
623
|
+
baselines. Record and validate:
|
|
624
|
+
|
|
625
|
+
```bash
|
|
626
|
+
# Record/update baselines for the variant
|
|
627
|
+
./gradlew updateDebugScreenshotTest
|
|
628
|
+
|
|
629
|
+
# Validate against committed baselines
|
|
630
|
+
./gradlew validateDebugScreenshotTest
|
|
631
|
+
```
|