android-junit5 icon indicating copy to clipboard operation
android-junit5 copied to clipboard

Jetpack compose support

Open noahjutz opened this issue 3 years ago • 13 comments

I would like to use junit 5 to test my jetpack compose project.

A compose test rule is required for setting up the UI and using Testing APIs: (documentation)

@get:Rule
val composeTestRule = createAndroidComposeRule<MyActivity>()

This only works for junit 4. Is there any way to use junit 5 with jetpack compose?

noahjutz avatar Dec 19 '20 12:12 noahjutz

Thank you for bringing this up! Unfortunately, test rules are a concept exclusive to JUnit 4 and a new implementation is required to make them work with JUnit 5 (similar to the ActivityScenario support we already have). While I have done some work with Jetpack Compose, I haven't had the chance to look into testing and specifically how the ComposeTestRule works. In a nutshell, you'd have to convert its content to JUnit 5 extension points such as BeforeEachCallback and AfterEachCallback.

I might consider ramping up a library for Compose support in the near future, once that API stabilizes a little further. Until then, please continue to write your Compose UI tests against the JUnit 4 APIs. If you don't mind, I'd like to keep this ticket around to keep visibility and gauge interest from people towards this feature. 🙏

mannodermaus avatar Dec 19 '20 19:12 mannodermaus

Any update on this? Compose is getting its stable release now (currently 1.0.0-rc02) .

Nikola-Milovic avatar Jul 28 '21 15:07 Nikola-Milovic

Nothing substantial. I did have a look through the test source code of Jetpack Compose a few months ago, unfortunately leaving quite disappointed: There is a lot of tight coupling to JUnit 4 so we'd need to copy-paste and rewrite the entire thing, pretty much. I would've liked some sort of abstraction similar to what recent efforts in androidx.test have brought to the table. That's why I'm a little hesitant to fully commit to this right away. I'll give it another look after 1.0 stable is released

mannodermaus avatar Jul 28 '21 15:07 mannodermaus

I was celebrating the stable release of Jetpack Compose by giving the integration another shot. It's a very, very simple example but I managed to create a bridge to Compose via the existing JUnit 4 stuff. Feels great to see a @ParameterizedTest driving a Composable <3

There's still a lot to come and I need many more hours to refine this hacky approach, but I'm quite happy about this. 🎉🎉

Screen Shot 2021-07-28 at 20 45 33

mannodermaus avatar Jul 28 '21 18:07 mannodermaus

Seems promising!

Nikola-Milovic avatar Jul 28 '21 19:07 Nikola-Milovic

Looks great! Would you mind sharing the extension's code as a workaround until this feature is released?

balazsbanyai avatar Aug 23 '21 14:08 balazsbanyai

I've spent a bit of time drafting the initial PR for this and pulled in what I had been experimenting with before. You can get it from Sonatype's snapshot repository:

dependencies {
  androidTestImplementation("de.mannodermaus.junit5:android-test-compose:0.1.0-SNAPSHOT")
}

Usage:

class YourTest {
  @RegisterExtension
  @JvmField
  val extension = createComposeExtension()

  @Test
  fun test() {
    extension.setContent {
      // Your composable
    }
  }
}

I'll be tracking progress over time over at https://github.com/mannodermaus/android-junit5/pull/257

mannodermaus avatar Aug 24 '21 20:08 mannodermaus

I created a simple workaround for this. All you need is:

  1. To create an Extension like this:
class ComposeRuleExtension : ParameterResolver {

    private val rule by lazy { createComposeRule() }

    override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean {
        return parameterContext.parameter.type == ComposeRuleRunner::class.java
    }

    override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any {
        return ComposeRuleRunner(
            rule,
            Description.createTestDescription(
                extensionContext.testClass.orElse(this::class.java),
                extensionContext.displayName
            )
        )
    }
}
  1. To create the runner for the extension:
class ComposeRuleRunner(private val rule: ComposeContentTestRule, private val description: Description) {
    fun run(action: ComposeContentTestRule.() -> Unit) {
        rule
            .apply(
                object : Statement() {
                    override fun evaluate() {
                        action.invoke(rule)
                    }
                },
                description
            )
            .evaluate()
    }
}

Now you can use the Extension:

@RunWith(AndroidJUnit4ClassRunner::class)
@ExtendWith(ComposeRuleExtension::class)
class MyTest {
    @Test
    @DisplayName("JUnit5 test with `createComposeTestRule()`")
    fun test1(runner: ComposeRuleRunner) = runner.run {
        setContent {
            MyTheme {
                MyScreen()
            }
        }

        onNodeWithText("Hello, world!", ignoreCase = true).assertExists()
    }
}

For me it works for now. And there are no dangerous pitfalls with threads here

VitaliyDoskoch avatar Oct 23 '21 15:10 VitaliyDoskoch

Thanks for your input @VitaliyDoskoch! I like how your solution works without any multithreading, it's very concise in that way. One of the things that is lacking with the @ExtendWith approach however is that the user wouldn't be able to configure the rule for their specific test class, since the usage of createComposeRule() is hardcoded into the extension.

I've taken it as inspiration to draft a new version of the ComposeExtension which works both ways:

  1. When the defaults are enough, @ExtendWith(AndroidComposeExtension::class) can be used globally or on the class level and the ComposeExtension is resolved as a parameter
  2. When the user needs to configure the rule, they can keep using createAndroidComposeExtension() with their activity class and other parameters, then mark that field with @RegisterExtension
// Usage 1
@ExtendWith(AndroidComposeExtension::class)
class ClassComposeExtensionTests {

    @Test
    fun test(extension: ComposeExtension) = extension.runComposeTest {
        setContent {
            Column {
                Text(text = "Hello World")
            }
        }

        onNodeWithText("Hello World").assertIsDisplayed()
    }
}

// Usage 2
class FieldComposeExtensionTests {
    @JvmField
    @RegisterExtension
    val extension = createAndroidComposeExtension<MyCustomActivity>()

    @Test
    fun test() = extension.runComposeTest {
        setContent {
            Column {
                Text(text = "Hello World")
            }
        }

        onNodeWithText("Hello World").assertIsDisplayed()
    }
}

I'm very glad that I could delete the thread shenanigans again. Expect a new snapshot of 0.1.0 for the compose artifact within the next couple of hours.

mannodermaus avatar Oct 24 '21 17:10 mannodermaus

I've run into an issue with 1.0.0-SNAPSHOT version. The problem is that I cannot use ComposeContext outside of extension.runComposeTest { }, since currently there is no other way to get ComposeContext. This makes any UI compose actions in @BeforeEach methods impossible - if I use another extension.runComposeTest { } in @BeforeEach, it'l be treated as separate, isolated test and won't apply to regular @Test. This kinda defeats the purpose of using junit5 for me since I cannot do "step-by-step" setup in @Nested classes.

code examples:

using junit4

@get:Rule
    val composeTestRule = createAndroidComposeRule<LoginActivity>()

    @Before
    fun setUp() {
        composeTestRule.onNodeWithText("Next").performClick()
    }

    @Test
    fun errorDisplayed() {
        composeTestRule.onNodeWithText(getStringResource(R.string.empty_email_error)).assertIsDisplayed()
    }

trying to achieve the same with junit5

@ExtendWith(AndroidComposeExtension::class)
internal class MyTest {

    @JvmField
    @RegisterExtension
    val extension = createAndroidComposeExtension<LoginActivity>()

    @BeforeEach
    // setUp() is treated as a separate test, so click() doesn't happen when errorDisplayed() is run
    fun setUp() = extension.runComposeTest {
        onNodeWithText("Next").performClick()
    }

    @Test
    fun errorDisplayed() = extension.runComposeTest {
        onNodeWithText(getStringResource(R.string.empty_email_error)).assertIsDisplayed()
    }
}

I've tried to workaround it with:

  • using espresso, but espresso matchers aren't well suited for compose (e.g. to match button by text instead of ID (as compose has no IDs) one has to write a custom matcher)
  • saving ComposeContext outside of extension.runComposeTest { }, like:
            @BeforeEach
            fun setUp() {
                extension.runComposeTest { composeRule = this }
                composeRule.onNodeWithText("Next").performClick()
            }
        but it fails with `IllegalStateException: Test not setup properly. Use a ComposeTestRule in your test to be able to interact with composables`

Is there any way out of this situation (apart from writing monolithic tests without @BeforeEach use or reverting to junit4) ?

Piotr-Smietana-Intent avatar Apr 29 '22 10:04 Piotr-Smietana-Intent

Also I've noticed another, minor issue - ComposeContext, being an alias to ComposeContentTestRule, does not allow to directly access to activity (which is defined in ComposeContentTestRule implementation, AndroidComposeTestRule), like it used in junit4:

junit4:

@get:Rule
    val composeTestRule = createAndroidComposeRule<LoginActivity>()

    @Test
    fun test() {
        composeTestRule.activity
    }

junit5 has to cast ComposeContext to AndroidComposeTestRule first, which is not terrible but still inconvenient

    @JvmField
    @RegisterExtension
    val extension = createAndroidComposeExtension<LoginActivity>()    

    @Test
    fun test() = extension.runComposeTest {
        // this won't compile
        //this.activity

        // this works
        (this as AndroidComposeTestRule<*, *>).activity
    }

Piotr-Smietana-Intent avatar Apr 29 '22 10:04 Piotr-Smietana-Intent

Thanks for your feedback, @Piotr-Smietana-Intent! I'm also not quite happy with the extension API for JUnit 5 yet, and your BeforeEach example illustrates this pretty well, too.

mannodermaus avatar May 12 '22 17:05 mannodermaus

Any plans to make this support stable? 🙂

ILikeYourHat avatar Aug 28 '22 12:08 ILikeYourHat

Compose supports more than just Android, too, and those of us writing tests for desktop apps also want a ComposeExtension instead of a ComposeTestRule.

The cross-platform equivalent of createAndroidComposeRule<T>() appears to be just createComposeRule() - on desktop this returns a DesktopComposeTestRule instead of the Android one.

I was attempting to write an extension to replace the test rule for desktop tests, and more or less hit the same problems described here. A lot of the code their test rule is using is internal, so I would end up copying those things into my code as well, and eventually I would have all the code in their module duplicated in mine. :(

I opened a ticket at compose-jb asking for them to consider using JUnit 5 instead of 4. The test API is still experimental, so wild changes in how it works are already expected.

hakanai avatar Oct 07 '22 10:10 hakanai

I tested this out - this is working great; very cool!

@RegisterExtension
@JvmField
val extension = createComposeExtension()

I ran into an issue with this:

@JvmField
@RegisterExtension
 val extension = createAndroidComposeExtension<MainActivity>()

I get an error: Cannot inline bytecode built with JVM target 11 into bytecode that is being built with JVM target 1.8. Please specify proper '-jvm-target' option

I may have missed something in setup?

Here's a link to my test (along w/the full project): https://github.com/santansarah/sharedtest-junit5-turbine/blob/compose-testing/app/src/androidTest/java/com/santansarah/sharedtestjunit5turbine/compose/ComposeTestActivity.kt

Android Studio Flamingo | 2022.2.1 Canary 9
Build #AI-222.4345.14.2221.9321504, built on November 22, 2022
Runtime version: 17.0.4.1+0-b2043.56-9127311 amd64
VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o.
Windows 11 10.0
plugins {
    id 'com.android.application' version '8.0.0-alpha09' apply false
    id 'com.android.library' version '8.0.0-alpha09' apply false
    id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
    id 'de.mannodermaus.android-junit5' version '1.8.2.1' apply false
}
compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
androidTestImplementation 'de.mannodermaus.junit5:android-test-compose:0.1.0-SNAPSHOT'

santansarah avatar Dec 30 '22 22:12 santansarah

@santansarah createAndroidComposeExtension requires building against JVM target 11, so either stick to the class annotation @ExtendWith(AndroidComposeExtension::class) or change your compile options to:

compileOptions {
        sourceCompatibility JavaVersion.VERSION_11
        targetCompatibility JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = '11'
    }
}

If it still does not work, check your preferences and ensure that the Kotlin compiler uses the same JVM target. I then had to "Invalidate Caches..." and after a restart of Android Studio, everything worked fine.

Faltenreich avatar Feb 27 '23 13:02 Faltenreich

Hi @Faltenreich - I changed my compile options to version 11; I also updated my project plugins + dependencies. Everything is working great - thanks so much! Sarah

santansarah avatar Feb 27 '23 22:02 santansarah

Was de.mannodermaus.junit5:android-test-compose:0.1.0-SNAPSHOT removed or am I missing something?

tuguzD avatar May 13 '23 09:05 tuguzD

No, shouldn't be. 🤔 The Maven Central snapshots repository lists the data for it just like before. Please make sure that the repo is available to your module and that Gradle offline mode isn't on by accident (speaking from experience there).

mannodermaus avatar May 15 '23 11:05 mannodermaus

Hello everybody! I wanted to share an update for the Compose integration, as I finally had a bit of time to spend on this endeavor over the last couple of nights.

Please update your dependency declarations as follows:

dependencies {
  androidTestImplementation("de.mannodermaus.junit5:android-test-compose:1.0.0-SNAPSHOT")
  androidTestImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
  debugImplementation("androidx.compose.ui:ui-test-manifest:1.4.3")
}

Also, make sure to use the latest Gradle plugin for JUnit 5 (1.9.3.0). Then, you should be able to use the revamped ComposeExtension in your instrumentation tests, literally:

class MyTests {
  @JvmField
  @RegisterExtension
  @ExperimentalTestApi
  val extension = createComposeExtension()
  // or, if you want to test your own Activity:
  // val extension =  createAndroidComposeExtension<MyActivity>() 

  @BeforeEach
  fun beforeEach() {
    extension.use {
      // You can do set-up logic here...
    }
  }

  @Test
  fun test() {
    extension.use {
      // ...and run the actual test here!
      setContent {
        Button(onClick = {}) {
          Text("button")
        }
      }

      onNodeWithText("button").performClick()
    }
  }
}

runComposeTest is now a synonym for use, but was deprecated in the latest snapshot and will be removed in a stable release.

There may very well be a bunch of cases that aren't working 100% yet, so please keep letting me know about those. 🥳

mannodermaus avatar May 17 '23 14:05 mannodermaus

This is working great so far, thanks! I was wondering if its possible to have the Activity start with a particular intent?

For reference, I found this post - where with the junit4 approach there seems to be ways to do this: https://stackoverflow.com/questions/68267861/add-intent-extras-in-compose-ui-test

I'm having trouble figuring out if the equivalent is possible here.

It looks like if we could provide this function:

public fun <A : ComponentActivity> createAndroidComposeExtension(
    activityClass: Class<A>
): AndroidComposeExtension<A> {
    return AndroidComposeExtension(
        scenarioSupplier = {
            ActivityScenario.launch(activityClass)
        }
    )
}

https://github.com/mannodermaus/android-junit5/blob/main/instrumentation/compose/src/main/java/de/mannodermaus/junit5/compose/AndroidComposeExtension.kt#L58

with a custom scenarioSupplier, we could call a different version of ActivityScenario.launch, for instance androidx.test:core:1.5.0 has one like this which takes a startActivityIntent:

public static <A extends Activity> ActivityScenario<A> launch(Intent startActivityIntent) {
    ActivityScenario<A> scenario = new ActivityScenario<>(checkNotNull(startActivityIntent));
    scenario.launchInternal(/*activityOptions=*/ null, /*launchActivityForResult=*/ false);
    return scenario;
  }

https://developer.android.com/reference/androidx/test/core/app/ActivityScenario#launch(android.content.Intent) and perhaps that would work?

Update: Added this PR, let me know if this makes sense or not: https://github.com/mannodermaus/android-junit5/pull/301

compscidr avatar Jul 16 '23 18:07 compscidr

The inaugural version of Compose, including the additions by @compscidr, have been released! To keep in line with the versioning of the other instrumentation libraries, I bumped the Compose artifact to 1.4.0 as well:

dependencies {
  androidTestImplementation("de.mannodermaus.junit5:android-test-compose:1.4.0")
}

mannodermaus avatar Nov 04 '23 14:11 mannodermaus

Thanks, this is great! Should I expect it to work for Compose Desktop?

mgroth0 avatar Feb 24 '24 05:02 mgroth0

I am getting a warning that is requeiring me to use @Suppress("JUnitMalformedDeclaration")

@Suppress("JUnitMalformedDeclaration")
    @OptIn(androidx.compose.ui.test.ExperimentalTestApi::class)
    @JvmField
    @RegisterExtension
    @ExperimentalTestApi
    val extension: ComposeExtension = createComposeExtension()
Field 'extension' annotated with '@RegisterExtension' should be of type 'org.junit.jupiter.api.extension.Extension' 

Is this to be expected?

mgroth0 avatar Feb 24 '24 05:02 mgroth0

Thanks, this is great! Should I expect it to work for Compose Desktop?

No, this project is solely confined to Android and this includes the Compose support.

I am getting a warning that is requeiring me to use @Suppress("JUnitMalformedDeclaration")

This warning can be disregarded, but it definitely is a bug. I've filed #318 for it, thanks for bringing it up!

mannodermaus avatar Apr 02 '24 03:04 mannodermaus