Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 14:34:29 +04:00
parent 774898f560
commit 4e05219c25
11 changed files with 579 additions and 243 deletions

View file

@ -0,0 +1,214 @@
# Unit Testing Rules
This document covers **unit tests** only — sources under `src/test`, running on the JVM via JUnit 5 (Jupiter). UI / instrumentation tests (`src/androidTest`, Kaspresso + Espresso on the JUnit 4 on-device runner) are a separate concern and out of scope here.
## Stack
| Purpose | Library | Version source |
|---|---|---|
| Test runner | JUnit 5 (Jupiter) | `deps.test.junit5` |
| Mocking | MockK | `deps.test.mockk` |
| Flow testing | Turbine | `deps.test.turbine` |
| Assertions | Google Truth | `deps.test.truth` |
| Coroutines | `kotlinx-coroutines-test` | `deps.test.coroutine` |
All unit tests run on JUnit 5. JUnit 4 (`deps.test.junit` = `junit:junit`) is **not** used in `src/test` at all — it survives only in `src/androidTest` instrumentation. Don't add new JUnit 4 unit tests.
Versions live in `gradle/dependencies.toml`. Do not hardcode library coordinates in module build scripts — always go through the catalog.
## Shared test modules
Depend on these via `testImplementation(projects.*)` — never copy their utilities inline.
Build test fixtures with **factory functions that default every argument** (`createXxx(id = 1, name = "Cat", … )`) rather than calling bloated constructors at each call site. A test then overrides only the fields relevant to it, so the intent stays visible and adding a model field doesn't churn every test. This is the idiom behind the `Mock*Factory` classes below — extend them instead of hand-rolling fixtures.
### `:test:core` (pure JVM)
`test/core/src/main/java/com/tangem/test/core/`. Re-exports as `api`: `test.coroutine`, `test.junit5`, `test.mockk`, `test.truth`, `test.turbine`. Use it as the one-line entry point to pull the whole unit-testing stack into a JVM module. Depends on `domain:core` and `arrow.core` (so its utilities can reference domain abstractions like `FlowProducer`).
Utilities:
- `TestCoroutineExt.getEmittedValues(flow)` — collect a `Flow` into a `List` from a `TestScope`.
- `TestFlowProducerTools(scope, dispatcher)` — test double for `FlowProducerTools` that mirrors production `DefaultFlowProducerTools` (retryWhen + fallback + `distinctUntilChanged` + `shareIn`) on a caller-provided test scope/dispatcher, without analytics/logging. Pass `TestScope.backgroundScope` + a dispatcher built from `testScheduler` so the 2s retry delay is virtual-time-controllable. Use it for `FlowProducer` tests instead of mocking `FlowProducerTools`.
- `@ProvideTestModels` — meta-annotation over JUnit 5 `@MethodSource("provideTestModels")` for parameterized tests.
- `TruthArrowExt``assertEither`, `assertEitherRight`, `assertEitherLeft`, `assertSome`, `assertNone` for Arrow types.
### `:common:test` (Android library — legacy, being retired)
`common/test/src/main/java/com/tangem/common/test/`. Factories and fakes for domain/data models. Being phased out in favour of `:test:core` (JVM mechanisms) and `:test:mock` (mock factories); don't add new utilities here.
- `TestAppCoroutineScope(testScope)` — test implementation of `AppCoroutineScope`.
- `MockStateDataStore` — in-memory `DataStore` for tests.
- `Mock*Factory` classes for `CryptoCurrency`, `UserWallet`, `NetworkStatus`, `ScanResponse`, `YieldDTO`, `QuoteResponse`, `UpdateWalletManagerResult` etc.
### `:test:mock`
`test/mock/`. Mock data for models not yet covered elsewhere (currently `MockAccounts`). Add to this module rather than creating new ad-hoc mock files.
## Dispatchers
Never use `Dispatchers.Main`/`IO`/`Default` directly in production code — always inject `CoroutineDispatcherProvider` from `core/utils`.
In tests, override with `TestingCoroutineDispatcherProvider` (defined in `core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt`). By default `main`/`mainImmediate`/`io`/`default` are `Dispatchers.Unconfined`, while `single` is a single-thread `Executors.newFixedThreadPool(1)` dispatcher.
For `Model`-layer tests inside features, construct it with a single `StandardTestDispatcher(testScheduler)` for all five roles (built from the enclosing `TestScope`) so `advanceUntilIdle()` controls execution. See any `features/*/impl` model test for the `TestScope.createTestingCoroutineDispatcherProvider()` helper.
## Naming & placement
- **Test class**: `FooTest` (singular noun). Not `FooSpec`, not `FooBehavior`, not `FooTests`.
- **Test method**: backtick-quoted sentence that **must** follow `GIVEN … WHEN … THEN …` (uppercase). The name states the behaviour under test — precondition, action, expected outcome — not the implementation.
```kotlin
@Test
fun `GIVEN currency status emitted WHEN model created THEN analytics sent`() = runTest { … }
```
A part may collapse when trivial (e.g. `GIVEN no wallets WHEN load THEN returns empty`), but all three keywords stay present.
- **Test body**: if the body is more than a one-liner (i.e. has distinct setup / action / check phases), it **must** be marked with `// Arrange`, `// Act`, `// Assert` comments. GWT names the behaviour from the outside; AAA structures the code inside.
- **Location**: mirrored packages under `src/test/kotlin/`. No `src/testFixtures/` — shared helpers go to the modules above.
## Unit-test skeleton (JUnit 5)
```kotlin
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FooTest {
private val barUseCase: BarUseCase = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val foo = Foo(barUseCase, dispatchers)
@BeforeEach
fun resetMocks() {
clearMocks(barUseCase)
}
@Test
fun `GIVEN bar returns right WHEN invoke THEN emits value`() = runTest {
// Arrange
coEvery { barUseCase(any()) } returns Either.Right(expected)
// Act
val actual = foo.invoke(input)
// Assert
assertThat(actual).isEqualTo(expected)
coVerify(exactly = 1) { barUseCase(input) }
}
}
```
- `@TestInstance(Lifecycle.PER_CLASS)` is **opt-in per class, not the project default.** Add it only where you need a non-static `@MethodSource`/`provideTestModels` provider or want to share expensive setup across methods (~half of test classes do). The JUnit default stays `PER_METHOD` (a fresh instance per test). Beware: `PER_CLASS` reuses one instance across all methods, so mutable fields leak between tests — reset them in `@BeforeEach`.
- **Group by method under test.** When a class/file exposes several functions and each accumulates many tests, don't keep one flat list — give each function its own `@Nested @TestInstance(Lifecycle.PER_CLASS) inner class`. The nesting maps the test structure onto the production API and keeps per-function setup local to its group.
```kotlin
internal class DesignControllerTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetDesigns {
@Test fun `GIVEN … WHEN getDesigns THEN all fields included`() { … }
@Test fun `GIVEN limit WHEN getDesigns THEN list is capped`() { … }
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class DeleteDesign {
@Test fun `GIVEN existing id WHEN deleteDesign THEN removed from db`() { … }
}
}
```
- You do **not** declare `useJUnitPlatform()` per module — the `configuration` convention plugin applies it (and the JUnit 5 engine) to every module. See "Gradle wiring" below.
## MockK conventions
- Field-level init: `private val x: T = mockk()`; use `mockk(relaxed = true)` only when stubs are not the subject of the test.
- Stub coroutines with `coEvery { … } returns …` / `returnsMany(...)`; verify with `coVerify { … }`, `coVerify(exactly = n) { … }`, `coVerifyOrder { … }`.
- Create mocks once as `val` fields and reset them with `clearMocks(...)` in `@BeforeEach` — recreating mocks (`x = mockk()` inside `@BeforeEach`) every test is measurably expensive (MockK instantiation dominates the runtime of small tests). Only recreate a field when the subject-under-test itself holds mutable state that must be fresh per test.
- For companion/top-level objects use `mockkObject(Obj)` and pair with `unmockkObject(Obj)` in teardown.
## Flow testing
- Default to `TestScope.getEmittedValues(flow)` (from `:test:core`) when you just want the list of values produced during the test scope — this is the most common approach in the codebase.
- Use **Turbine** (`flow.test { … }`) when you specifically need to assert on the emission *sequence* (ordering, intermediate items, completion/error timing), or for hot `SharedFlow`s where you must control collection start/stop.
- Drive hot sources via `MutableSharedFlow` / `MutableStateFlow` and `advanceUntilIdle()` between emission and assertion.
- For `FlowProducer` tests (retry/fallback/shareIn semantics), inject `TestFlowProducerTools` from `:test:core` and use Turbine + `advanceTimeBy(2001); runCurrent()` to step over the 2s retry window deterministically.
## Parameterized tests
When the same behaviour is exercised over a set of inputs, write **one parameterized test** — not several near-identical methods, and not one method with a stack of `assertThat(...)` calls over different inputs. Repeated asserts hide *which* input failed and stop at the first failure; a parameterized test reports each case separately. Add a new case = add a row to the provider.
Use the project's `@ProvideTestModels` annotation — it wires `@MethodSource("provideTestModels")` for you.
```kotlin
@ParameterizedTest
@ProvideTestModels
fun create(model: CreateModel) = runTest { … }
private data class CreateModel(val input: Input, val expected: Either<Error, Value>)
private fun provideTestModels() = listOf(
CreateModel(input = …, expected = Either.Right(…)),
CreateModel(input = …, expected = Either.Left(Error.Foo)),
)
```
`provideTestModels` is a non-static instance method, so the class needs `@TestInstance(Lifecycle.PER_CLASS)` (or a `@JvmStatic` provider in a companion).
## Assertions
- Default to Truth: `assertThat(actual).isEqualTo(expected)`, `.isInstanceOf(T::class.java)`, `.hasMessageThat().isEqualTo(…)`, `.isNull()`.
- **Assert whole objects, not field-by-field.** When the type is a `data class`, build the expected instance and compare with one `isEqualTo(expected)` — the structural `equals`/`toString` gives a self-explanatory diff. For collections use `.containsExactly(…)` (add `.inOrder()` when order matters). Prefer this over a series of `assertThat(actual.id)…`, `assertThat(actual.name)…` checks, which produce opaque failures and miss unexpected fields.
- For Arrow `Either`/`Option`, prefer `assertEither`, `assertEitherLeft`, `assertEitherRight`, `assertSome`, `assertNone` from `:test:core`.
- Exception testing: `runCatching { … }.exceptionOrNull()` + Truth, not `assertThrows`.
## Feature model tests
`features/*/impl` Decompose models share a heavy dependency graph — extract a `XxxModelTestBase` with pre-built mocks/fixtures and inherit per-scenario test classes from it (see `features/staking/impl/.../presentation/model/StakingModelTestBase` as reference).
Lifecycle:
```kotlin
val model = createModel(testScope = this)
advanceUntilIdle()
// assertions…
model.onDestroy()
```
## Running tests
```bash
./gradlew unitTest # all JVM + debug/googleDebug unit tests (root aggregator)
./gradlew :<module>:testDebugUnitTest # single Android library module
./gradlew :app:testGoogleDebugUnitTest # app module
./gradlew :<jvm-module>:test # pure JVM module
./gradlew :<module>:testDebugUnitTest --tests "com.tangem.<Fqn>Test" # single class
```
The `unitTest` aggregator lives in the root `build.gradle.kts`; it is wired automatically for every `com.android.application`, `com.android.library`, and pure `org.jetbrains.kotlin.jvm` subproject — no need to touch it when adding a new module.
## Gradle wiring for a new test-bearing module
The `configuration` convention plugin (`configureUnitTests` in `plugins/configuration/.../TestConfigurations.kt`) centralizes the JUnit 5 setup for **every** module:
1. `useJUnitPlatform()` on all `Test` tasks — so Jupiter tests are discovered (without it the default JUnit 4 runner runs zero Jupiter tests).
2. `testRuntimeOnly(<test-junit5-engine>)` — the Jupiter runtime engine. The platform without the engine silently runs **zero** tests, so these two are paired in one place.
3. Test logging (full exception format, standard streams, PASSED/SKIPPED/FAILED events, per-task summary).
So a test module must **not** re-declare `useJUnitPlatform()`, the engine, or `testLogging { … }`. It only needs the Jupiter **API** (provided transitively by `:test:core`, or declared explicitly):
```kotlin
// Any module (JVM or Android library) — plugin already supplies platform + engine + logging
plugins {
alias(deps.plugins.kotlin.jvm) // or the android-library convention
id("configuration")
}
dependencies {
testImplementation(projects.test.core) // junit5 (api) + mockk + turbine + truth + coroutine-test
testImplementation(projects.common.test) // add only if the tests need legacy model factories / fakes
}
```
If a module doesn't want the full `:test:core` bundle, declare the Jupiter API directly with `testImplementation(deps.test.junit5)` — the engine still comes from the plugin, so never add `testRuntimeOnly(deps.test.junit5.engine)` per module.
## Module type vs. layer
The domain layer is **not** uniformly pure-JVM: domain modules are split roughly evenly between `org.jetbrains.kotlin.jvm` (pure JVM) and `com.android.library` modules. Don't assume the layer dictates the module type — check the `plugins { }` block to pick the right test task:
- `kotlin.jvm` (pure JVM) → `./gradlew :<module>:test`
- `com.android.library` / `com.android.application``./gradlew :<module>:testDebugUnitTest` (`:app``testGoogleDebugUnitTest`)
`./gradlew unitTest` runs the right task for every module regardless of type.

View file

@ -419,7 +419,6 @@ dependencies {
/** Testing libraries */
testImplementation(projects.test.core)
testImplementation(projects.common.test)
testImplementation(deps.test.junit)
androidTestImplementation(deps.test.junit.android)
androidTestImplementation(deps.test.espresso)
androidTestImplementation(deps.test.espresso.intents)

View file

@ -8,22 +8,27 @@ import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.TokensSortType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import app.cash.turbine.test
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("UnusedFlow")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DefaultMultiAccountListProducerTest {
@ -40,6 +45,23 @@ class DefaultMultiAccountListProducerTest {
dispatchers = TestingCoroutineDispatcherProvider(),
)
private fun TestScope.createProducer(): DefaultMultiAccountListProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultMultiAccountListProducer(
params = Unit,
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
userWalletsListRepository = userWalletsListRepository,
singleAccountListSupplier = singleAccountListSupplier,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
}
private val userWalletId = UserWalletId("011")
private val userWallet = mockk<UserWallet> {
every { this@mockk.walletId } returns userWalletId
@ -144,7 +166,6 @@ class DefaultMultiAccountListProducerTest {
}
}
@Disabled
@Test
fun `flow returns empty list if factory throws exception`() = runTest {
// Arrange
@ -154,12 +175,12 @@ class DefaultMultiAccountListProducerTest {
val exception = RuntimeException("Converter error")
every { singleAccountListSupplier.invoke(userWalletId) } throws exception
// Act
val actual = producer.produceWithFallback().let(::getEmittedValues)
// Assert
val expected = emptyList<AccountList>()
Truth.assertThat(actual).containsExactly(expected)
// Act / Assert: the factory throws -> retryWhen emits the empty fallback.
// Stop before the 2s retry fires so the upstream is collected exactly once.
createProducer().produceWithFallback().test {
Truth.assertThat(awaitItem()).isEqualTo(emptyList<AccountList>())
cancelAndIgnoreRemainingEvents()
}
coVerifySequence {
userWalletsListRepository.load()

View file

@ -1,5 +1,6 @@
package com.tangem.data.networks.multi
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.common.test.domain.card.MockScanResponseFactory
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
@ -11,23 +12,28 @@ import com.tangem.data.networks.store.NetworksStatusesStore
import com.tangem.data.networks.toSimple
import com.tangem.domain.card.configs.GenericCardConfig
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultMultiNetworkStatusProducerTest {
@ -48,6 +54,24 @@ internal class DefaultMultiNetworkStatusProducerTest {
flowProducerTools = flowProducerTools,
)
private fun TestScope.createProducer(): DefaultMultiNetworkStatusProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultMultiNetworkStatusProducer(
params = params,
networksStatusesStore = networksStatusesStore,
userWalletsListRepository = userWalletsListRepository,
networkFactory = networkFactory,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
)
}
@BeforeEach
fun resetMocks() {
clearMocks(networksStatusesStore, userWalletsListRepository, networkFactory)
@ -294,7 +318,6 @@ internal class DefaultMultiNetworkStatusProducerTest {
Truth.assertThat(actual2.first()).isEqualTo(expected2)
}
@Disabled
@Test
fun `flow throws exception`() = runTest {
// Arrange
@ -338,30 +361,26 @@ internal class DefaultMultiNetworkStatusProducerTest {
} returns statuses.last().network
// endregion
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
// Act 1 (fallback)
val actual1 = getEmittedValues(flow = producerFlow)
producerFlow.test {
// first collection throws -> retryWhen emits the empty fallback, then waits 2s
Truth.assertThat(awaitItem()).isEqualTo(emptySet<NetworkStatus>())
// Assert
val expected1 = emptySet<NetworkStatus>()
Truth.assertThat(actual1.size).isEqualTo(1)
Truth.assertThat(actual1.first()).isEqualTo(expected1)
verify(inverse = true) {
networkFactory.create(networkId = any(), derivationPath = any(), userWallet = any())
}
verifyOrder(inverse = true) {
userWalletsListRepository.getSyncOrNull(any())
networkFactory.create(networkId = any(), derivationPath = any(), userWallet = any())
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
Truth.assertThat(awaitItem()).isEqualTo(statuses)
cancelAndIgnoreRemainingEvents()
}
// Act 2 (emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
// Assert
val expected2 = statuses
Truth.assertThat(actual2.size).isEqualTo(1)
Truth.assertThat(actual2.first()).isEqualTo(expected2)
verifyOrder {
userWalletsListRepository.userWallets
networkFactory.create(

View file

@ -1,27 +1,33 @@
package com.tangem.data.networks.single
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.common.test.domain.network.MockNetworkStatusFactory
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.network.NetworkStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.networks.multi.MultiNetworkStatusProducer
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.single.SingleNetworkStatusProducer
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleNetworkStatusProducerTest {
private val params = SingleNetworkStatusProducer.Params(
@ -30,15 +36,22 @@ internal class DefaultSingleNetworkStatusProducerTest {
)
private val multiNetworkStatusSupplier = mockk<MultiNetworkStatusSupplier>()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val flowProducerTools: FlowProducerTools = mockk()
private val producer = DefaultSingleNetworkStatusProducer(
params = params,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = dispatchers,
flowProducerTools = flowProducerTools,
)
private fun TestScope.createProducer(): DefaultSingleNetworkStatusProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultSingleNetworkStatusProducer(
params = params,
multiNetworkStatusSupplier = multiNetworkStatusSupplier,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
)
}
@Test
fun `test that flow is mapped for network from params`() = runTest {
@ -53,7 +66,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produce()
val actual = createProducer().produce()
verify { multiNetworkStatusSupplier(multiParams) }
@ -63,11 +76,6 @@ internal class DefaultSingleNetworkStatusProducerTest {
Truth.assertThat(values).isEqualTo(listOf(status))
}
// TODO: rework for produceWithFallback() hot-SharedFlow semantics. These tests assert against
// multiple cold collections, which is incompatible with shareIn(replay = 1) used in production.
// Dormant under JUnit 4 (useJUnitPlatform without vintage); disabled to match
// DefaultMultiNetworkStatusProducerTest.
@Disabled("Needs rework for produceWithFallback() hot-SharedFlow semantics")
@Test
fun `test that flow is updated if network status is updated`() = runTest {
val expected = MutableSharedFlow<Set<NetworkStatus>>(replay = 2, extraBufferCapacity = 1)
@ -75,30 +83,23 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
actual.test {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null))
expected.emit(value = setOf(updatedStatus))
Truth.assertThat(awaitItem()).isEqualTo(updatedStatus)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
val updatedStatus = status.copy(value = NetworkStatus.Unreachable(null))
expected.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
cancelAndIgnoreRemainingEvents()
}
}
@Disabled("Needs rework for produceWithFallback() hot-SharedFlow semantics")
@Test
fun `test that flow is filtered the same status`() = runTest {
val expected = MutableSharedFlow<Set<NetworkStatus>>(replay = 2, extraBufferCapacity = 1)
@ -106,29 +107,23 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
// first emit
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
actual.test {
val status = MockNetworkStatusFactory.createMissedDerivation(params.network)
expected.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
// same status again -> filtered out by distinctUntilChanged
expected.emit(value = setOf(status))
expectNoEvents()
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
expected.emit(value = setOf(status))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Disabled("Needs rework for produceWithFallback() infinite retryWhen + delay under virtual time")
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
@ -147,21 +142,24 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { multiNetworkStatusSupplier(multiParams) }
val values1 = getEmittedValues(flow = actual)
actual.test {
// first collection throws -> retryWhen emits the fallback, then waits 2s before retrying
val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network)
Truth.assertThat(awaitItem()).isEqualTo(fallbackStatus)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = MockNetworkStatusFactory.createUnreachable(params.network)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
innerFlow.emit(value = true)
Truth.assertThat(awaitItem()).isEqualTo(status)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Test
@ -173,7 +171,7 @@ internal class DefaultSingleNetworkStatusProducerTest {
val multiParams = MultiNetworkStatusProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns expected
val actual = producer.produce()
val actual = createProducer().produce()
verify { multiNetworkStatusSupplier(multiParams) }

View file

@ -1,26 +1,32 @@
package com.tangem.data.quotes.single
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.tangem.data.quotes.store.QuotesStatusesStore
import com.tangem.domain.core.flow.FlowProducerTools
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.quote.QuoteStatus
import com.tangem.domain.quotes.single.SingleQuoteStatusProducer
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSingleQuoteStatusProducerTest {
private val params = SingleQuoteStatusProducer.Params(
@ -28,14 +34,22 @@ internal class DefaultSingleQuoteStatusProducerTest {
)
private val quotesStore = mockk<QuotesStatusesStore>()
private val flowProducerTools: FlowProducerTools = mockk()
private val producer = DefaultSingleQuoteStatusProducer(
params = params,
quotesStatusesStore = quotesStore,
flowProducerTools = flowProducerTools,
dispatchers = TestingCoroutineDispatcherProvider(),
)
private fun TestScope.createProducer(): DefaultSingleQuoteStatusProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultSingleQuoteStatusProducer(
params = params,
quotesStatusesStore = quotesStore,
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
}
@Test
fun `test that flow is mapped for network from params`() = runTest {
@ -49,7 +63,7 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeQuote
val actual = producer.produce()
val actual = createProducer().produce()
verify { quotesStore.get() }
@ -59,74 +73,60 @@ internal class DefaultSingleQuoteStatusProducerTest {
Truth.assertThat(values).isEqualTo(listOf(status))
}
@Disabled("Needs rework for produceWithFallback() hot-SharedFlow semantics")
@Test
fun `test that flow is updated if quote is updated`() = runTest {
val storeQuote = MutableSharedFlow<Set<QuoteStatus>>(replay = 2, extraBufferCapacity = 1)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
actual.test {
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
val updatedStatus = QuoteStatus(
rawCurrencyId = params.rawCurrencyId,
value = QuoteStatus.Data(
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
)
storeQuote.emit(value = setOf(updatedStatus))
Truth.assertThat(awaitItem()).isEqualTo(updatedStatus)
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
val updatedStatus = QuoteStatus(
rawCurrencyId = params.rawCurrencyId,
value = QuoteStatus.Data(
fiatRate = BigDecimal.ONE,
priceChange = BigDecimal.ZERO,
fiatRateUSD = BigDecimal.ZERO,
source = StatusSource.ACTUAL,
),
)
storeQuote.emit(value = setOf(updatedStatus))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(2)
Truth.assertThat(values2).isEqualTo(listOf(status, updatedStatus))
cancelAndIgnoreRemainingEvents()
}
}
@Disabled("Needs rework for produceWithFallback() hot-SharedFlow semantics")
@Test
fun `test that flow is filtered the same status`() = runTest {
val storeQuote = MutableSharedFlow<Set<QuoteStatus>>(replay = 2, extraBufferCapacity = 1)
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
// first emit
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
actual.test {
val status = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
storeQuote.emit(value = setOf(status))
Truth.assertThat(awaitItem()).isEqualTo(status)
val values1 = getEmittedValues(flow = actual)
// same status again -> filtered out by distinctUntilChanged
storeQuote.emit(value = setOf(status))
expectNoEvents()
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(status))
// second emit
storeQuote.emit(value = setOf(status))
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Disabled("Needs rework for produceWithFallback() infinite retryWhen + delay under virtual time")
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
@ -152,24 +152,24 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeQuote
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
val values1 = getEmittedValues(flow = actual)
actual.test {
val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
Truth.assertThat(awaitItem()).isEqualTo(fallbackStatus)
Truth.assertThat(values1.size).isEqualTo(1)
val fallbackStatus = QuoteStatus(rawCurrencyId = params.rawCurrencyId)
Truth.assertThat(values1).isEqualTo(listOf(fallbackStatus))
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
innerFlow.emit(value = true)
Truth.assertThat(awaitItem()).isEqualTo(status)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(status))
cancelAndIgnoreRemainingEvents()
}
}
@Disabled("Needs rework for produceWithFallback() hot-SharedFlow semantics")
@Test
fun `test if flow doesn't contain network from params`() = runTest {
val storeFlow = flowOf(
@ -180,12 +180,14 @@ internal class DefaultSingleQuoteStatusProducerTest {
every { quotesStore.get() } returns storeFlow
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
verify { quotesStore.get() }
val values = getEmittedValues(flow = actual)
Truth.assertThat(values.size).isEqualTo(0)
actual.test {
// params currency (BTC) is not in the store -> nothing is emitted
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -12,19 +12,26 @@ import com.tangem.domain.models.staking.*
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import app.cash.turbine.test
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultMultiStakingBalanceProducerTest {
private val params = MultiStakingBalanceProducer.Params(userWalletId = UserWalletId("011"))
@ -42,6 +49,25 @@ internal class DefaultMultiStakingBalanceProducerTest {
dispatchers = dispatchers,
)
// Producer wired with a real test FlowProducerTools (shareIn + retry + distinctUntilChanged)
// for produceWithFallback() cases.
private fun TestScope.createProducer(): DefaultMultiStakingBalanceProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultMultiStakingBalanceProducer(
params = params,
stakeKitBalancesStore = stakeKitBalancesStore,
p2PEthPoolBalancesStore = p2PEthPoolBalancesStore,
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
)
}
@Test
fun `test that flow is mapped for user wallet id from params`() = runTest {
val balances = setOf(
@ -107,7 +133,6 @@ internal class DefaultMultiStakingBalanceProducerTest {
Truth.assertThat(values2).isEqualTo(expected)
}
@Disabled("Needs rework: distinctUntilChanged moved into produceWithFallback()/shareInProducer")
@Test
fun `test that flow is filtered the same balance`() = runTest {
val networksStatusesFlow = MutableSharedFlow<Set<StakingBalance>>(replay = 2)
@ -115,35 +140,28 @@ internal class DefaultMultiStakingBalanceProducerTest {
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produce()
val actual = createProducer().produceWithFallback()
// check after producer.produce()
verify { stakeKitBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
// first emit
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
actual.test {
val wrappers = setOf(
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(tonId).toDomain(),
MockYieldBalanceWrapperDTOFactory.createWithEmptyBalance(solanaId).toDomain(),
)
networksStatusesFlow.emit(wrappers)
networksStatusesFlow.emit(wrappers)
Truth.assertThat(awaitItem()).isEqualTo(wrappers)
val values1 = getEmittedValues(flow = actual)
// same balances again -> filtered out by distinctUntilChanged
networksStatusesFlow.emit(wrappers)
expectNoEvents()
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1.first()).isEqualTo(wrappers)
// second emit
networksStatusesFlow.emit(wrappers)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2.first()).isEqualTo(wrappers)
cancelAndIgnoreRemainingEvents()
}
}
@Disabled("Needs rework for produceWithFallback() infinite retryWhen + delay under virtual time")
@Test
fun `test if flow throws exception`() = runTest {
val exception = IllegalStateException()
@ -165,23 +183,24 @@ internal class DefaultMultiStakingBalanceProducerTest {
every { stakeKitBalancesStore.get(params.userWalletId) } returns networksStatusesFlow
every { p2PEthPoolBalancesStore.get(params.userWalletId) } returns flowOf(emptySet())
val actual = producer.produceWithFallback()
val actual = createProducer().produceWithFallback()
// check after producer.produce()
verify { stakeKitBalancesStore.get(params.userWalletId) }
verify { p2PEthPoolBalancesStore.get(params.userWalletId) }
val values1 = getEmittedValues(flow = actual)
actual.test {
// first collection throws -> retryWhen emits the empty fallback, then waits 2s
Truth.assertThat(awaitItem()).isEqualTo(emptySet<StakingBalance>())
Truth.assertThat(values1.size).isEqualTo(1)
Truth.assertThat(values1).isEqualTo(listOf(emptySet<StakingBalance>()))
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
innerFlow.emit(value = true)
Truth.assertThat(awaitItem()).isEqualTo(balances)
val values2 = getEmittedValues(flow = actual)
Truth.assertThat(values2.size).isEqualTo(1)
Truth.assertThat(values2).isEqualTo(listOf(balances))
cancelAndIgnoreRemainingEvents()
}
}
@Test

View file

@ -11,22 +11,29 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.staking.multi.MultiStakingBalanceProducer
import com.tangem.domain.staking.multi.MultiStakingBalanceSupplier
import com.tangem.domain.staking.single.SingleStakingBalanceProducer
import app.cash.turbine.test
import com.tangem.test.core.TestFlowProducerTools
import com.tangem.test.core.getEmittedValues
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
/**
[REDACTED_AUTHOR]
*/
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class DefaultSingleStakingBalanceProducerTest {
@ -48,6 +55,23 @@ internal class DefaultSingleStakingBalanceProducerTest {
flowProducerTools = flowProducerTools,
)
private fun TestScope.createProducer(): DefaultSingleStakingBalanceProducer {
val testDispatcher = UnconfinedTestDispatcher(testScheduler)
return DefaultSingleStakingBalanceProducer(
params = params,
multiStakingBalanceSupplier = multiNetworkStatusSupplier,
analyticsExceptionHandler = analyticsExceptionHandler,
dispatchers = TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
),
flowProducerTools = TestFlowProducerTools(scope = backgroundScope, dispatcher = testDispatcher),
)
}
@BeforeEach
fun resetMocks() {
clearMocks(multiNetworkStatusSupplier, analyticsExceptionHandler)
@ -77,7 +101,6 @@ internal class DefaultSingleStakingBalanceProducerTest {
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Disabled
@Test
fun `flow is updated if staking balance is updated`() = runTest {
// Arrange
@ -86,31 +109,23 @@ internal class DefaultSingleStakingBalanceProducerTest {
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
val updatedBalance = StakingBalance.Error(stakingId = tonId)
producerFlow.test {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
multiFlow.emit(value = setOf(balance))
Truth.assertThat(awaitItem()).isEqualTo(balance)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
val actual1 = getEmittedValues(flow = producerFlow)
val updatedBalance = StakingBalance.Error(stakingId = tonId)
multiFlow.emit(value = setOf(updatedBalance))
Truth.assertThat(awaitItem()).isEqualTo(updatedBalance)
// Assert (first emit)
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(balance)
// Act (second emit)
multiFlow.emit(value = setOf(updatedBalance))
val actual2 = getEmittedValues(flow = producerFlow)
// Assert (second emit)
Truth.assertThat(actual2).hasSize(2)
Truth.assertThat(actual2).containsExactly(balance, updatedBalance)
cancelAndIgnoreRemainingEvents()
}
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Disabled
@Test
fun `flow is filtered the same status`() = runTest {
// Arrange
@ -119,30 +134,23 @@ internal class DefaultSingleStakingBalanceProducerTest {
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
producerFlow.test {
val balance = MockYieldBalanceWrapperDTOFactory.createWithBalance(tonId).toDomain()
multiFlow.emit(value = setOf(balance))
Truth.assertThat(awaitItem()).isEqualTo(balance)
// Act (first emit)
multiFlow.emit(value = setOf(balance))
val actual1 = getEmittedValues(flow = producerFlow)
// same balance again -> filtered out by distinctUntilChanged
multiFlow.emit(value = setOf(balance))
expectNoEvents()
// Assert (first emit)
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(balance)
// Act (second emit)
multiFlow.emit(value = setOf(balance))
val actual2 = getEmittedValues(flow = producerFlow)
// Assert (second emit)
Truth.assertThat(actual2).hasSize(1)
Truth.assertThat(actual2).containsExactly(balance)
cancelAndIgnoreRemainingEvents()
}
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}
@Disabled
@Test
fun `flow throws exception`() = runTest {
// Arrange
@ -163,23 +171,22 @@ internal class DefaultSingleStakingBalanceProducerTest {
val multiParams = MultiStakingBalanceProducer.Params(userWalletId = params.userWalletId)
every { multiNetworkStatusSupplier(multiParams) } returns multiFlow
val producerFlow = producer.produceWithFallback()
val producerFlow = createProducer().produceWithFallback()
// Act (first emit)
val actual1 = getEmittedValues(flow = producerFlow)
producerFlow.test {
// first collection throws -> retryWhen emits the fallback, then waits 2s
val fallbackStatus = StakingBalance.Error(stakingId = tonId.copy(address = "0x1"))
Truth.assertThat(awaitItem()).isEqualTo(fallbackStatus)
// Assert (first emit)
val fallbackStatus = StakingBalance.Error(stakingId = tonId.copy(address = "0x1"))
// recover the upstream and let the retry fire
innerFlow.value = true
advanceTimeBy(delayTimeMillis = 2001)
runCurrent()
Truth.assertThat(actual1).hasSize(1)
Truth.assertThat(actual1).containsExactly(fallbackStatus)
Truth.assertThat(awaitItem()).isEqualTo(balance)
// Act (second emit)
innerFlow.emit(value = true)
val actual2 = getEmittedValues(flow = producerFlow)
Truth.assertThat(actual2).hasSize(1)
Truth.assertThat(actual2).containsExactly(balance)
cancelAndIgnoreRemainingEvents()
}
verify(exactly = 1) { multiNetworkStatusSupplier(multiParams) }
}

View file

@ -13,7 +13,6 @@ import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Disabled
import org.junit.jupiter.api.Test
/**
@ -137,9 +136,6 @@ internal class StakingBalancesStoreUpdateMethodsTest {
Truth.assertThat(persistenceStore.data.firstOrNull()).isEqualTo(emptyMap<String, Set<YieldBalanceWrapperDTO>>())
}
// TODO: revisit — expected is built via wrapper.toDomain(ONLY_CACHE) but that yields source=ACTUAL,
// while storeError() applies ONLY_CACHE. Mock/toDomain vs production source handling needs review.
@Disabled("Source-mismatch between toDomain() expectation and storeError() output; needs domain review")
@Test
fun `store error if runtime store contains balance with this id`() = runTest {
val wrapper = MockYieldBalanceWrapperDTOFactory.createWithBalance(stakingId)
@ -152,8 +148,10 @@ internal class StakingBalancesStoreUpdateMethodsTest {
store.storeError(userWalletId = userWalletId, stakingIds = setOf(stakingId))
// storeError keeps the existing (CACHE) balance and downgrades its source to ONLY_CACHE.
// toDomain(ONLY_CACHE) can't express this: the converter maps any non-CACHE source to ACTUAL.
val runtimeExpected = mapOf(
userWalletId to setOf(wrapper.toDomain(source = StatusSource.ONLY_CACHE)),
userWalletId to setOf(wrapper.toDomain(source = StatusSource.CACHE).copySealed(source = StatusSource.ONLY_CACHE)),
)
Truth.assertThat(runtimeStore.getSyncOrNull()).isEqualTo(runtimeExpected)

View file

@ -5,6 +5,7 @@ plugins {
dependencies {
implementation(projects.core.utils)
implementation(projects.domain.core)
implementation(deps.arrow.core)
api(deps.androidx.datastore.core)

View file

@ -0,0 +1,58 @@
package com.tangem.test.core
import com.tangem.domain.core.flow.FlowProducer
import com.tangem.domain.core.flow.FlowProducerTools
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.shareIn
/**
* Test implementation of [FlowProducerTools] that mirrors the production `DefaultFlowProducerTools`
* behaviour (retryWhen + fallback + distinctUntilChanged + shareIn) on a caller-provided test
* scope/dispatcher, without analytics/logging.
*
* Pass a [TestScope.backgroundScope] and a `StandardTestDispatcher`/`UnconfinedTestDispatcher` built
* from the test scheduler so virtual time (e.g. the 2s retry delay) is controllable.
*
[REDACTED_AUTHOR]
*/
class TestFlowProducerTools(
private val scope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
) : FlowProducerTools {
override fun <T> shareInProducer(
flow: Flow<T>,
flowProducer: FlowProducer<T>,
withRetryWhen: Boolean,
): SharedFlow<T> {
var upstream = flow
if (withRetryWhen) {
upstream = upstream.retryWhen { _, _ ->
flowProducer.fallback.onSome { emit(it) }
delay(timeMillis = 2000)
true
}
}
return upstream
.flowOn(dispatcher)
.distinctUntilChanged()
.shareIn(
scope = scope,
replay = 1,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 0,
replayExpirationMillis = 0,
),
)
}
}