@@ -53,204 +53,39 @@ Btw, MVIKotlin store itself are already declared as a factory class, following t
53
53
54
54
## MVIKotlin Store Testing
55
55
56
- The decompose-logic module contains comprehensive test coverage for all MVIKotlin stores. The tests follow consistent patterns and conventions that should be used when writing new store tests.
56
+ ### Test Structure
57
57
58
- ### Test Class Structure and Setup
58
+ - ** Naming** : Test classes use ` Test ` suffix (e.g., ` LandingPageStoreTest ` )
59
+ - ** Setup** : Mock services with ` dev.mokkery ` , use ` StandardTestDispatcher ` , wrap ` DefaultStoreFactory ` with ` LoggingStoreFactory `
60
+ - ** Cleanup** : Call ` store.dispose() ` in ` @AfterTest `
59
61
60
- ** Naming Convention** : Test classes are named after the store they test with a ` Test ` suffix (e.g., ` LandingPageStoreTest ` ).
61
-
62
- ** Core Components** : Each test class declares:
63
-
64
- - A ` StandardTestDispatcher ` for controlling coroutine execution
65
- - Mocked service dependencies
66
- - The store being tested
67
-
68
- ``` kotlin
69
- class LandingPageStoreTest {
70
- private val testDispatcher = StandardTestDispatcher ()
71
-
72
- lateinit var landingService: LandingService
73
- lateinit var landingPageStore: Store <LandingPageIntent , LandingPageState , LandingPageLabel >
74
- }
75
- ```
76
-
77
- ** Setup Pattern** : The ` @BeforeTest ` method follows this pattern:
78
-
79
- 1 . Mock service dependencies using ` dev.mokkery `
80
- 2 . Create store using its factory
81
- 3 . Inject mocked services and test dispatcher
82
- 4 . Wrap ` DefaultStoreFactory ` with ` LoggingStoreFactory ` for debugging
83
-
84
- ``` kotlin
85
- @BeforeTest
86
- fun setUp () {
87
- landingService = mock()
88
- landingPageStore = LandingPageStoreFactory (
89
- LoggingStoreFactory (DefaultStoreFactory ()),
90
- landingService,
91
- testDispatcher
92
- ).createStore()
93
- }
94
- ```
95
-
96
- ** Cleanup** : The ` @AfterTest ` method must call ` store.dispose() ` to prevent test leakage:
97
-
98
- ``` kotlin
99
- @AfterTest
100
- fun reset () {
101
- landingPageStore.dispose()
102
- }
103
- ```
104
-
105
- ### Dependency Mocking with Mokkery
106
-
107
- ** Library** : Use ` dev.mokkery ` exclusively for mocking.
108
-
109
- ** Creating Mocks** : Use ` mock() ` to create service mocks:
62
+ ### Mocking with Mokkery
110
63
111
64
``` kotlin
65
+ // Create mocks
112
66
landingService = mock()
113
- ```
114
-
115
- ** Stubbing Suspend Functions** :
116
-
117
- - Success: ` everySuspend { service.method(any()) } returns result `
118
- - Failure: ` everySuspend { service.method(any()) } throws Exception("error") `
119
-
120
- ``` kotlin
121
- // Success case
122
- everySuspend {
123
- articlesListService.getArticles(defaultSearchFilter, offset = 0 )
124
- } returns testArticles
125
-
126
- // Failure case
127
- everySuspend {
128
- landingService.checkAccessibilityAndSetUrl(any())
129
- } throws Exception (" some error" )
130
- ```
131
-
132
- ** Stubbing Regular Functions/Flows** : Use ` every { ... } returns ... ` for non-suspend functions:
133
-
134
- ``` kotlin
135
- every { mePageService.userConfigFlow } returns flowOf(UserConfigState .OnLogin (" test-url" , userInfo))
136
- ```
137
-
138
- ** Verification** : Use ` verifySuspend ` with ` VerifyMode.exactly(1) ` to assert method calls:
139
-
140
- ``` kotlin
141
- verifySuspend(exactly(1 )) {
142
- landingService.checkAccessibilityAndSetUrl(" a change" )
143
- }
144
- ```
145
-
146
- ### State Change Testing
147
-
148
- ** Test Runner** : Use ` runTest(testDispatcher) ` for all coroutine-based tests.
149
-
150
- ** State Observation Pattern** : Use ` Channel ` to observe state changes sequentially:
151
-
152
- ``` kotlin
153
- @Test
154
- fun testStateChange () = runTest(testDispatcher) {
155
- val stateChannel = Channel <ArticlesListState >()
156
-
157
- val disposable = articlesListStore.states(observer(onNext = {
158
- this .launch { stateChannel.send(it) }
159
- }))
160
-
161
- // Ignore initial state if not relevant
162
- stateChannel.receive()
163
-
164
- // Send intent
165
- articlesListStore.accept(ArticlesListIntent .LoadMore )
166
-
167
- // Assert state transitions
168
- val loadingState = stateChannel.receive()
169
- assertEquals(LoadMoreState .Loading , loadingState.loadMoreState)
170
-
171
- val loadedState = stateChannel.receive()
172
- assertEquals(testArticles, loadedState.collectedThumbInfos)
173
-
174
- disposable.dispose()
175
- }
176
- ```
177
-
178
- ** Direct State Access** : For simple synchronous changes, access state directly:
179
-
180
- ``` kotlin
181
- authPageStore.accept(AuthPageIntent .UsernameChanged (" new username" ))
182
- assertEquals(authPageStore.state.username, " new username" )
183
- ```
184
-
185
- ### Label Testing
186
-
187
- ** Primary Pattern** : Use ` store.labelsChannel(scope) ` with separate ` TestScope ` :
188
-
189
- ``` kotlin
190
- @OptIn(ExperimentalMviKotlinApi ::class )
191
- @Test
192
- fun testLabel () = runTest(testDispatcher) {
193
- val testScope = TestScope (testDispatcher)
194
- val labelsChannel = landingPageStore.labelsChannel(testScope)
195
-
196
- landingPageStore.accept(LandingPageIntent .CheckAndMoveToMainPage )
197
-
198
- val label = labelsChannel.receive()
199
- assertEquals(LandingPageLabel .ToNextPage , label)
200
-
201
- testScope.cancel() // Critical for test completion
202
- }
203
- ```
204
-
205
- ** Important** : Always use a separate ` TestScope ` for ` labelsChannel ` to prevent deadlocks with ` runTest ` . Cancel the scope at the end of the test.
206
-
207
- ### Error Handling Patterns
208
-
209
- ** Simulating Errors** : Stub service methods to throw exceptions:
210
-
211
- ``` kotlin
212
- val errorMessage = " Failed to load articles"
213
- everySuspend {
214
- articlesListService.getArticles(any(), offset = any())
215
- } throws RuntimeException (errorMessage)
216
- ```
217
-
218
- ** Testing Error State** : If errors update the store state:
219
-
220
- ``` kotlin
221
- landingPageStore.accept(LandingPageIntent .CheckAndMoveToMainPage )
222
67
223
- val newState = stateChannel.receive()
224
- assertEquals(errorMessage, newState.errorMsg)
225
- ```
226
-
227
- ** Testing Error Labels** : If errors are communicated via labels:
68
+ // Stub suspend functions
69
+ everySuspend { service.method(any()) } returns result
70
+ everySuspend { service.method(any()) } throws Exception (" error" )
228
71
229
- ``` kotlin
230
- val failureLabel = labelChannel.receive()
231
- assertIs<LandingPageLabel .Failure >(failureLabel)
232
- assertEquals(" some error" , failureLabel.message)
233
- assertTrue(failureLabel.exception is RuntimeException )
72
+ // Verify calls
73
+ verifySuspend(exactly(1 )) { service.method(any()) }
234
74
```
235
75
236
- ### Async/Coroutine Management
76
+ ### Testing Patterns
237
77
238
- ** Test Dispatcher ** : Always use ` StandardTestDispatcher ` for predictable coroutine execution:
78
+ ** State Changes ** : Use ` Channel ` for async state observation or direct access for sync changes
239
79
240
80
``` kotlin
241
- private val testDispatcher = StandardTestDispatcher ()
81
+ val stateChannel = Channel <State >()
82
+ store.states(observer { launch { stateChannel.send(it) } })
83
+ // OR
84
+ store.accept(intent)
85
+ assertEquals(expected, store.state.property)
242
86
```
243
87
244
- ** Test Runner** : Use ` runTest(testDispatcher) ` for controlled async execution:
245
-
246
- ``` kotlin
247
- @Test
248
- fun myTest () = runTest(testDispatcher) {
249
- // test code
250
- }
251
- ```
252
-
253
- ** Scope Management** : Use ` TestScope ` for managing coroutine lifecycles, especially for ` labelsChannel ` :
88
+ ** Labels** : Use separate ` TestScope ` to prevent deadlocks
254
89
255
90
``` kotlin
256
91
val testScope = TestScope (testDispatcher)
@@ -259,96 +94,8 @@ val labelsChannel = store.labelsChannel(testScope)
259
94
testScope.cancel()
260
95
```
261
96
262
- ### Common Test Utilities and Best Practices
263
-
264
- ** Required Dependencies** :
265
-
266
- - ` kotlinx-coroutines-test ` for ` StandardTestDispatcher ` , ` TestScope ` , and ` runTest `
267
- - ` com.arkivanov.mvikotlin.extensions.coroutines ` for ` labelsChannel `
268
- - ` dev.mokkery ` for mocking
269
- - ` kotlinx.coroutines.channels.Channel ` for state observation
270
-
271
- ** LoggingStoreFactory** : Always wrap ` DefaultStoreFactory ` with ` LoggingStoreFactory ` for better debugging:
272
-
273
- ``` kotlin
274
- LandingPageStoreFactory (
275
- LoggingStoreFactory (DefaultStoreFactory ()),
276
- landingService,
277
- testDispatcher
278
- ).createStore()
279
- ```
280
-
281
- ** Resource Cleanup** : Always dispose of resources:
282
-
283
- - Any coroutine launched by the coroutine scope used by the ` runTest() ` should be cancelled
284
- - Any new coroutine scope created should be cancelled, e.g. ` myTestScope.cancel() `
285
- - Any ` Disposable ` from Decompose library should be disposed by calling ` disposable.dispose() `
286
- - Call ` store.dispose() ` in ` @AfterTest `
287
-
288
- ** Test Data** : Define test data as properties for reuse across test methods:
289
-
290
- ``` kotlin
291
- private val testArticles = listOf (
292
- ArticleInfo (
293
- authorThumbnail = " https://example.com/avatar.png" ,
294
- authorUsername = " testuser" ,
295
- title = " Test Article" ,
296
- // ... other properties
297
- )
298
- )
299
- ```
300
-
301
- ### Bootstrapper Testing
302
-
303
- ** AutoInit Parameter** : Stores with bootstrappers require special handling during testing to control when the bootstrapper starts.
304
-
305
- ** Pattern** : Store factory methods should accept an ` autoInit: Boolean = true ` parameter:
306
-
307
- ``` kotlin
308
- fun createStore (autoInit : Boolean = true) = storeFactory.create(
309
- name = " MyStore" ,
310
- initialState = MyState .initial(),
311
- bootstrapper = createBootstrapper(),
312
- executorFactory = executor,
313
- reducer = reducer,
314
- autoInit = autoInit
315
- )
316
- ```
317
-
318
- ** Usage in Tests** : Always pass ` autoInit = false ` in tests to control bootstrapper timing:
319
-
320
- ``` kotlin
321
- @BeforeTest
322
- fun setUp () {
323
- myService = mock()
324
- myStore = MyStoreFactory (
325
- LoggingStoreFactory (DefaultStoreFactory ()),
326
- myService,
327
- testDispatcher,
328
- ).createStore(autoInit = false ) // Prevents automatic bootstrapper start
329
- }
330
- ```
331
-
332
- ** Manual Initialization** : Call ` store.init() ` when ready to test bootstrapper behavior:
333
-
334
- ``` kotlin
335
- @Test
336
- fun testBootstrapperFlow () = runTest(testDispatcher) {
337
- // Setup mocks first
338
- val someFlowChannel = Channel <SomeFlowValue >()
339
- every { myService.someFlow } returns someFlowChannel.receiveAsFlow()
340
-
341
- // Start bootstrapper manually
342
- myStore.init ()
343
-
344
- // Test the resulting state changes
345
- // ...
346
- }
347
- ```
97
+ ** Bootstrappers** : If the store has a bootstrapper, use ` autoInit = false ` in tests, call ` store.init() ` manually when ready
348
98
349
- ** Why This Pattern is Necessary** : Without ` autoInit = false ` , the bootstrapper immediately starts executing when the store is created, potentially:
350
- - Dispatching actions before test setup is complete
351
- - Causing race conditions with mock setup
352
- - Making tests unpredictable and hard to debug
99
+ ### Resource Management
353
100
354
- These patterns ensure consistent, reliable, and maintainable tests for all MVIKotlin stores in the decompose-logic module.
101
+ Always dispose: coroutine scopes ( ` cancel() ` ), Decompose disposables ( ` .dispose() ` )
0 commit comments