diff --git a/CHANGELOG.md b/CHANGELOG.md index 4243d58a1..21a70434a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ------ diff --git a/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/CircuitReceiveTurbine.kt b/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/CircuitReceiveTurbine.kt new file mode 100644 index 000000000..fc1e0ffc6 --- /dev/null +++ b/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/CircuitReceiveTurbine.kt @@ -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 : ReceiveTurbine { + /** + * 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 ReceiveTurbine.asCircuitReceiveTurbine( + policy: SnapshotMutationPolicy +): CircuitReceiveTurbine { + return CircuitReceiveTurbineImpl(this, policy) +} + +@OptIn(ExperimentalForInheritanceCircuitTestApi::class) +private class CircuitReceiveTurbineImpl( + private val delegate: ReceiveTurbine, + private val policy: SnapshotMutationPolicy, +) : CircuitReceiveTurbine, ReceiveTurbine 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 + } + } + } +} diff --git a/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/ExperimentalForInheritanceCircuitTestApi.kt b/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/ExperimentalForInheritanceCircuitTestApi.kt new file mode 100644 index 000000000..16625e90d --- /dev/null +++ b/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/ExperimentalForInheritanceCircuitTestApi.kt @@ -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 diff --git a/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/PresenterTestExtensions.kt b/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/PresenterTestExtensions.kt index 25285316a..3cc78d83c 100644 --- a/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/PresenterTestExtensions.kt +++ b/circuit-test/src/commonMain/kotlin/com/slack/circuit/test/PresenterTestExtensions.kt @@ -3,6 +3,8 @@ 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 @@ -10,17 +12,14 @@ 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 @@ -28,21 +27,20 @@ import kotlinx.coroutines.flow.distinctUntilChanged public suspend fun Presenter.test( timeout: Duration? = null, name: String? = null, - moleculeFlowTransformer: (Flow) -> Flow = Flow::distinctUntilChanged, - block: suspend ReceiveTurbine.() -> Unit, + policy: SnapshotMutationPolicy = structuralEqualityPolicy(), + block: suspend CircuitReceiveTurbine.() -> 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 @@ -51,10 +49,10 @@ public suspend fun presenterTestOf( presentFunction: @Composable () -> UiState, timeout: Duration? = null, name: String? = null, - moleculeFlowTransformer: (Flow) -> Flow = Flow::distinctUntilChanged, - block: suspend ReceiveTurbine.() -> Unit, + policy: SnapshotMutationPolicy = structuralEqualityPolicy(), + block: suspend CircuitReceiveTurbine.() -> Unit, ) { - moleculeFlow(RecompositionMode.Immediate, presentFunction) - .run(moleculeFlowTransformer) - .test(timeout, name, block) + moleculeFlow(RecompositionMode.Immediate, presentFunction).test(timeout, name) { + asCircuitReceiveTurbine(policy).block() + } } diff --git a/docs/testing.md b/docs/testing.md index 4b671e7f9..6270eaac9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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. diff --git a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/home/HomePresenterTest.kt b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/home/HomePresenterTest.kt index 406678641..d439302fe 100644 --- a/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/home/HomePresenterTest.kt +++ b/samples/star/src/androidUnitTest/kotlin/com/slack/circuit/star/home/HomePresenterTest.kt @@ -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 @@ -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) + } } } diff --git a/samples/tacos/build.gradle.kts b/samples/tacos/build.gradle.kts index 3e3838f0e..47ad2e17c 100644 --- a/samples/tacos/build.gradle.kts +++ b/samples/tacos/build.gradle.kts @@ -20,6 +20,8 @@ android { testOptions { unitTests.isIncludeAndroidResources = true } } +androidComponents { beforeVariants { it.enable = it.name.contains("debug", ignoreCase = true) } } + tasks .withType() .matching { it !is KaptGenerateStubsTask } diff --git a/samples/tacos/src/test/kotlin/com/slack/circuit/tacos/OrderTacosPresenterTest.kt b/samples/tacos/src/test/kotlin/com/slack/circuit/tacos/OrderTacosPresenterTest.kt index b767f4671..2951fb980 100644 --- a/samples/tacos/src/test/kotlin/com/slack/circuit/tacos/OrderTacosPresenterTest.kt +++ b/samples/tacos/src/test/kotlin/com/slack/circuit/tacos/OrderTacosPresenterTest.kt @@ -2,9 +2,6 @@ // 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 @@ -12,9 +9,9 @@ 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 @@ -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 @@ -126,7 +122,7 @@ class OrderTacosPresenterTest { val filling = Ingredient("apple") sink(OrderStep.UpdateOrder.SetFilling(filling)) - advanceUntilIdle() + awaitUnchanged() assertThat(details).isEqualTo(OrderDetails(filling = filling)) } } @@ -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 @@ -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 @@ -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()) } + } } }