Skip to content

Jetpack compose support #234

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
noahjutz opened this issue Dec 19, 2020 · 32 comments
Closed

Jetpack compose support #234

noahjutz opened this issue Dec 19, 2020 · 32 comments

Comments

@noahjutz
Copy link

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?

@mannodermaus
Copy link
Owner

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. 🙏

@Nikola-Milovic
Copy link

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

@mannodermaus
Copy link
Owner

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
Copy link
Owner

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

@Nikola-Milovic
Copy link

Seems promising!

@balazsbanyai
Copy link

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

@mannodermaus
Copy link
Owner

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 #257

@VitaliyDoskoch
Copy link

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

@mannodermaus
Copy link
Owner

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.

@Piotr-Smietana-Intent
Copy link

Piotr-Smietana-Intent commented Apr 29, 2022

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
Copy link

Piotr-Smietana-Intent commented Apr 29, 2022

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
    }

@mannodermaus
Copy link
Owner

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.

@ILikeYourHat
Copy link

Any plans to make this support stable? 🙂

@hakanai
Copy link

hakanai commented Oct 7, 2022

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.

@santansarah
Copy link

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'

@Faltenreich
Copy link

Faltenreich commented Feb 27, 2023

@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.

@santansarah
Copy link

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

@tuguzD
Copy link

tuguzD commented May 13, 2023

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

@mannodermaus
Copy link
Owner

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
Copy link
Owner

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. 🥳

@compscidr
Copy link
Contributor

compscidr commented Jul 16, 2023

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: #301

@mannodermaus
Copy link
Owner

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

@mgroth0
Copy link

mgroth0 commented Feb 24, 2024

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

@mgroth0
Copy link

mgroth0 commented Feb 24, 2024

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?

@mannodermaus
Copy link
Owner

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!

@lukaszkalnik
Copy link

lukaszkalnik commented May 24, 2024

When using this together with https://github.com/apter-tech/junit5-robolectric-extension I get the following error:

java.lang.UnsupportedOperationException: main looper can only be controlled from main thread
	at org.robolectric.shadows.ShadowPausedLooper.executeOnLooper(ShadowPausedLooper.java:692)
	at org.robolectric.shadows.ShadowPausedLooper.idle(ShadowPausedLooper.java:104)
	at org.robolectric.android.internal.RoboMonitoringInstrumentation.waitForIdleSync(RoboMonitoringInstrumentation.java:85)
	at androidx.test.core.app.ActivityScenario.moveToState(ActivityScenario.java:649)
	at androidx.test.core.app.ActivityScenario.close(ActivityScenario.java:416)
	at de.mannodermaus.junit5.compose.AndroidComposeExtension.afterEach(AndroidComposeExtension.kt:176)
	at java.base/java.util.ArrayList.forEach(Unknown Source)
	at java.base/java.util.ArrayList.forEach(Unknown Source)

@leggomymeggos
Copy link

@lukaszkalnik Same here; I'm also trying to use that library to update my Robolectric tests.

@mannodermaus Here's a sample test class that ends up throwing the main looper can only be controlled from main thread error:

import androidx.compose.material.Text
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import de.mannodermaus.junit5.compose.createComposeExtension
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.RegisterExtension
import tech.apter.junit.jupiter.robolectric.RobolectricExtension

@OptIn(ExperimentalTestApi::class)
@ExtendWith(RobolectricExtension::class)
class SampleTest {
    @Suppress("JUnitMalformedDeclaration")
    @JvmField
    @RegisterExtension
    val extension = createComposeExtension()

    @Test
    fun `Given a string, then it is displayed`() = extension.use {
        setContent {
            Text("Hello")
        }

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

I'm generally really excited for this library because it means that all of my project's UI tests could use JUnit5! This exception in Robolectric tests is a big blocker though

@mannodermaus
Copy link
Owner

Thanks for the added information! I have no resources to dedicate to this at the moment, but have created #340 to keep track of it. As this new trace is unrelated to Compose, let's put this ticket to rest and continue over there. 🙇‍♂️

@LloydBlv
Copy link

LloydBlv commented Jun 12, 2024

I annotated my tests with the good old @RunWith(RobolectricTesRunner::class) happily 😄 and always got a green even for all my failing tests until I realized I needed to use @ExtendWith(RobolectricExtension::class) instead. then I got the "main looper can only be controlled from main thread" error

//Always gets passed for any type of assertions
@RunWith(RobolectricTestRunner::class)
class SomeComposeRobolectricUiTests {
    @OptIn(ExperimentalTestApi::class)
    @Rule
    val composeTestRule = createComposeExtension()

So just to clarify, the current state of using Robolectric Jetpack Compose UI tests with JUnit5 is that there's no working solution at the moment right? Please correct me if I am wrong

@mgroth0
Copy link

mgroth0 commented Jul 4, 2024

Yes, I haven't tried it in a while but my assumption based on vague memory of past experiences is that Robolectric is not compatible with this library (at least for Compose)

If this library could be compatible with robolectric, there might be hope one day for running unit tests (not instrumented) for compose with JUnit 5.

Currently, I have a hacky way to run some compose unit tests (not instrumented) in JUnit 5 with this plugin, and without robolectric. But it doesn't work for all components, and it involved a lot of hacky trial and error that I wouldn't recommend to anyone. I decided in the end to just be patient for more updates either from here or from the standard android test library.

@vincent-paing
Copy link

Yes, I haven't tried it in a while but my assumption based on vague memory of past experiences is that Robolectric is not compatible with this library (at least for Compose)

If this library could be compatible with robolectric, there might be hope one day for running unit tests (not instrumented) for compose with JUnit 5.

Currently, I have a hacky way to run some compose unit tests (not instrumented) in JUnit 5 with this plugin, and without robolectric. But it doesn't work for all components, and it involved a lot of hacky trial and error that I wouldn't recommend to anyone. I decided in the end to just be patient for more updates either from here or from the standard android test library.

We have a community plugin for robolectric now :D https://github.com/apter-tech/junit5-robolectric-extension

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests