Skip to content

Commit ca3b928

Browse files
CXwudiclaude
andcommitted
📝 compact MVIKotlin testing section in decompose-logic.md
Condensed verbose testing documentation from 301 to 43 lines while preserving all essential information. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 25d28db commit ca3b928

File tree

1 file changed

+22
-275
lines changed

1 file changed

+22
-275
lines changed

common-doc/frontend/decompose-logic.md

Lines changed: 22 additions & 275 deletions
Original file line numberDiff line numberDiff line change
@@ -53,204 +53,39 @@ Btw, MVIKotlin store itself are already declared as a factory class, following t
5353

5454
## MVIKotlin Store Testing
5555

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
5757

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`
5961

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
11063

11164
```kotlin
65+
// Create mocks
11266
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)
22267

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")
22871

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()) }
23474
```
23575

236-
### Async/Coroutine Management
76+
### Testing Patterns
23777

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
23979

24080
```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)
24286
```
24387

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
25489

25590
```kotlin
25691
val testScope = TestScope(testDispatcher)
@@ -259,96 +94,8 @@ val labelsChannel = store.labelsChannel(testScope)
25994
testScope.cancel()
26095
```
26196

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
34898

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
353100

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

Comments
 (0)