diff --git a/README.md.template b/README.md.template index ceff9701..dfeea43d 100644 --- a/README.md.template +++ b/README.md.template @@ -149,8 +149,8 @@ Can you think of more? Let's discuss in the issues section! Kotlin ```kotlin - dependencies { - androidTestImplementation("de.mannodermaus.junit5:android-test-extensions:${instrumentationVersion}") + junitPlatform { + instrumentationTests.includeExtensions.set(true) } ``` @@ -159,8 +159,8 @@ Can you think of more? Let's discuss in the issues section! Groovy ```groovy - dependencies { - androidTestImplementation "de.mannodermaus.junit5:android-test-extensions:${instrumentationVersion}" + junitPlatform { + instrumentationTests.includeExtensions.set(true) } ``` @@ -168,15 +168,16 @@ Can you think of more? Let's discuss in the issues section! ### Jetpack Compose To test `@Composable` functions on device with JUnit 5, first enable support for instrumentation tests as described above. -Then, add the integration library for Jetpack Compose to the `androidTestImplementation` configuration: +Then, add the Compose test dependency to your `androidTestImplementation` configuration +and the plugin will autoconfigure JUnit 5 Compose support for you!
Kotlin ```kotlin dependencies { - // Test extension & transitive dependencies - androidTestImplementation("de.mannodermaus.junit5:android-test-compose:${instrumentationVersion}") + // Compose test framework + androidTestImplementation("androidx.compose.ui:ui-test-android:$compose_version") // Needed for createComposeExtension() and createAndroidComposeExtension() debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version") @@ -189,8 +190,8 @@ Then, add the integration library for Jetpack Compose to the `androidTestImpleme ```groovy dependencies { - // Test extension & transitive dependencies - androidTestImplementation "de.mannodermaus.junit5:android-test-compose:${instrumentationVersion}" + // Compose test framework + androidTestImplementation "androidx.compose.ui:ui-test-android:$compose_version" // Needed for createComposeExtension() and createAndroidComposeExtension() debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" @@ -200,6 +201,31 @@ Then, add the integration library for Jetpack Compose to the `androidTestImpleme [The wiki][wiki-home] includes a section on how to test your Composables with JUnit 5. +### Override the version of instrumentation test libraries + +By default, the plugin will make sure to use a compatible version of the instrumentation test libraries +when it sets up the artifacts automatically. However, it is possible to choose a custom version instead via its DSL: + +
+ Kotlin + + ```kotlin + junitPlatform { + instrumentationTests.version.set("${instrumentationVersion}") + } + ``` +
+ +
+ Groovy + + ```groovy + junitPlatform { + instrumentationTests.version.set("${instrumentationVersion}") + } + ``` +
+ ## Official Support At this time, Google hasn't shared any immediate plans to bring first-party support for JUnit 5 to Android. The following list is an aggregation of pending feature requests: diff --git a/plugin/CHANGELOG.md b/plugin/CHANGELOG.md index 491fcbb3..7e5167d7 100644 --- a/plugin/CHANGELOG.md +++ b/plugin/CHANGELOG.md @@ -7,6 +7,8 @@ Change Log - Allow overriding the version of the instrumentation libraries applied with the plugin - Update Jacoco & instrumentation test DSLs of the plugin to use Gradle Providers for their input parameters (e.g. `instrumentationTests.enabled.set(true)` instead of `instrumentationTests.enabled = true`) - Removed deprecated `integrityCheckEnabled` flag from the plugin DSL's instrumentation test options +- Allow opt-in usage of extension library via the plugin's DSL +- Allow autoconfiguration of compose library if Compose is used in the androidTest dependency list ## 1.10.0.0 (2023-11-05) - JUnit 5.10.0 diff --git a/plugin/android-junit5/build.gradle.kts b/plugin/android-junit5/build.gradle.kts index caba37bc..fed0c14d 100644 --- a/plugin/android-junit5/build.gradle.kts +++ b/plugin/android-junit5/build.gradle.kts @@ -88,6 +88,7 @@ val versionClassTask = tasks.register("createVersionClass") { mapOf( "tokens" to mapOf( "INSTRUMENTATION_GROUP" to Artifacts.Instrumentation.groupId, + "INSTRUMENTATION_COMPOSE" to Artifacts.Instrumentation.Compose.artifactId, "INSTRUMENTATION_CORE" to Artifacts.Instrumentation.Core.artifactId, "INSTRUMENTATION_EXTENSIONS" to Artifacts.Instrumentation.Extensions.artifactId, "INSTRUMENTATION_RUNNER" to Artifacts.Instrumentation.Runner.artifactId, diff --git a/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/configureJUnit5.kt b/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/configureJUnit5.kt index 42bca48e..144e33ac 100644 --- a/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/configureJUnit5.kt +++ b/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/configureJUnit5.kt @@ -14,12 +14,16 @@ import de.mannodermaus.gradle.plugins.junit5.internal.config.PluginConfig import de.mannodermaus.gradle.plugins.junit5.internal.extensions.android import de.mannodermaus.gradle.plugins.junit5.internal.extensions.getAsList import de.mannodermaus.gradle.plugins.junit5.internal.extensions.getTaskName +import de.mannodermaus.gradle.plugins.junit5.internal.extensions.hasDependency import de.mannodermaus.gradle.plugins.junit5.internal.extensions.junit5Warn import de.mannodermaus.gradle.plugins.junit5.internal.extensions.namedOrNull +import de.mannodermaus.gradle.plugins.junit5.internal.extensions.usesComposeIn +import de.mannodermaus.gradle.plugins.junit5.internal.extensions.usesJUnitJupiterIn import de.mannodermaus.gradle.plugins.junit5.internal.utils.excludedPackagingOptions import de.mannodermaus.gradle.plugins.junit5.tasks.AndroidJUnit5JacocoReport import de.mannodermaus.gradle.plugins.junit5.tasks.AndroidJUnit5WriteFilters import org.gradle.api.Project +import org.gradle.api.artifacts.Dependency import org.gradle.api.tasks.testing.Test internal fun configureJUnit5( @@ -109,11 +113,7 @@ private fun AndroidJUnitPlatformExtension.prepareInstrumentationTests(project: P if (!instrumentationTests.enabled.get()) return // Automatically configure instrumentation tests when JUnit 5 is detected in that configuration - val hasJupiterApi = project.configurations - .getByName("androidTestImplementation") - .dependencies - .any { it.group == "org.junit.jupiter" && it.name == "junit-jupiter-api" } - if (!hasJupiterApi) return + if (!project.usesJUnitJupiterIn("androidTestImplementation")) return // Attach the JUnit 5 RunnerBuilder to the list, unless it's already added val runnerBuilders = android.defaultConfig.testInstrumentationRunnerArguments.getAsList("runnerBuilder") @@ -141,6 +141,13 @@ private fun AndroidJUnitPlatformExtension.prepareInstrumentationTests(project: P "${Libraries.instrumentationExtensions}:$version" ) } + + if (project.usesComposeIn("androidTestImplementation")) { + project.dependencies.add( + "androidTestImplementation", + "${Libraries.instrumentationCompose}:$version" + ) + } } private fun AndroidJUnitPlatformExtension.configureUnitTests(project: Project, variant: Variant) { diff --git a/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/extensions/ProjectExt.kt b/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/extensions/ProjectExt.kt index dd1b92ba..347b6cd8 100644 --- a/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/extensions/ProjectExt.kt +++ b/plugin/android-junit5/src/main/kotlin/de/mannodermaus/gradle/plugins/junit5/internal/extensions/ProjectExt.kt @@ -5,6 +5,7 @@ import com.android.build.gradle.BasePlugin import de.mannodermaus.gradle.plugins.junit5.dsl.AndroidJUnitPlatformExtension import de.mannodermaus.gradle.plugins.junit5.internal.config.EXTENSION_NAME import org.gradle.api.Project +import org.gradle.api.artifacts.Dependency import java.util.concurrent.atomic.AtomicBoolean import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind @@ -35,3 +36,21 @@ internal fun Project.whenAndroidPluginAdded(block: (BasePlugin) -> Unit) { } } } + +internal fun Project.hasDependency(configurationName: String, matching: (Dependency) -> Boolean): Boolean { + val configuration = project.configurations.getByName(configurationName) + + return configuration.dependencies.any(matching) +} + +internal fun Project.usesJUnitJupiterIn(configurationName: String): Boolean { + return project.hasDependency(configurationName) { + it.group == "org.junit.jupiter" && it.name == "junit-jupiter-api" + } +} + +internal fun Project.usesComposeIn(configurationName: String): Boolean { + return project.hasDependency(configurationName) { + it.group?.startsWith("androidx.compose") ?: false + } +} diff --git a/plugin/android-junit5/src/main/templates/Libraries.kt b/plugin/android-junit5/src/main/templates/Libraries.kt index 6986fe57..b992a9b2 100644 --- a/plugin/android-junit5/src/main/templates/Libraries.kt +++ b/plugin/android-junit5/src/main/templates/Libraries.kt @@ -2,6 +2,7 @@ package de.mannodermaus internal object Libraries { const val instrumentationVersion = "@INSTRUMENTATION_VERSION@" + const val instrumentationCompose = "@INSTRUMENTATION_GROUP@:@INSTRUMENTATION_COMPOSE@" const val instrumentationCore = "@INSTRUMENTATION_GROUP@:@INSTRUMENTATION_CORE@" const val instrumentationExtensions = "@INSTRUMENTATION_GROUP@:@INSTRUMENTATION_EXTENSIONS@" const val instrumentationRunner = "@INSTRUMENTATION_GROUP@:@INSTRUMENTATION_RUNNER@" diff --git a/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/plugin/InstrumentationSupportTests.kt b/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/plugin/InstrumentationSupportTests.kt index 95031bdf..3baaa072 100644 --- a/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/plugin/InstrumentationSupportTests.kt +++ b/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/plugin/InstrumentationSupportTests.kt @@ -5,6 +5,7 @@ import de.mannodermaus.Libraries import de.mannodermaus.gradle.plugins.junit5.internal.config.ANDROID_JUNIT5_RUNNER_BUILDER_CLASS import de.mannodermaus.gradle.plugins.junit5.internal.extensions.android import de.mannodermaus.gradle.plugins.junit5.internal.extensions.junitPlatform +import de.mannodermaus.gradle.plugins.junit5.util.assertThat import de.mannodermaus.gradle.plugins.junit5.util.evaluate import org.gradle.api.Project import org.junit.jupiter.api.BeforeEach @@ -67,14 +68,15 @@ class InstrumentationSupportTests { /* Dependencies */ @Test - fun `add the dependencies`() { + fun `add only the main dependencies`() { project.addJUnitJupiterApi() project.evaluate() - assertThat(project.dependencyNamed("androidTestImplementation", "android-test-core")) - .isEqualTo("${Libraries.instrumentationCore}:${Libraries.instrumentationVersion}") - assertThat(project.dependencyNamed("androidTestRuntimeOnly", "android-test-runner")) - .isEqualTo("${Libraries.instrumentationRunner}:${Libraries.instrumentationVersion}") + assertThat(project).configuration("androidTestImplementation").hasDependency(coreLibrary()) + assertThat(project).configuration("androidTestRuntimeOnly").hasDependency(runnerLibrary()) + + assertThat(project).configuration("androidTestImplementation").doesNotHaveDependency(extensionsLibrary()) + assertThat(project).configuration("androidTestImplementation").doesNotHaveDependency(composeLibrary()) } @Test @@ -83,8 +85,8 @@ class InstrumentationSupportTests { project.junitPlatform.instrumentationTests.version.set("1.3.3.7") project.evaluate() - assertThat(project.dependencyNamed("androidTestImplementation", "android-test-core")).endsWith("1.3.3.7") - assertThat(project.dependencyNamed("androidTestRuntimeOnly", "android-test-runner")).endsWith("1.3.3.7") + assertThat(project).configuration("androidTestImplementation").hasDependency(coreLibrary("1.3.3.7")) + assertThat(project).configuration("androidTestRuntimeOnly").hasDependency(runnerLibrary("1.3.3.7")) } @Test @@ -96,8 +98,8 @@ class InstrumentationSupportTests { project.dependencies.add("androidTestRuntimeOnly", addedRunner) project.evaluate() - assertThat(project.dependencyNamed("androidTestImplementation", "android-test-core")).isEqualTo(addedCore) - assertThat(project.dependencyNamed("androidTestRuntimeOnly", "android-test-runner")).isEqualTo(addedRunner) + assertThat(project).configuration("androidTestImplementation").hasDependency(coreLibrary("0.1.3.3.7")) + assertThat(project).configuration("androidTestRuntimeOnly").hasDependency(runnerLibrary("0.1.3.3.7")) } @Test @@ -106,16 +108,61 @@ class InstrumentationSupportTests { project.junitPlatform.instrumentationTests.enabled.set(false) project.evaluate() - assertThat(project.dependencyNamed("androidTestImplementation", "android-test-core")).isNull() - assertThat(project.dependencyNamed("androidTestRuntimeOnly", "android-test-runner")).isNull() + assertThat(project).configuration("androidTestImplementation").doesNotHaveDependency(coreLibrary(null)) + assertThat(project).configuration("androidTestRuntimeOnly").doesNotHaveDependency(runnerLibrary(null)) } @Test fun `do not add the dependencies when Jupiter is not added`() { project.evaluate() - assertThat(project.dependencyNamed("androidTestImplementation", "android-test-core")).isNull() - assertThat(project.dependencyNamed("androidTestRuntimeOnly", "android-test-runner")).isNull() + assertThat(project).configuration("androidTestImplementation").doesNotHaveDependency(coreLibrary(null)) + assertThat(project).configuration("androidTestRuntimeOnly").doesNotHaveDependency(runnerLibrary(null)) + } + + @Test + fun `do not add the dependencies when Jupiter is not added, even if extension is configured to be added`() { + project.junitPlatform.instrumentationTests.includeExtensions.set(true) + project.evaluate() + + assertThat(project).configuration("androidTestImplementation").doesNotHaveDependency(coreLibrary(null)) + assertThat(project).configuration("androidTestImplementation").doesNotHaveDependency(extensionsLibrary(null)) + assertThat(project).configuration("androidTestRuntimeOnly").doesNotHaveDependency(runnerLibrary(null)) + } + + @Test + fun `add the extension library if configured`() { + project.addJUnitJupiterApi() + project.junitPlatform.instrumentationTests.includeExtensions.set(true) + project.evaluate() + + assertThat(project).configuration("androidTestImplementation").hasDependency(coreLibrary()) + assertThat(project).configuration("androidTestImplementation").hasDependency(extensionsLibrary()) + assertThat(project).configuration("androidTestRuntimeOnly").hasDependency(runnerLibrary()) + } + + @Test + fun `add the compose library if configured`() { + project.addJUnitJupiterApi() + project.addCompose() + project.evaluate() + + assertThat(project).configuration("androidTestImplementation").hasDependency(coreLibrary()) + assertThat(project).configuration("androidTestImplementation").hasDependency(composeLibrary()) + assertThat(project).configuration("androidTestRuntimeOnly").hasDependency(runnerLibrary()) + } + + @Test + fun `add the extensions and compose libraries if configured`() { + project.addJUnitJupiterApi() + project.addCompose() + project.junitPlatform.instrumentationTests.includeExtensions.set(true) + project.evaluate() + + assertThat(project).configuration("androidTestImplementation").hasDependency(coreLibrary()) + assertThat(project).configuration("androidTestImplementation").hasDependency(composeLibrary()) + assertThat(project).configuration("androidTestImplementation").hasDependency(extensionsLibrary()) + assertThat(project).configuration("androidTestRuntimeOnly").hasDependency(runnerLibrary()) } /* Private */ @@ -124,10 +171,27 @@ class InstrumentationSupportTests { dependencies.add("androidTestImplementation", "org.junit.jupiter:junit-jupiter-api:+") } - private fun Project.dependencyNamed(configurationName: String, name: String): String? { - return configurations.getByName(configurationName) - .dependencies - .firstOrNull { it.name == name } - ?.run { "$group:$name:$version" } + private fun Project.addCompose() { + dependencies.add("androidTestImplementation", "androidx.compose.ui:ui-test-android:+") + } + + private fun composeLibrary(withVersion: String? = Libraries.instrumentationVersion) = + library(Libraries.instrumentationCompose, withVersion) + + private fun coreLibrary(withVersion: String? = Libraries.instrumentationVersion) = + library(Libraries.instrumentationCore, withVersion) + + private fun extensionsLibrary(withVersion: String? = Libraries.instrumentationVersion) = + library(Libraries.instrumentationExtensions, withVersion) + + private fun runnerLibrary(withVersion: String? = Libraries.instrumentationVersion) = + library(Libraries.instrumentationRunner, withVersion) + + private fun library(artifactId: String, version: String?) = buildString { + append(artifactId) + if (version != null) { + append(':') + append(version) + } } } diff --git a/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/util/GradleTruth.kt b/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/util/GradleTruth.kt new file mode 100644 index 00000000..f5742239 --- /dev/null +++ b/plugin/android-junit5/src/test/kotlin/de/mannodermaus/gradle/plugins/junit5/util/GradleTruth.kt @@ -0,0 +1,68 @@ +package de.mannodermaus.gradle.plugins.junit5.util + +import com.google.common.truth.FailureMetadata +import com.google.common.truth.Subject +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertWithMessage +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration + +/* Methods */ + +fun assertThat(actual: Project): ProjectSubject = + Truth.assertAbout(::ProjectSubject).that(actual) + +/* Types */ + +class ProjectSubject( + metadata: FailureMetadata, + private val actual: Project?, +) : Subject(metadata, actual) { + + fun configuration(name: String): ConfigurationSubject = check("configuration()") + .about(::ConfigurationSubject) + .that(actual?.configurations?.getByName(name)) +} + +class ConfigurationSubject( + metadata: FailureMetadata, + private val actual: Configuration?, +) : Subject(metadata, actual) { + private val dependencyNames by lazy { + actual?.dependencies + ?.map { "${it.group}:${it.name}:${it.version}" } + .orEmpty() + } + + fun hasDependency(notation: String) { + containsDependency(notation, expectExists = true) + } + + fun doesNotHaveDependency(notation: String) { + containsDependency(notation, expectExists = false) + } + + /* Private */ + + private fun containsDependency(notation: String, expectExists: Boolean) { + // If the expected dependency has a version component, + // include it in the check. Otherwise, check for the existence + // of _any_ version for the dependency in question + val notationIncludesVersion = notation.count { it == ':' } > 1 + val hasMatch = if (notationIncludesVersion) { + notation in dependencyNames + } else { + dependencyNames.any { it.startsWith("$notation:") } + } + + val messagePrefix = if (expectExists) { + "Expected to have a dependency on '$notation' in configuration '${actual?.name}', but did not." + } else { + "Expected not to have a dependency on '$notation' in configuration '${actual?.name}', but did." + } + + assertWithMessage( + "$messagePrefix\nDependencies in this configuration: $dependencyNames" + ).that(hasMatch).isEqualTo(expectExists) + } +}