A companion to mocking libraries that enables stubbing and verification of functions annotated with @androidx.compose.runtime.Composable
.
It currently works on JVM and Android, and integrates with the following mocking libraries:
It may come in handy if you need to stub certain functionality exposed by Compose UI,
or if you're using Compose for purposes other than Compose UI, and want to do stubbing within that context.
Here are a couple of example use cases from the integration tests of the project:
- Paparazzi snapshot tests with stubbed collectIsPressedAsState in order to record and verify the look of pressed UI elements.
See example here - Integration tests of a Composable function or Activity/Fragment in combination with a Molecule-style presenter.
See example here.
The reason why we can't stub or verify @Composable
functions in the first place is twofold:
- They are only allowed to run in a very particular context, namely in androidx.compose.runtime.Composition.setContent.
- The Compose compiler plugin transforms
@Composable
functions like so:
@Composable fun composableFun(args)
->
@Composable fun composableFun(args, $composer: Composer, $changed: Int)
This means that even if we're able to provide a context to stub in, we can't provide matchers for the$composer
and$changed
arguments (unless we resort to reflection shenanigans), as a result, any stubbing we attempt will fail to be matched because the generated argument values will change.
This is addressed by:
- Providing a way to run
@Composable
functions once for stubbing purposes, this then hooks into the stubbing mechanism of the mocking library that is being integrated with. - A Kotlin compiler plugin that runs after the Compose compiler plugin has done its transformations.
The plugin transforms calls to@Composable
functions within a stubbing context like so:
composableFunctionCall(args = args, $composer = composer, $changed = changed)
->
composableFunctionCall(args = args, $composer = any<Composer>(), $changed = any<Int>())
This allows us to stub and verify without caring about the values of$composer
and$changed
.
This approach is multiplatform friendly by virtue of doing compile time IR-transformations rather than runtime bytecode instrumentation.
Apply and configure the Gradle plugin:
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.jeppeman.mockposable:mockposable-gradle:0.10'
}
}
apply plugin: 'com.jeppeman.mockposable'
// This is where you configure what libraries to integrate with, and what version of the compose
// compiler to use.
mockposable {
// You can add one or many, e.g:
// plugins = ['mockk']
// plugins = ['mockito']
// plugins = ['compose-ui']
// plugins = ['mockk', 'mockito', 'compose-ui']
plugins = [...] // plugins = listOf(...) for build.gradle.kts
// This is optional, and defaults to the version that mockposable uses internally.
// If you as a consumer upgrade to a newer version of Kotlin before this plugin has had a
// chance to, you can explicitly select a version of the compose compiler plugin that is
// compatible with the version of Kotlin you use.
composeCompilerPluginVersion = "x.y.z"
}
This will apply the Kotlin compiler plugin as well as the relevant runtime dependencies.
The MockK-companion provides counterparts for the MockK standard API that works with @Composable
functions.
mockposable {
plugins = ['mockk']
}
import com.jeppeman.mockposable.mockk.everyComposable
import com.jeppeman.mockposable.mockk.verifyComposable
interface Dumb {
@Composable
fun dumber(arg: Int): Int
}
@Test
fun `test something`() {
val dumbMock = mockk<Dumb> {
everyComposable { dumber(any()) } returns 42
}
...
verifyComposable { dumbMock.dumber(any()) }
}
// Stubbing top level composable functions
@Composable
fun topLevelComposable(): String {
return "foo"
}
@Test
fun `test something else`() = mockkStatic("com.example.MyFilenameKt") { // The FQ name of the container class Kotlin creates for the top level function
everyComposable { topLevelComposable() } returns "bar"
...
verifyComposable { topLevelComposable() }
}
The full API surface of the MockK-companion comprises the following:
MockK Stub/verification function | Composable counterpart |
---|---|
every | everyComposable |
answers | answersComposable |
andThenAnswer | andThenComposable |
verify | verifyComposable |
verifyAll | verifyComposableAll |
verifyOrder | verifyComposableOrder |
verifySequence | verifyComposableSequence |
Mockito-companion. Same as MockK-companion but for Mockito.
mockposable {
plugins = ['mockito']
}
import com.jeppeman.mockposable.mockito.onComposable
import com.jeppeman.mockposable.mockito.verifyComposable
interface Dumb {
@Composable
fun dumber(arg: Int): Int
}
@Test
fun `test something`() {
val dumbMock = mock<Dumb> {
onComposable { dumber(any()) } returns 9
}
...
verifyComposable(dumbMock) { dumber(any()) }
}
The full API surface of the Mockito-companion comprises the following:
Mockito Stub/verification function | Composable counterpart |
---|---|
on | onComposable |
doAnswer | doAnswerComposable |
verify | verifyComposable |
In order to stub composables that gets emitted to the view tree in Compose UI (such as Text), there is a special rule that is needed in order to make sure that the stubbed composables are run with the right Composer, and hence emitted to the correct view tree:
mockposable {
plugins = ['compose-ui']
}
@RunWith(AndroidJUnit4::class)
class MyTest {
@get:Rule
val composeTestRule = MockposableComposeRule(createComposeRule())
@Test
fun test() = mockkStatic("androidx.compose.material.TextKt") {
everyComposable { Text(text = any()) } answersComposable { Text(text = "Will replace") }
composeTestRule.setContent { Text(text = "Will be replaced") }
composeTestRule.onNodeWithText("Will replace").assertIsDisplayed()
composeTestRule.onNodeWithText("Will be replaced").assertDoesNotExist()
}
}