Testing a view
A view written as an extension on VIEViewScope can be tested without its ViewModel.
MockVIE, from the vie-test artifact, holds a model, records what the view posts, and sends it signals.
Test dependencies
A view test needs vie-test for MockVIE, and Compose’s UI test library to render the view:
kotlin {
sourceSets {
commonTest.dependencies {
implementation("org.kodein.arch.vie:vie-test:0.1.0")
implementation("org.jetbrains.compose.ui:ui-test:1.12.0")
}
jvmTest.dependencies {
implementation(compose.desktop.currentOs) (1)
}
}
}
| 1 | The desktop runtime that runs the UI tests of the JVM target. |
On a web target, the Compose Gradle plugin requires binaries.executable() before it lets Compose UI tests run, see CMP-4906.
The VIE demo disables its JS browser tests for that reason.
|
Asserting on posted intents
@OptIn(ExperimentalTestApi::class) (1)
class CounterTest {
@Test
fun plusPostsIncrement() = runComposeUiTest { (2)
val vie = MockVIE<Model, Intent, Signal>(
Model(count = 0),
)
setContent { vie.Counter(model = vie.model) } (3)
onNodeWithText("+").performClick()
onNodeWithText("+").performClick()
assertEquals(
listOf(Intent.Increment, Intent.Increment),
vie.pendingIntents(), (4)
)
}
}
| 1 | runComposeUiTest and the waitUntil… functions are experimental, and opting in is an error to forget. |
| 2 | Import androidx.compose.ui.test.v2.runComposeUiTest: the one without v2 is deprecated. |
| 3 | The view under test is the VIEViewScope extension, called on the mock. |
| 4 | An extension function: import org.kodein.arch.vie.test.pendingIntents. |
pendingIntents() drains: it returns what the view posted since the previous call, in order, and an empty list if it posted nothing.
MockVIE only records the intents.
Nothing processes them: the model does not change, and no signal is emitted.
What a ViewModel does with an intent is tested on its own, with runVMTest.
The mock
MockVIE is a complete VIE implementation:
| Member | Purpose |
|---|---|
|
Builds the mock with the model to render. |
|
The current model. |
|
Assign |
|
Records the intent. |
|
The intents posted and not yet received or drained, in order. |
|
An extension function, which drains |
|
Emit into it to send a signal to the view. |
|
Sends a signal without suspending. |
Changing the model
model reads modelFlow.value once, without observing it: vie.Counter(model = vie.model) renders the model the mock was built with, and does not recompose when modelFlow.value changes.
To change the model during a test, have the view collect modelFlow:
setContent {
val model by vie.modelFlow.collectAsState()
vie.Counter(model = model)
}
vie.modelFlow.value = Model(count = 5)
waitUntilExactlyOneExists(hasText("Count: 5"))
Driving signals
signal sends a signal to the view’s SignalEffect, to check how the view reacts.
Add this test to CounterTest:
@Test
fun errorShowsSnackbar() = runComposeUiTest {
val vie = MockVIE<Model, Intent, Signal>(
Model(count = 0),
)
setContent { vie.Counter(model = vie.model) }
waitForIdle() (1)
vie.signal(Signal.Error("Disk full"))
waitUntilAtLeastOneExists(hasText("Disk full"))
}
| 1 | Lets the view compose, so that SignalEffect collects before the signal is sent. |
A signal sent before SignalEffect starts collecting is lost, an Effect included, unlike with a VIEViewModel, which keeps Effects until a view collects.
|