Skip to content

Mocking with Jetpack Compose - stubbing and verification of Composable functions

License

Notifications You must be signed in to change notification settings

jeppeman/mockposable

Repository files navigation

Mockposable

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:

Why

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.

How

The reason why we can't stub or verify @Composable functions in the first place is twofold:

  1. They are only allowed to run in a very particular context, namely in androidx.compose.runtime.Composition.setContent.
  2. 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:

  1. 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.
  2. 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.

Usage

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.

Stubbing and verification with MockK

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

Stubbing and verification with Mockito

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

Stubbing and verification with Compose UI

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()
    }
}

About

Mocking with Jetpack Compose - stubbing and verification of Composable functions

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages