android-junit5
android-junit5 copied to clipboard
Jetpack compose support
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?
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. 🙏
Any update on this? Compose is getting its stable release now (currently 1.0.0-rc02
) .
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
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](https://user-images.githubusercontent.com/3897705/127378992-ec98b1a1-83b6-4034-aaa5-6f92e2983615.png)
Seems promising!
Looks great! Would you mind sharing the extension's code as a workaround until this feature is released?
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
I created a simple workaround for this. All you need is:
- 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
)
)
}
}
- 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
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:
- When the defaults are enough,
@ExtendWith(AndroidComposeExtension::class)
can be used globally or on the class level and theComposeExtension
is resolved as a parameter - 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.
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) ?
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
}
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.
Any plans to make this support stable? 🙂
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.
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 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.
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
Was de.mannodermaus.junit5:android-test-compose:0.1.0-SNAPSHOT
removed or am I missing something?
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).
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. 🥳
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
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")
}
Thanks, this is great! Should I expect it to work for Compose Desktop?
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?
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!