Skip to content

Commit

Permalink
Implement CircuitReceiveTurbine (#1597)
Browse files Browse the repository at this point in the history
This is an alternative to the distinctUntilChanged approach we added,
where this exposes a new `CircuitReceiveTurbine` that overrides
`awaitItem()` to emulate this behavior instead. This also offers helpers
like `awaitUnchanged()` and policy controls.
  • Loading branch information
ZacSweers authored Sep 16, 2024
1 parent ce398b3 commit 6241e13
Show file tree
Hide file tree
Showing 8 changed files with 137 additions and 68 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ Changelog
--------------

- **New**: Add code gen support for [kotlin-inject](https://github.com/evant/kotlin-inject) + [kotlin-inject-anvil](https://github.com/amzn/kotlin-inject-anvil). See the [code gen docs](https://slackhq.github.io/circuit/code-gen/) for usage instructions. We've also added a sample project.
- **New**: `presenterTestOf()` and `Presenter.test()` functions now return a new `CircuitReceiveTurbine` interface. This interface slightly but importantly modifies the behavior of `awaitItem()` by making it only emit _changed_ items rather than every item. If you do want to assert the equivalent state is emitted across recompositions, you can use `awaitUnchanged()`.
- **Behavior change**: Due to the above-documented change to `awaitItem()`, you may need to update tests that previously assumed duplicate emissions.
- Update to Kotlin `2.0.20`.
- **Change**: Switch to stdlib's implementation of `Uuid`. This release now requires Kotlin `2.0.20` or later.
- **Behavior change**: `presenterTestOf` and `Presenter.test` have a new optional `moleculeFlowTransformer` parameter that allows for advanced filtering of the `Flow` returned out of the underlying `moleculeFlow`. By default, this now runs a `distinctUntilChanged` operator.
- Build against KSP `2.0.20-1.0.24`.
- Build against KSP `2.0.20-1.0.25`.

0.23.1
------
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (C) 2024 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.test

import androidx.compose.runtime.SnapshotMutationPolicy
import app.cash.turbine.ReceiveTurbine
import com.slack.circuit.runtime.CircuitUiState

/**
* A Circuit-specific extension to [ReceiveTurbine] with extra helper functions for Circuit testing.
*
* This implementation of [ReceiveTurbine] slightly alters the behavior of [awaitItem] by only
* emitting items that are _different_ from the previously emitted item.
*/
@OptIn(ExperimentalSubclassOptIn::class)
@SubclassOptInRequired(ExperimentalForInheritanceCircuitTestApi::class)
public interface CircuitReceiveTurbine<UiState : CircuitUiState> : ReceiveTurbine<UiState> {
/**
* Awaits the next item and asserts that it is _unchanged_ from the previous emission. Essentially
* this is a sort of escape-hatch from the altered "distinct until changed" behavior of
* [awaitItem] in this implementation and can be used to more or less assert no change in state
* after the next recomposition.
*/
public suspend fun awaitUnchanged()
}

internal fun <UiState : CircuitUiState> ReceiveTurbine<UiState>.asCircuitReceiveTurbine(
policy: SnapshotMutationPolicy<UiState>
): CircuitReceiveTurbine<UiState> {
return CircuitReceiveTurbineImpl(this, policy)
}

@OptIn(ExperimentalForInheritanceCircuitTestApi::class)
private class CircuitReceiveTurbineImpl<UiState : CircuitUiState>(
private val delegate: ReceiveTurbine<UiState>,
private val policy: SnapshotMutationPolicy<UiState>,
) : CircuitReceiveTurbine<UiState>, ReceiveTurbine<UiState> by delegate {

private var lastItem: UiState? = null

override suspend fun awaitUnchanged() {
val next = delegate.awaitItem()
if (next != lastItem) {
throw AssertionError(
"Expected unchanged item but received ${next}. Previous was ${lastItem}."
)
}
}

override suspend fun awaitItem(): UiState {
while (true) {
val last = lastItem
val next = delegate.awaitItem()
lastItem = next
if (last == null) {
return next
} else if (!policy.equivalent(last, next)) {
return next
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (C) 2024 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.test

@Target(AnnotationTarget.CLASS)
@RequiresOptIn(
level = RequiresOptIn.Level.WARNING,
message =
"Inheriting from this circuit-test API is unstable. " +
"Either new methods may be added in the future, which would break the inheritance, " +
"or correctly inheriting from it requires fulfilling contracts that may change in the future.",
)
public annotation class ExperimentalForInheritanceCircuitTestApi
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,44 @@
package com.slack.circuit.test

import androidx.compose.runtime.Composable
import androidx.compose.runtime.SnapshotMutationPolicy
import androidx.compose.runtime.structuralEqualityPolicy
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.slack.circuit.runtime.CircuitUiState
import com.slack.circuit.runtime.presenter.Presenter
import kotlin.time.Duration
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged

/**
* Presents this [Presenter] and invokes a `suspend` [ReceiveTurbine] [block] that can be used to
* assert state emissions from this presenter.
*
* @param timeout an optional timeout for the test. Defaults to 1 second (in Turbine) if undefined.
* @param moleculeFlowTransformer an optional transformer for the underlying [moleculeFlow]. Must
* still return a [Flow] of type [UiState], but can be used for custom filtering. By default, it
* runs [distinctUntilChanged].
* @param policy a policy to controls how state changes are compared in
* [CircuitReceiveTurbine.awaitItem].
* @param block the block to invoke.
* @see moleculeFlow
* @see test
*/
public suspend fun <UiState : CircuitUiState> Presenter<UiState>.test(
timeout: Duration? = null,
name: String? = null,
moleculeFlowTransformer: (Flow<UiState>) -> Flow<UiState> = Flow<UiState>::distinctUntilChanged,
block: suspend ReceiveTurbine<UiState>.() -> Unit,
policy: SnapshotMutationPolicy<UiState> = structuralEqualityPolicy(),
block: suspend CircuitReceiveTurbine<UiState>.() -> Unit,
) {
presenterTestOf({ present() }, timeout, name, moleculeFlowTransformer, block)
presenterTestOf({ present() }, timeout, name, policy, block)
}

/**
* Presents this [presentFunction] and invokes a `suspend` [ReceiveTurbine] [block] that can be used
* to assert state emissions from it.
* Presents this [presentFunction] and invokes a `suspend` [CircuitReceiveTurbine] [block] that can
* be used to assert state emissions from it.
*
* @param presentFunction the [Composable] present function being tested.
* @param timeout an optional timeout for the test. Defaults to 1 second (in Turbine) if undefined.
* @param moleculeFlowTransformer an optional transformer for the underlying [moleculeFlow]. Must
* still return a [Flow] of type [UiState], but can be used for custom filtering. By default, it
* runs [distinctUntilChanged].
* @param policy a policy to controls how state changes are compared in
* [CircuitReceiveTurbine.awaitItem].
* @param block the block to invoke.
* @see moleculeFlow
* @see test
Expand All @@ -51,10 +49,10 @@ public suspend fun <UiState : CircuitUiState> presenterTestOf(
presentFunction: @Composable () -> UiState,
timeout: Duration? = null,
name: String? = null,
moleculeFlowTransformer: (Flow<UiState>) -> Flow<UiState> = Flow<UiState>::distinctUntilChanged,
block: suspend ReceiveTurbine<UiState>.() -> Unit,
policy: SnapshotMutationPolicy<UiState> = structuralEqualityPolicy(),
block: suspend CircuitReceiveTurbine<UiState>.() -> Unit,
) {
moleculeFlow(RecompositionMode.Immediate, presentFunction)
.run(moleculeFlowTransformer)
.test(timeout, name, block)
moleculeFlow(RecompositionMode.Immediate, presentFunction).test(timeout, name) {
asCircuitReceiveTurbine(policy).block()
}
}
5 changes: 3 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ Testing

Circuit is designed to make testing as easy as possible. Its core components are not mockable nor do they need to be mocked. Fakes are provided where needed, everything else can be used directly.

Circuit will have a test artifact containing APIs to aid testing both presenters and composable UIs:
Circuit offers a test artifact containing APIs to aid testing both presenters and composable UIs:

- `Presenter.test()` - an extension function that bridges the Compose and coroutines world. Use of this function is recommended for testing presenter state emissions and incoming UI events. Under the hood it leverages [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine).
- `presenterTestOf()` - a top-level function that wraps a composable function to bridge the Compose and coroutines world. Use of this function is recommended for testing presenter state emissions and incoming UI events. Under the hood it leverages [Molecule](https://github.com/cashapp/molecule) and [Turbine](https://github.com/cashapp/turbine). It returns a `CircuitReceiveTurbine`, a custom implementation of `ReceiveTurbine` that modifies the behavior of `awaitItem()` to only emit _changed_ items (i.e. "distinct until changed").
- `Presenter.test()` - an extension function on `Presenter` that bridges to `presenterTestOf()`.
- `FakeNavigator` - a test fake implementing the `Navigator` interface. Use of this object is recommended when testing screen navigation (ie. goTo, pop/back). This acts as a real navigator and exposes recorded information for testing purposes.
- `TestEventSink` - a generic test fake for recording and asserting event emissions through an event sink function.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.star.home

import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.slack.circuit.star.home.HomeScreen.Event.ClickNavItem
import com.slack.circuit.test.FakeNavigator
import com.slack.circuit.test.presenterTestOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
Expand All @@ -17,19 +15,18 @@ import org.robolectric.RobolectricTestRunner
class HomePresenterTest {
@Test
fun changeIndices() = runTest {
moleculeFlow(RecompositionMode.Immediate) { HomePresenter(FakeNavigator(HomeScreen)) }
.test {
// Initial index is 0.
val firstState = awaitItem()
assertThat(firstState.selectedIndex).isEqualTo(0)
presenterTestOf({ HomePresenter(FakeNavigator(HomeScreen)) }) {
// Initial index is 0.
val firstState = awaitItem()
assertThat(firstState.selectedIndex).isEqualTo(0)

// Clicking the same index does nothing.
firstState.eventSink(ClickNavItem(0))
expectNoEvents()
// Clicking the same index does nothing.
firstState.eventSink(ClickNavItem(0))
expectNoEvents()

// Changing the index emits a new state.
firstState.eventSink(ClickNavItem(1))
assertThat(awaitItem().selectedIndex).isEqualTo(1)
}
// Changing the index emits a new state.
firstState.eventSink(ClickNavItem(1))
assertThat(awaitItem().selectedIndex).isEqualTo(1)
}
}
}
2 changes: 2 additions & 0 deletions samples/tacos/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ android {
testOptions { unitTests.isIncludeAndroidResources = true }
}

androidComponents { beforeVariants { it.enable = it.name.contains("debug", ignoreCase = true) } }

tasks
.withType<KotlinCompile>()
.matching { it !is KaptGenerateStubsTask }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuit.tacos

import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import com.slack.circuit.tacos.model.Ingredient
import com.slack.circuit.tacos.model.toCurrencyString
import com.slack.circuit.tacos.step.FillingsOrderStep
import com.slack.circuit.tacos.step.OrderStep
import com.slack.circuit.tacos.step.SummaryOrderStep
import com.slack.circuit.tacos.step.ToppingsOrderStep
import com.slack.circuit.test.presenterTestOf
import com.slack.circuit.test.test
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -72,11 +69,10 @@ class OrderTacosPresenterTest {
initialStep = ToppingsOrderStep,
)

moleculeFlow(RecompositionMode.Immediate) { presenter.present() }
.test {
awaitItem().run { eventSink(OrderTacosScreen.Event.Previous) }
assertThat(awaitItem().stepState).isEqualTo(FillingsOrderStep.State.Loading)
}
presenterTestOf({ presenter.present() }) {
awaitItem().run { eventSink(OrderTacosScreen.Event.Previous) }
assertThat(awaitItem().stepState).isEqualTo(FillingsOrderStep.State.Loading)
}
}

@Test
Expand Down Expand Up @@ -126,7 +122,7 @@ class OrderTacosPresenterTest {
val filling = Ingredient("apple")
sink(OrderStep.UpdateOrder.SetFilling(filling))

advanceUntilIdle()
awaitUnchanged()
assertThat(details).isEqualTo(OrderDetails(filling = filling))
}
}
Expand All @@ -149,17 +145,16 @@ class OrderTacosPresenterTest {
initialStep = ToppingsOrderStep,
)

moleculeFlow(RecompositionMode.Immediate) { presenter.present() }
.test {
awaitItem()
assertThat(details).isEqualTo(OrderDetails())
presenterTestOf({ presenter.present() }) {
awaitItem()
assertThat(details).isEqualTo(OrderDetails())

val toppings = persistentSetOf(Ingredient("apple"))
sink(OrderStep.UpdateOrder.SetToppings(toppings))
val toppings = persistentSetOf(Ingredient("apple"))
sink(OrderStep.UpdateOrder.SetToppings(toppings))

assertThat(awaitItem().stepState).isEqualTo(ToppingsOrderStep.State.Loading)
assertThat(details).isEqualTo(OrderDetails(toppings = toppings))
}
awaitUnchanged()
assertThat(details).isEqualTo(OrderDetails(toppings = toppings))
}
}

@Test
Expand All @@ -182,19 +177,18 @@ class OrderTacosPresenterTest {
initialStep = SummaryOrderStep,
)

moleculeFlow(RecompositionMode.Immediate) { presenter.present() }
.test {
assertThat(awaitItem().stepState).isInstanceOf(SummaryOrderStep.SummaryState::class.java)

sink(OrderStep.Restart)
presenterTestOf({ presenter.present() }) {
assertThat(awaitItem().stepState).isInstanceOf(SummaryOrderStep.SummaryState::class.java)

awaitItem().run {
assertThat(stepState).isEqualTo(FillingsOrderStep.State.Loading)
assertThat(isNextEnabled).isFalse()
}
sink(OrderStep.Restart)

assertThat(details).isEqualTo(OrderDetails())
awaitItem().run {
assertThat(stepState).isEqualTo(FillingsOrderStep.State.Loading)
assertThat(isNextEnabled).isFalse()
}

assertThat(details).isEqualTo(OrderDetails())
}
}

@Test
Expand All @@ -211,7 +205,8 @@ class OrderTacosPresenterTest {
initialOrderDetails = initialData,
)

moleculeFlow(RecompositionMode.Immediate) { presenter.present() }
.test { awaitItem().run { assertThat(orderCost).isEqualTo(expectedCost.toCurrencyString()) } }
presenterTestOf({ presenter.present() }) {
awaitItem().run { assertThat(orderCost).isEqualTo(expectedCost.toCurrencyString()) }
}
}
}

0 comments on commit 6241e13

Please sign in to comment.