From d5ca9362c24927359d68248222e7b9b1df0c5405 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 10 May 2020 19:48:24 +0200 Subject: [PATCH 1/3] Split stub from Controller interface --- control-core/build.gradle.kts | 7 ++- .../at/florianschuster/control/Controller.kt | 11 +--- .../at/florianschuster/control/event.kt | 4 +- .../florianschuster/control/implementation.kt | 36 +++++++------ .../kotlin/at/florianschuster/control/stub.kt | 36 ++++++++++--- .../at/florianschuster/control/EventTest.kt | 6 +-- .../at/florianschuster/control/StubTest.kt | 51 ++++++++++++++----- .../control/counterexample/CounterViewTest.kt | 16 +++--- .../githubexample/search/GithubViewTest.kt | 13 +++-- 9 files changed, 112 insertions(+), 68 deletions(-) diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index 8b5dc3ed..e0d161d6 100644 --- a/control-core/build.gradle.kts +++ b/control-core/build.gradle.kts @@ -42,9 +42,12 @@ pitest { "at.florianschuster.control.DefaultTagKt**", // inline function "at.florianschuster.control.ExtensionsKt**", // too many inline collects - // pitest cannot handle some invokeSuspend functions correctly + // inlined invokeSuspend "at.florianschuster.control.ControllerImplementation\$1\$2", - "at.florianschuster.control.ControllerImplementation\$1" + "at.florianschuster.control.ControllerImplementation\$1", + + // lateinit var isInitialized + "at.florianschuster.control.ControllerImplementation\$stubInitialized\$1" ) threads.set(4) jvmArgs.add("-ea") diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index c344365a..af86f6cf 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -54,16 +54,7 @@ interface Controller { */ val state: Flow - /** - * Set to true if you want to enable stubbing with [stub]. - * This has be set before binding [Controller.state] or [dispatch] an [Action]. - */ - var stubEnabled: Boolean - - /** - * Use this [ControllerStub] for view testing. - */ - val stub: ControllerStub + companion object } /** diff --git a/control-core/src/main/kotlin/at/florianschuster/control/event.kt b/control-core/src/main/kotlin/at/florianschuster/control/event.kt index 48e2e20e..d170ccce 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/event.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/event.kt @@ -54,8 +54,8 @@ sealed class ControllerEvent( * When the [ControllerImplementation.stub] is set to enabled. */ class Stub internal constructor( - tag: String, enabled: Boolean - ) : ControllerEvent(tag, "stub: enabled = $enabled") + tag: String + ) : ControllerEvent(tag, "is now stubbed") /** * When the [ControllerImplementation] stream is completed. diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 203853dd..349aab57 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -43,45 +43,41 @@ internal class ControllerImplementation( internal val controllerLog: ControllerLog ) : Controller { - internal val stateJob: Job // internal for testing + internal val stateJob: Job private val actionChannel = BroadcastChannel(BUFFERED) private val stateChannel = ConflatedBroadcastChannel() - private val controllerStub by lazy { ControllerStubImplementation(initialState) } + + // region stub + + internal lateinit var stub: ControllerStubImplementation + internal val stubInitialized: Boolean get() = this::stub.isInitialized + + // endregion + + // region controller override val state: Flow - get() = if (!stubEnabled) { + get() = if (stubInitialized) stub.stateChannel.asFlow() else { if (!stateJob.isActive) startStateJob() stateChannel.asFlow() - } else { - controllerStub.stateChannel.asFlow() } override val currentState: State - get() = if (!stubEnabled) { + get() = if (stubInitialized) stub.stateChannel.value else { if (!stateJob.isActive) startStateJob() stateChannel.value - } else { - controllerStub.stateChannel.value } override fun dispatch(action: Action) { - if (!stubEnabled) { + if (stubInitialized) { + stub.mutableActions.add(action) + } else { if (!stateJob.isActive) startStateJob() actionChannel.offer(action) - } else { - controllerStub.mutableActions.add(action) } } - override var stubEnabled: Boolean = false - set(value) { - controllerLog.log(ControllerEvent.Stub(tag, value)) - field = value - } - - override val stub: ControllerStub get() = controllerStub - init { val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) @@ -128,6 +124,8 @@ internal class ControllerImplementation( return if (stateJob.isActive) false // double checked locking else stateJob.start() } + + // endregion } internal fun mutatorScope( diff --git a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt index 3e7e93f2..f5fe88fd 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -1,24 +1,46 @@ package at.florianschuster.control import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.ConflatedBroadcastChannel /** - * Use this [ControllerStub] for view testing. + * Retrieves a [ControllerStub] for this [Controller] used for view testing. + * Once accessed, the [Controller] is stubbed and cannot be un-stubbed. + * + * Custom implementations of [Controller] cannot be stubbed. + */ +@ExperimentalCoroutinesApi +@FlowPreview +fun Controller.stub(): ControllerStub { + require(this is ControllerImplementation) { + "Cannot stub a custom implementation of a Controller." + } + if (!stubInitialized) { + controllerLog.log(ControllerEvent.Stub(tag)) + stub = ControllerStubImplementation(initialState) + } + return stub +} + +/** + * A stub of a [Controller] for view testing. */ interface ControllerStub { /** - * [Controller] [Action]'s as ordered [List]. + * The [Action]'s dispatched to the [Controller] as ordered [List]. * Use this to verify if view bindings trigger the correct [Action]'s. */ - val actions: List + val dispatchedActions: List /** - * Offers a mocked [State]. + * Emits a new [State] for [Controller.state] and [Controller.currentState]. * Use this to verify if [State] is correctly bound to a view. */ - fun setState(state: State) + fun emitState(state: State) + + companion object } /** @@ -32,9 +54,9 @@ internal class ControllerStubImplementation( internal val mutableActions = mutableListOf() internal val stateChannel = ConflatedBroadcastChannel(initialState) - override val actions: List get() = mutableActions + override val dispatchedActions: List get() = mutableActions - override fun setState(state: State) { + override fun emitState(state: State) { stateChannel.offer(state) } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index f2fb7d94..6a1186f4 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -37,7 +37,7 @@ internal class EventTest { assertTrue(lastEvents[2] is ControllerEvent.State) } - sut.stubEnabled = true + sut.stub() assertTrue(events.last() is ControllerEvent.Stub) sut.stateJob.cancel() @@ -45,7 +45,7 @@ internal class EventTest { } @Test - fun `ControllerImplementation logs mutator error correctly`() { // todo + fun `ControllerImplementation logs mutator error correctly`() { val events = mutableListOf() val sut = TestCoroutineScope().eventsController(events) @@ -57,7 +57,7 @@ internal class EventTest { } @Test - fun `ControllerImplementation logs reducer error correctly`() { // todo + fun `ControllerImplementation logs reducer error correctly`() { val events = mutableListOf() val sut = TestCoroutineScope().eventsController(events) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index d758ebde..eb7eafd2 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -5,15 +5,44 @@ import at.florianschuster.test.flow.emissions import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import org.junit.Rule import org.junit.Test +import java.lang.IllegalArgumentException import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue internal class StubTest { @get:Rule val testCoroutineScope = TestCoroutineScopeRule() + @Test + fun `custom controller implementation cannot be stubbed`() { + val sut = object : Controller { + override fun dispatch(action: Int) = Unit + override val currentState: Int get() = error("") + override val state: Flow get() = error("") + } + + assertFailsWith { sut.stub() } + } + + @Test + fun `stub is initialized only after accessing stub()`() { + val sut = + testCoroutineScope.createStringController() as ControllerImplementation, List, List> + assertFalse(sut.stubInitialized) + + assertFailsWith { sut.stub.dispatchedActions } + assertFalse(sut.stubInitialized) + + sut.stub().dispatchedActions + assertTrue(sut.stubInitialized) + } + @Test fun `stub actions are recorded correctly`() { val expectedActions = listOf( @@ -21,12 +50,11 @@ internal class StubTest { listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController() - sut.stubEnabled = true + val sut = testCoroutineScope.createStringController().apply { stub() } expectedActions.forEach(sut::dispatch) - assertEquals(expectedActions, sut.stub.actions) + assertEquals(expectedActions, sut.stub().dispatchedActions) } @Test @@ -36,11 +64,10 @@ internal class StubTest { listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController() - sut.stubEnabled = true + val sut = testCoroutineScope.createStringController().apply { stub() } val testFlow = sut.state.testIn(testCoroutineScope) - expectedStates.forEach(sut.stub::setState) + expectedStates.forEach(sut.stub()::emitState) testFlow expect emissions(listOf(initialState) + expectedStates) } @@ -52,23 +79,21 @@ internal class StubTest { listOf("two"), listOf("three") ) - val sut = testCoroutineScope.createStringController() - sut.stubEnabled = true + val sut = testCoroutineScope.createStringController().apply { stub() } - sut.stub.setState(listOf("something 1")) - sut.stub.setState(listOf("something 2")) + sut.stub().emitState(listOf("something 1")) + sut.stub().emitState(listOf("something 2")) val testFlow = sut.state.testIn(testCoroutineScope) - expectedStates.forEach(sut.stub::setState) + expectedStates.forEach(sut.stub()::emitState) testFlow expect emissions(listOf(listOf("something 2")) + expectedStates) } @Test fun `stub action does not trigger state machine`() { - val sut = testCoroutineScope.createStringController() - sut.stubEnabled = true + val sut = testCoroutineScope.createStringController().apply { stub() } sut.dispatch(listOf("test")) diff --git a/examples/example-counter/src/androidTest/kotlin/at/florianschuster/control/counterexample/CounterViewTest.kt b/examples/example-counter/src/androidTest/kotlin/at/florianschuster/control/counterexample/CounterViewTest.kt index ae82fdc0..3ede4272 100644 --- a/examples/example-counter/src/androidTest/kotlin/at/florianschuster/control/counterexample/CounterViewTest.kt +++ b/examples/example-counter/src/androidTest/kotlin/at/florianschuster/control/counterexample/CounterViewTest.kt @@ -8,7 +8,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.test.TestCoroutineScope +import at.florianschuster.control.stub import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -21,8 +21,10 @@ internal class CounterViewTest { @Before fun setup() { - controller = CounterController(TestCoroutineScope()).apply { stubEnabled = true } - CounterView.CounterControllerProvider = { controller } + CounterView.CounterControllerProvider = { scope -> + controller = CounterController(scope).apply { stub() } + controller + } launchFragmentInContainer() } @@ -32,7 +34,7 @@ internal class CounterViewTest { onView(withId(R.id.increaseButton)).perform(click()) // then - assertEquals(CounterAction.Increment, controller.stub.actions.last()) + assertEquals(CounterAction.Increment, controller.stub().dispatchedActions.last()) } @Test @@ -41,7 +43,7 @@ internal class CounterViewTest { onView(withId(R.id.decreaseButton)).perform(click()) // then - assertEquals(CounterAction.Decrement, controller.stub.actions.last()) + assertEquals(CounterAction.Decrement, controller.stub().dispatchedActions.last()) } @Test @@ -50,7 +52,7 @@ internal class CounterViewTest { val testValue = 1 // when - controller.stub.setState(CounterState(value = testValue)) + controller.stub().emitState(CounterState(value = testValue)) // then onView(withId(R.id.valueTextView)).check(matches(withText("$testValue"))) @@ -59,7 +61,7 @@ internal class CounterViewTest { @Test fun whenStateOffersLoadingProgressBarIsVisible() { // when - controller.stub.setState(CounterState(loading = true)) + controller.stub().emitState(CounterState(loading = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) diff --git a/examples/example-github/src/androidTest/kotlin/at/florianschuster/control/githubexample/search/GithubViewTest.kt b/examples/example-github/src/androidTest/kotlin/at/florianschuster/control/githubexample/search/GithubViewTest.kt index 7da8c018..ace1f86d 100644 --- a/examples/example-github/src/androidTest/kotlin/at/florianschuster/control/githubexample/search/GithubViewTest.kt +++ b/examples/example-github/src/androidTest/kotlin/at/florianschuster/control/githubexample/search/GithubViewTest.kt @@ -14,6 +14,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.runners.AndroidJUnit4 import at.florianschuster.control.githubexample.R +import at.florianschuster.control.stub import org.hamcrest.Matcher import org.hamcrest.Matchers.not import org.junit.Before @@ -28,10 +29,12 @@ internal class GithubViewTest { @Before fun setup() { - viewModel = GithubViewModel().apply { controller.stubEnabled = true } GithubView.GithubViewModelFactory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = viewModel as T + override fun create(modelClass: Class): T { + viewModel = GithubViewModel().apply { controller.stub() } + return viewModel as T + } } launchFragmentInContainer(themeResId = R.style.Theme_MaterialComponents) } @@ -48,20 +51,20 @@ internal class GithubViewTest { // then assertEquals( GithubViewModel.Action.UpdateQuery(testQuery), - viewModel.controller.stub.actions.last() + viewModel.controller.stub().dispatchedActions.last() ) } @Test fun whenStateOffersLoadingNextPageThenProgressBarIsShown() { // when - viewModel.controller.stub.setState(GithubViewModel.State(loadingNextPage = true)) + viewModel.controller.stub().emitState(GithubViewModel.State(loadingNextPage = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) // when - viewModel.controller.stub.setState(GithubViewModel.State(loadingNextPage = false)) + viewModel.controller.stub().emitState(GithubViewModel.State(loadingNextPage = false)) // then onView(withId(R.id.loadingProgressBar)).check(matches(not(isDisplayed()))) From af64da4aedcd075b513027ce2e80306ad8a1f1f4 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 10 May 2020 22:58:27 +0200 Subject: [PATCH 2/3] Renamed mutableDispatchedActions --- .../main/kotlin/at/florianschuster/control/implementation.kt | 2 +- .../src/main/kotlin/at/florianschuster/control/stub.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index f0fca263..46f653d4 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -71,7 +71,7 @@ internal class ControllerImplementation( override fun dispatch(action: Action) { if (stubInitialized) { - stub.mutableActions.add(action) + stub.mutableDispatchedActions.add(action) } else { if (!stateJob.isActive) startStateJob() actionChannel.offer(action) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt index 747a181b..90ba9eed 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -51,10 +51,10 @@ internal class ControllerStubImplementation( initialState: State ) : ControllerStub { - internal val mutableActions = mutableListOf() + internal val mutableDispatchedActions = mutableListOf() internal val stateFlow = MutableStateFlow(initialState) - override val dispatchedActions: List get() = mutableActions + override val dispatchedActions: List get() = mutableDispatchedActions override fun emitState(state: State) { stateFlow.value = state From 91c128441ae59b3e9912629799e8a42d42d106d9 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Mon, 11 May 2020 09:19:18 +0200 Subject: [PATCH 3/3] Removec companion objects from stub and Controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit where did those come from 🤔 --- .../src/main/kotlin/at/florianschuster/control/Controller.kt | 2 -- control-core/src/main/kotlin/at/florianschuster/control/stub.kt | 2 -- 2 files changed, 4 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index af86f6cf..77f0d593 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -53,8 +53,6 @@ interface Controller { * The [State] [Flow]. Use this to collect [State] changes. */ val state: Flow - - companion object } /** diff --git a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt index 90ba9eed..41eabb20 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -39,8 +39,6 @@ interface ControllerStub { * Use this to verify if [State] is correctly bound to a view. */ fun emitState(state: State) - - companion object } /**