Any recommendations on how to unit test an `actor`...
# coroutines
d
Any recommendations on how to unit test an
actor
? Basically I have a redux-ish pipeline where each input may result in 0+ state changes. Not sure how to tell when my pipeline is "inactive".
In practice, that
map
is a
flatMap
implemented by different subclasses (reducer) and anything can happen inside it.
j
An actor is supposed to send message to another actors
You can setup the response channel in test, and suspend with a timeout until the answer come
d
As opposed to consuming it itself? (
consumeEach
)
j
Copy code
data class State(val count: Int)

sealed class Action {
  data class Add(val increment: Int = 1) : Action()
}

fun CoroutineScope.stateStore(states: SendChannel<State>): SendChannel<Action> =
    actor {
      var state = State(0)
      states.send(state)
      for (action in channel) {
        state = when (action) {
          is Action.Add -> state.copy(count = state.count + action.increment)
        }
        states.send(state)
      }
    }
Copy code
fun test() = runBlocking {
  val result = Channel<State>()
  
  val actor = stateStore(result)
  
  assertEquals(0, result.receive().count)
  actor.send(Action.Add(42))
  assertEquals(42, result.receive().count)
  actor.close()
}
you may of course use a timeout, to fail faster if the actor sends nothing
Do you get the idea?
d
Pass in a
ReceiveChannel
that handles the state changes, in real life send the state changes to the StateStore, for tests, receive and examine the new states in the test?
j
Yes, or provide a way to open a subscription. And then open the subscription in test
d
Using a Producer?
I don't think I want to interfere with the pipeline sending states to the StateStore, because one input could make multiple changes and the second could rely on the first
j
Yes, but don't you want to assert the output?
No matter how many output are occuring
d
which would actually probably be fine, since I use a
scan
operator (instead of directly reading from the StateStore), but it's still intrusive
j
Here's something closer to your example:
Copy code
data class AddAction(val numberToAdd: Long)
data class State(val number: Long)
	
class ClassToTest(val reducer: (AddAction) -> State) {
  
	private val broadcast = ConflatedBroadcastChannel<State>(State(0))
	
    val pipeline = GlobalScope.actor<AddAction> {
        consumeEach { action ->
		  broadcast.send(reducer(broadcast.value, action))
		}
    }
	
	fun openStateSubscription(): ReceiveChannel<State> = broadcast.openSubscription()
}

fun main(args: Array<String>) = runBlocking {
    val testedClass = ClassToTest(reducer) 

	val sub = testedClass.openStateSubscription()
	assertThat(sub.receive().number).isEqualTo(0)
	
	
    testedClass.pipeline.send(ClassToTest.AddAction(Long.MAX_VALUE))
	assertThat(sub.receive().number).isNotEqualTo(0)
}
d
shoot, i have a meeting, i'll be gone for an hour
j
Good luck 😉
d
I didn't get to look over all of your answer, I was thinking
asPublisher()
but it might be a similar idea
Alright, just looked through. I think that I made my example code too simple and didn't properly represent how my code is structured.
my StateStore is really a wrapper around
LiveData
(an Android-specific analogue to a Channel)
and lives in a different class, and is an
object
Ok, so followup question. I mentioned that an Action can have 0+ resulting State changes
How would I test that some other mock was called as a result of the Action, but no state change happened?
some kind of
pipeline.isEmpty
or
pipeline.isActive
where it can tell me if there's anything going on
I think that might not be possible, though
Alright, based on your feedback @Jonathan, I got what I think is a pretty good solution
Copy code
class ClassToTest {
    data class AddAction(val numberToAdd: Long)
    data class State(val number: Long)

    object Store {
        var state: State = State(0)
            set(value) {
                field = value
                broadcaster.offer(field)
            }

        private val broadcaster = BroadcastChannel<State>(1)
        fun openStateSubscription() = broadcaster.openSubscription()
    }

    val pipeline = GlobalScope.actor<AddAction>(context = Dispatchers.Unconfined) {
        channel
                .flatMap(context = coroutineContext) { action ->
                    produce {
                        repeat(10) {
                            delay(100)
                            send(action.numberToAdd)
                        }
                    }
                }
                .map {
                    // Please ignore the horrible threading issues that could arise from this
                    numberToAdd ->
                    Store.state.copy(number = Store.state.number + numberToAdd)
                }
                .consumeEach {
                    Store.state = it
                }
    }
}

fun main(args: Array<String>) {
    val testedClass = ClassToTest()

    testedClass.pipeline.sendBlocking(ClassToTest.AddAction(1))

    runBlocking {
        val subscription = ClassToTest.Store.openStateSubscription()
        assertThat(subscription.receive().number).isNotEqualTo(0)
        subscription.receive()
        assertThat(ClassToTest.Store.state.number).isEqualTo(2)
        delay(200)
        assertThat(ClassToTest.Store.state.number).isEqualTo(3)
        null
    }
}
I had this thought earlier, to spin-wait until my
LiveData
in my
Store
got a value, but there's no built-in way to block until you can
receive
like with Coroutines
so either I'll expose the state in both a LiveData and a Channel, or spin-wait on LiveData
j
Great. Be careful though, your state is public. Actor state should be private and confined to the actor's coroutine.
(Mutable + Shared = Danger)
Also, one might question the need of actor here. Without actor, you would expose a suspending method which returns only once its job is done. And then you can verify calls on the mock.
d
Thank you! You might be right about the suspending method. The channel lets me use
scan
, except that I just realized that's actually potentially a huge problem
since i could have multiple channels at the same time using
scan
, each thinking that they're using the latest version of State but never actually "refreshing" their state from the store
Android apps generally have one view at a time showing, so it's sort of unlikely, but still a big oversight
On suspend fun vs actor, the actor has a buffer, so I can queue up inputs (user actions). With a suspend fun, I'd have to implement my own buffer, although it might be the way I go anyway.