Skip to content

Commit

Permalink
Merge pull request #17 from floschu/feature/split-stub
Browse files Browse the repository at this point in the history
Split stub from Controller interface
  • Loading branch information
floschu authored May 11, 2020
2 parents e98c862 + 91c1284 commit c06b367
Show file tree
Hide file tree
Showing 9 changed files with 110 additions and 70 deletions.
7 changes: 5 additions & 2 deletions control-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,6 @@ interface Controller<Action, Mutation, State> {
* The [State] [Flow]. Use this to collect [State] changes.
*/
val state: Flow<State>

/**
* 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<Action, State>
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,45 +43,41 @@ internal class ControllerImplementation<Action, Mutation, State>(
internal val controllerLog: ControllerLog
) : Controller<Action, Mutation, State> {

internal val stateJob: Job // internal for testing
internal val stateJob: Job

private val actionChannel = BroadcastChannel<Action>(BUFFERED)
private val stateFlow = MutableStateFlow(initialState)
private val controllerStub by lazy { ControllerStubImplementation<Action, State>(initialState) }

// region stub

internal lateinit var stub: ControllerStubImplementation<Action, State>
internal val stubInitialized: Boolean get() = this::stub.isInitialized

// endregion

// region controller

override val state: Flow<State>
get() = if (!stubEnabled) {
get() = if (stubInitialized) stub.stateFlow else {
if (!stateJob.isActive) startStateJob()
stateFlow
} else {
controllerStub.stateFlow
}

override val currentState: State
get() = if (!stubEnabled) {
get() = if (stubInitialized) stub.stateFlow.value else {
if (!stateJob.isActive) startStateJob()
stateFlow.value
} else {
controllerStub.stateFlow.value
}

override fun dispatch(action: Action) {
if (!stubEnabled) {
if (stubInitialized) {
stub.mutableDispatchedActions.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<Action, State> get() = controllerStub

init {
val actionFlow: Flow<Action> = actionsTransformer(actionChannel.asFlow())

Expand Down Expand Up @@ -128,6 +124,8 @@ internal class ControllerImplementation<Action, Mutation, State>(
return if (stateJob.isActive) false // double checked locking
else stateJob.start()
}

// endregion
}

internal fun <Action, State> mutatorScope(
Expand Down
36 changes: 28 additions & 8 deletions control-core/src/main/kotlin/at/florianschuster/control/stub.kt
Original file line number Diff line number Diff line change
@@ -1,24 +1,44 @@
package at.florianschuster.control

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow

/**
* 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 <Action, State> Controller<Action, *, State>.stub(): ControllerStub<Action, State> {
require(this is ControllerImplementation<Action, *, State>) {
"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<Action, State> {

/**
* [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<Action>
val dispatchedActions: List<Action>

/**
* 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)
}

/**
Expand All @@ -29,12 +49,12 @@ internal class ControllerStubImplementation<Action, State>(
initialState: State
) : ControllerStub<Action, State> {

internal val mutableActions = mutableListOf<Action>()
internal val mutableDispatchedActions = mutableListOf<Action>()
internal val stateFlow = MutableStateFlow(initialState)

override val actions: List<Action> get() = mutableActions
override val dispatchedActions: List<Action> get() = mutableDispatchedActions

override fun setState(state: State) {
override fun emitState(state: State) {
stateFlow.value = state
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ internal class EventTest {
assertTrue(lastEvents[2] is ControllerEvent.State)
}

sut.stubEnabled = true
sut.stub()
assertTrue(events.last() is ControllerEvent.Stub)

sut.stateJob.cancel()
assertTrue(events.last() is ControllerEvent.Completed)
}

@Test
fun `ControllerImplementation logs mutator error correctly`() { // todo
fun `ControllerImplementation logs mutator error correctly`() {
val events = mutableListOf<ControllerEvent>()
val sut = TestCoroutineScope().eventsController(events)

Expand All @@ -57,7 +57,7 @@ internal class EventTest {
}

@Test
fun `ControllerImplementation logs reducer error correctly`() { // todo
fun `ControllerImplementation logs reducer error correctly`() {
val events = mutableListOf<ControllerEvent>()
val sut = TestCoroutineScope().eventsController(events)

Expand Down
51 changes: 38 additions & 13 deletions control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,56 @@ 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<Int, Int, Int> {
override fun dispatch(action: Int) = Unit
override val currentState: Int get() = error("")
override val state: Flow<Int> get() = error("")
}

assertFailsWith<IllegalArgumentException> { sut.stub() }
}

@Test
fun `stub is initialized only after accessing stub()`() {
val sut =
testCoroutineScope.createStringController() as ControllerImplementation<List<String>, List<String>, List<String>>
assertFalse(sut.stubInitialized)

assertFailsWith<UninitializedPropertyAccessException> { sut.stub.dispatchedActions }
assertFalse(sut.stubInitialized)

sut.stub().dispatchedActions
assertTrue(sut.stubInitialized)
}

@Test
fun `stub actions are recorded correctly`() {
val expectedActions = listOf(
listOf("one"),
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
Expand All @@ -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)
}
Expand All @@ -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"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<CounterView>()
}

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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")))
Expand All @@ -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()))
Expand Down
Loading

0 comments on commit c06b367

Please sign in to comment.