Skip to content

Testing

The monaka-test module provides a coroutine-aware DSL for testing state machines in isolation. It wraps Turbine and kotlinx-coroutines-test, so tests run under virtual time with no real delays.


Setup

// build.gradle.kts — add to your test source set
commonTest.dependencies {
    implementation("dev.gmvalentino.monaka:monaka-test:<version>")
    implementation(kotlin("test"))
}

The test DSL requires a StateMachine<S, A, E> value built with stateMachine { }. If your production code uses store { } inline (e.g. inside a ViewModel), mirror the handlers in a separate stateMachine { } value for testing:

// Production — store tied to a scope
class CounterViewModel : ViewModel() {
    val store = store<CounterState, CounterAction, CounterEffect>(viewModelScope) {
        initialState(CounterState(0))
        state<CounterState> {
            on<CounterAction.Increment> { transition(state.copy(count = state.count + 1)) }
        }
    }
}

// Test — same handlers, no scope
private val counterMachine = stateMachine<CounterState, CounterAction, CounterEffect> {
    initialState(CounterState(0))
    state<CounterState> {
        on<CounterAction.Increment> { transition(state.copy(count = state.count + 1)) }
    }
}

If your machine is a StateMachineStore class that holds a stateMachine { } value as a property, pass that property directly to testStore.


Entry point — testStore

@Test
fun counterIncrements() = testStore(machine = counterMachine) {
    testCase("increment once") {
        trigger(CounterAction.Increment) {
            expectState<CounterState> { state.count == 1 }
        }
    }
}

testStore wraps runTest, so it inherits virtual time and runs synchronously in CI.

Each testCase block receives a fresh store built from the same StateMachine configuration, so test cases within one testStore call are completely isolated from each other.


DSL reference

given(state)

Override the machine's initial state. Must be called before the first trigger.

testCase("start from Typing") {
    given(LoginState.Typing(username = "alice", password = "secret"))
    trigger(LoginAction.Submit) {
        expectState<LoginState.Submitting>()
    }
}

trigger(action) { … }

Dispatch an action and assert on the resulting emissions inside the block.

trigger(LoginAction.Submit) {
    expectState<LoginState.Submitting>()
    expectState<LoginState.Authenticated> { state.username == "alice" }
    expectEffect(LoginEffect.NavigateToHome)
}

trigger(LifecycleEvent) { … }

Forward a lifecycle event and assert on emissions.

trigger(LifecycleEvent.OnPause) {
    expectState<TimerState.Paused>()
}

trigger(StateHook) { … }

Fire a state lifecycle hook manually — useful for testing onEnter / onExit / onUpdate without driving a full transition:

trigger(StateHook.OnEnter) {
    expectState<LoginState.Submitting>()
}

trigger(StateHook.OnUpdate(previousState = SearchState(query = ""))) {
    expectAction<SearchAction.ResultsReceived>()
}

advanceTime(duration) { … }

Advance virtual time and assert on emissions produced by timed work:

testCase("debounce fires after 300ms") {
    given(SearchState(query = "", isLoading = false))

    trigger(SearchAction.QueryChanged("ko")) {}  // handler launches task with delay(300)

    advanceTime(300.milliseconds) {
        expectAction<SearchAction.ResultsReceived>()
    }
}

Assertion methods

All assertions are called inside a trigger { … } block on the implicit AssertScope receiver.

States

Method Behaviour
expectState(state) Assert next state equals state.
expectState<T> { predicate } Assert next state is an instance of T and matches the optional predicate. Access the typed state via state.
skipState() Consume the next state without asserting.
expectState(CounterState(count = 1))
expectState<LoginState.Authenticated> { state.username == "alice" }

Effects

Method Behaviour
expectEffect(effect) Assert next effect equals effect.
expectEffect<T> { predicate } Assert next effect is an instance of T and matches the optional predicate. Access the typed effect via effect.
expectNoEffects() Assert no effect has been emitted yet.
skipEffect() Consume the next effect without asserting.

Handler-initiated actions

These assert on actions that a handler dispatched internally (via ActionScope.dispatch or from inside task { }). Actions dispatched by the test itself are filtered out.

Method Behaviour
expectAction(action) Assert next handler action equals action.
expectAction<T> { predicate } Assert next handler action is an instance of T matching the optional predicate.
expectNoAction() Assert no handler-initiated action has been emitted yet.
skipAction() Consume the next handler action without asserting.

Automatic idle check

At the end of every test case, testStore automatically calls expectIdle() — asserting that all three streams (states, effects, handler actions) are fully drained. This catches unexpected extra emissions.

Opt out for a specific test case when you intentionally leave work pending:

// At declaration time — skip idle check entirely for this case:
testCase("fires and forgets", exhaustive = false) {  }

// At runtime — conditionally skip based on a branch:
testCase("conditional") {
    trigger(SomeAction) {  }
    if (someCondition) finish()  // stops the idle check from running
}

Full example — login flow

private val loginMachine = stateMachine<LoginState, LoginAction, LoginEffect> {
    initialState(LoginState.Idle)

    state<LoginState.Idle> {
        on<LoginAction.TypeCredentials> {
            transition(LoginState.Typing(action.username, action.password))
        }
    }

    state<LoginState.Typing> {
        on<LoginAction.Submit> {
            task("login", autoCancel = true) {
                dispatch(LoginAction.LoginSucceeded("alice"))
            }
            transition(LoginState.Submitting)
        }
    }

    state<LoginState.Submitting> {
        on<LoginAction.LoginSucceeded> {
            transition(LoginState.Authenticated(action.username))
            sideEffect(LoginEffect.NavigateToHome)
        }
        on<LoginAction.LoginFailed> {
            transition(LoginState.Idle)
            sideEffect(LoginEffect.ShowError(action.reason))
        }
    }

    state<LoginState> {
        on<LoginAction.Logout> {
            transition(LoginState.Idle)
            sideEffect(LoginEffect.NavigateToLogin)
        }
    }
}

@Test
fun loginFlow() = testStore(machine = loginMachine) {
    testCase("happy-path login") {
        trigger(LoginAction.TypeCredentials("alice", "secret")) {
            expectState<LoginState.Typing> { state.username == "alice" }
        }

        trigger(LoginAction.Submit) {
            expectState<LoginState.Submitting>()
            expectState<LoginState.Authenticated> { state.username == "alice" }
            expectEffect(LoginEffect.NavigateToHome)
        }
    }

    testCase("logout resets to Idle") {
        given(LoginState.Authenticated("alice"))

        trigger(LoginAction.Logout) {
            expectState<LoginState.Idle>()
            expectEffect(LoginEffect.NavigateToLogin)
        }
    }
}