Skip to content

Testing

Presenters are Composables that return values, so they can be tested as functions — no emulator, no Robolectric, no Compose UI test rule.

testImplementation("io.github.avelon1a:orbit-test:0.0.11")

The harness runs on every target, so these tests execute on the JVM and on iOS.

Testing a presenter

Presenter.test { } runs the presenter and gives you a Turbine over its emitted states.

@Test
fun incrementingBumpsTheCount() = runTest {
    CounterPresenter(FakeNavigator(CounterScreen)).test {
        val initial = awaitItem()
        assertEquals(0, initial.count)

        initial.eventSink(CounterEvent.Increment)
        assertEquals(1, awaitItem().count)

        cancelAndIgnoreRemainingEvents()
    }
}

The rhythm is always the same: await a state, send an event through its eventSink, await the next state. Finish with cancelAndIgnoreRemainingEvents() so the presenter is torn down.

test accepts a timeout if a presenter does slow work:

presenter.test(timeout = 30.seconds) { ... }

FakeNavigator

Records navigation without a real back stack:

@Test
fun openingAnItemNavigates() = runTest {
    val navigator = FakeNavigator(HomeScreen)

    HomePresenter(navigator).test {
        awaitItem().eventSink(HomeEvent.OpenDetail("42"))
        assertEquals(DetailScreen("42"), navigator.awaitNextScreen())
        cancelAndIgnoreRemainingEvents()
    }
}
Method Asserts on
awaitNextScreen() the next goTo
awaitPop() the next pop, with its PopResult
awaitResetRoot() the next resetRoot, with its flags
assertIsEmpty() the back stack is empty
expectNoEvents() nothing navigated

Seed it with a starting stack, root first:

val navigator = FakeNavigator(HomeScreen, DetailScreen("42"))

Pop results come back on the event:

val event = navigator.awaitPop()
assertEquals(DeletedResult("42"), event.result)

Asserting nothing happened

@Test
fun incrementDoesNotNavigate() = runTest {
    val navigator = FakeNavigator(CounterScreen)

    CounterPresenter(navigator).test {
        awaitItem().eventSink(CounterEvent.Increment)
        awaitItem()
        cancelAndIgnoreRemainingEvents()
    }

    navigator.expectNoEvents()
}

Overlays

FakeOverlayHost intercepts what a presenter shows and answers it:

val overlayHost = FakeOverlayHost()

val shown = overlayHost.awaitOverlay()
assertTrue(shown is ConfirmOverlay)
overlayHost.finish(true)

Provide it via LocalOverlayHost in the composition under test.

Retained state in tests

With no registry provided, rememberRetained falls back to NoOpRetainedStateRegistry and behaves like remember. Presenter tests therefore need no setup, and each test { } starts clean.

To exercise retention itself, provide a registry and re-run the presenter against it:

val registry = RetainedStateRegistry()

Testing UIs

UIs are ordinary Composables taking a state object, so they need no Orbit-specific tooling — construct a state and use the standard Compose UI test APIs:

composeTestRule.setContent {
    HomeContent(HomeState(items = persistentListOf("Item 1")) { })
}
composeTestRule.onNodeWithText("Item 1").assertIsDisplayed()

Because the UI only reads its state parameter, every case — loading, error, empty — is reachable by passing a different state. There is no need to drive a presenter into that condition first.

What still needs a device

Some behaviour is integration-level and unit tests cannot reach it:

  • retained state surviving a real configuration change,
  • the back stack rebuilding after process death,
  • transitions and shared elements.

Verify those on an emulator or simulator when you touch navigation or retention.