Skip to content

Commit d082fce

Browse files
authored
Discard JUnit 5 test runners even after creation, if further filters result in it not contributing any tests (#326)
This is interesting for `@Tag`-annotated classes that are skipped completely. If their runner is not prevented from being returned to the instrumentation, Android expects something reported back to it for the class, but in reality JUnit 5 won't be reporting anything, causing a mismatch error
1 parent be10b22 commit d082fce

File tree

9 files changed

+161
-84
lines changed

9 files changed

+161
-84
lines changed

instrumentation/CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ Change Log
66
- Fix inheritance hierarchy of `ComposeExtension` to avoid false-positive warning regarding `@RegisterExtension` (#318)
77
- Improve parallel test execution for Android instrumentation tests
88
- Fix invalid naming of dynamic tests when executing only a singular test method from the IDE (#317)
9-
- Prevent test methods incorrectly defined as Kotlin top-level functions from messing up Android's internal test counting, causing issues like "Expected N+1 tests, received N" (#316)
9+
- Prevent test methods incorrectly defined as Kotlin top-level functions from messing up Android's internal test counting, causing issues like "Expected N+1 tests, received N" (#316)
10+
- Prevent test classes ignored by a tag from being considered for test execution, causing issues like "Expected N+1 tests, received N" (#298)
1011

1112
## 1.4.0 (2023-11-05)
1213

instrumentation/core/build.gradle.kts

+18-3
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ android {
5151
}
5252

5353
junitPlatform {
54-
// Using local dependency instead of Maven coordinates
55-
instrumentationTests.enabled = false
54+
filters {
55+
// See TaggedTests.kt for usage of this tag
56+
excludeTags("nope")
57+
}
5658
}
5759

5860
tasks.withType<KotlinCompile> {
@@ -68,6 +70,20 @@ tasks.withType<Test> {
6870
}
6971
}
7072

73+
// Use local project dependencies on android-test instrumentation libraries
74+
// instead of relying on their Maven coordinates for this module
75+
val instrumentationLibraryRegex = Regex("de\\.mannodermaus\\.junit5:android-test-(.+):")
76+
77+
configurations.all {
78+
if ("debugAndroidTestRuntimeClasspath" in name) {
79+
resolutionStrategy.dependencySubstitution.all {
80+
instrumentationLibraryRegex.find(requested.toString())?.let { result ->
81+
useTarget(project(":${result.groupValues[1]}"))
82+
}
83+
}
84+
}
85+
}
86+
7187
dependencies {
7288
implementation(libs.kotlinStdLib)
7389
implementation(libs.junitJupiterApi)
@@ -87,7 +103,6 @@ dependencies {
87103
androidTestImplementation(libs.junitJupiterParams)
88104
androidTestImplementation(libs.espressoCore)
89105
androidTestRuntimeOnly(project(":runner"))
90-
androidTestRuntimeOnly(libs.junitJupiterEngine)
91106

92107
testImplementation(project(":testutil"))
93108
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package de.mannodermaus.junit5
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Tag
5+
import org.junit.jupiter.api.Test
6+
7+
class TaggedTests {
8+
@Test
9+
fun includedTest() {
10+
}
11+
12+
@Tag("nope")
13+
@Test
14+
fun taggedTestDisabledOnMethodLevel() {
15+
assertEquals(5, 2 + 2)
16+
}
17+
}
18+
19+
@Tag("nope")
20+
class TaggedTestsDisabledOnClassLevel {
21+
@Test
22+
fun excludedTest() {
23+
assertEquals(5, 2 + 2)
24+
}
25+
}

instrumentation/runner/src/main/kotlin/de/mannodermaus/junit5/AndroidJUnit5Builder.kt

+5-11
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package de.mannodermaus.junit5
22

33
import android.util.Log
4-
import de.mannodermaus.junit5.internal.runners.JUnit5RunnerFactory.createJUnit5Runner
54
import de.mannodermaus.junit5.internal.LOG_TAG
6-
import de.mannodermaus.junit5.internal.extensions.jupiterTestMethods
5+
import de.mannodermaus.junit5.internal.runners.tryCreateJUnit5Runner
76
import org.junit.runner.Runner
87
import org.junit.runners.model.RunnerBuilder
98

@@ -60,16 +59,11 @@ public class AndroidJUnit5Builder : RunnerBuilder() {
6059
@Throws(Throwable::class)
6160
override fun runnerForClass(testClass: Class<*>): Runner? {
6261
try {
63-
if (!junit5Available) {
64-
return null
62+
return if (junit5Available) {
63+
tryCreateJUnit5Runner(testClass)
64+
} else {
65+
null
6566
}
66-
67-
if (testClass.jupiterTestMethods().isEmpty()) {
68-
return null
69-
}
70-
71-
return createJUnit5Runner(testClass)
72-
7367
} catch (e: NoClassDefFoundError) {
7468
Log.e(LOG_TAG, "JUnitPlatform not found on runtime classpath")
7569
throw IllegalStateException(

instrumentation/runner/src/main/kotlin/de/mannodermaus/junit5/internal/runners/AndroidJUnitPlatformRunnerListener.kt

+8-5
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,14 @@ internal class AndroidJUnitPlatformRunnerListener(
6363
) {
6464
val description = testTree.getDescription(testIdentifier)
6565
val status = testExecutionResult.status
66-
if (status == TestExecutionResult.Status.ABORTED) {
67-
notifier.fireTestAssumptionFailed(toFailure(testExecutionResult, description))
68-
} else if (status == TestExecutionResult.Status.FAILED) {
69-
notifier.fireTestFailure(toFailure(testExecutionResult, description))
70-
} else if (testIdentifier.isTest) {
66+
67+
if (testIdentifier.isTest) {
68+
if (status == TestExecutionResult.Status.ABORTED) {
69+
notifier.fireTestAssumptionFailed(toFailure(testExecutionResult, description))
70+
} else if (status == TestExecutionResult.Status.FAILED) {
71+
notifier.fireTestFailure(toFailure(testExecutionResult, description))
72+
}
73+
7174
notifier.fireTestFinished(description)
7275
}
7376
}

instrumentation/runner/src/main/kotlin/de/mannodermaus/junit5/internal/runners/AndroidJUnitPlatformTestTree.kt

+11-4
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,19 @@ internal class AndroidJUnitPlatformTestTree(
131131
parent: Description,
132132
testPlan: TestPlan
133133
) {
134-
val newDescription = createJUnit4Description(identifier, testPlan)
135-
parent.addChild(newDescription)
136-
descriptions[identifier] = newDescription
134+
val newDescription = createJUnit4Description(identifier, testPlan).also {
135+
descriptions[identifier] = it
136+
}
137+
138+
val newParent = if (identifier.isTest || identifier.isDynamicTest) {
139+
parent.addChild(newDescription)
140+
newDescription
141+
} else {
142+
parent
143+
}
137144

138145
testPlan.getChildren(identifier).forEach { child ->
139-
buildDescription(child, newDescription, testPlan)
146+
buildDescription(child, newParent, testPlan)
140147
}
141148
}
142149

instrumentation/runner/src/main/kotlin/de/mannodermaus/junit5/internal/runners/DummyJUnit5.kt

+10-5
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,19 @@ package de.mannodermaus.junit5.internal.runners
22

33
import android.util.Log
44
import de.mannodermaus.junit5.internal.LOG_TAG
5-
import de.mannodermaus.junit5.internal.extensions.jupiterTestMethods
65
import org.junit.runner.Description
76
import org.junit.runner.Runner
87
import org.junit.runner.notification.RunNotifier
8+
import java.lang.reflect.Method
99

1010
/**
1111
* Fake Runner that marks all JUnit 5 methods as ignored,
1212
* used for old devices without Java 8 capabilities.
1313
*/
14-
internal class DummyJUnit5(private val testClass: Class<*>) : Runner() {
15-
16-
private val testMethods = testClass.jupiterTestMethods()
14+
internal class DummyJUnit5(
15+
private val testClass: Class<*>,
16+
private val testMethods: Set<Method>,
17+
) : Runner() {
1718

1819
override fun run(notifier: RunNotifier) {
1920
Log.w(
@@ -27,5 +28,9 @@ internal class DummyJUnit5(private val testClass: Class<*>) : Runner() {
2728
}
2829
}
2930

30-
override fun getDescription(): Description = Description.createSuiteDescription(testClass)
31+
override fun getDescription(): Description = Description.createSuiteDescription(testClass).also {
32+
testMethods.forEach { method ->
33+
it.addChild(Description.createTestDescription(testClass, method.name))
34+
}
35+
}
3136
}
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
11
package de.mannodermaus.junit5.internal.runners
22

33
import android.os.Build
4+
import de.mannodermaus.junit5.internal.extensions.jupiterTestMethods
45
import org.junit.runner.Runner
56

6-
internal object JUnit5RunnerFactory {
7-
/**
8-
* Since we can't reference AndroidJUnit5 directly, use this factory for instantiation.
9-
*
10-
* On API 26 and above, delegate to the real implementation to drive JUnit 5 tests.
11-
* Below that however, they wouldn't work; for this case, delegate a dummy runner
12-
* which will highlight these tests as ignored.
13-
*/
14-
internal fun createJUnit5Runner(klass: Class<*>): Runner =
15-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
16-
AndroidJUnit5(klass)
17-
} else {
18-
DummyJUnit5(klass)
19-
}
7+
/**
8+
* Since we can't reference AndroidJUnit5 directly, use this factory for instantiation.
9+
*
10+
* On API 26 and above, delegate to the real implementation to drive JUnit 5 tests.
11+
* Below that however, they wouldn't work; for this case, delegate a dummy runner
12+
* which will highlight these tests as ignored.
13+
*/
14+
internal fun tryCreateJUnit5Runner(klass: Class<*>): Runner? {
15+
val testMethods = klass.jupiterTestMethods()
16+
17+
if (testMethods.isEmpty()) {
18+
return null
19+
}
20+
21+
val runner = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
22+
AndroidJUnit5(klass)
23+
} else {
24+
DummyJUnit5(klass, testMethods)
25+
}
26+
27+
// It's still possible for the runner to not be relevant to the test run,
28+
// which is related to how further filters are applied (e.g. via @Tag).
29+
// Only return the runner to the instrumentation if it has any tests to contribute,
30+
// otherwise there would be a mismatch between the number of test classes reported
31+
// to Android, and the number of test classes actually tested with JUnit 5 (ref #298)
32+
return runner.takeIf(Runner::hasExecutableTests)
2033
}
34+
35+
private fun Runner.hasExecutableTests() =
36+
this.description.children.isNotEmpty()
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,71 @@
11
package de.mannodermaus.junit5
22

3+
import android.os.Build
34
import com.google.common.truth.Truth.assertThat
4-
import org.junit.jupiter.api.Test
5-
import org.junit.jupiter.params.ParameterizedTest
6-
import org.junit.jupiter.params.provider.ValueSource
5+
import de.mannodermaus.junit5.testutil.AndroidBuildUtils.withApiLevel
6+
import de.mannodermaus.junit5.testutil.AndroidBuildUtils.withMockedInstrumentation
7+
import org.junit.jupiter.api.DynamicContainer.dynamicContainer
8+
import org.junit.jupiter.api.DynamicNode
9+
import org.junit.jupiter.api.DynamicTest.dynamicTest
10+
import org.junit.jupiter.api.TestFactory
711

812
class AndroidJUnit5BuilderTests {
913

1014
private val builder = AndroidJUnit5Builder()
1115

12-
@Test
13-
fun `no runner is created if class only contains top-level test methods`() {
16+
@TestFactory
17+
fun `no runner is created if class only contains top-level test methods`() = runTest(
18+
expectSuccess = false,
1419
// In Kotlin, a 'Kt'-suffixed class of top-level functions cannot be referenced
1520
// via the ::class syntax, so construct a reference to the class directly
16-
val cls = Class.forName(javaClass.packageName + ".TestClassesKt")
17-
18-
// Top-level tests should be discarded, so no runner must be created for this class
19-
runTest(cls, expectSuccess = false)
20-
}
21+
Class.forName(javaClass.packageName + ".TestClassesKt")
22+
)
2123

22-
@ValueSource(
23-
classes = [
24-
HasTest::class,
25-
HasRepeatedTest::class,
26-
HasTestFactory::class,
27-
HasTestTemplate::class,
28-
HasParameterizedTest::class,
29-
HasInnerClassWithTest::class,
30-
HasTaggedTest::class,
31-
HasInheritedTestsFromClass::class,
32-
HasInheritedTestsFromInterface::class,
33-
]
24+
@TestFactory
25+
fun `runner is created correctly for classes with valid jupiter test methods`() = runTest(
26+
expectSuccess = true,
27+
HasTest::class.java,
28+
HasRepeatedTest::class.java,
29+
HasTestFactory::class.java,
30+
HasTestTemplate::class.java,
31+
HasParameterizedTest::class.java,
32+
HasInnerClassWithTest::class.java,
33+
HasTaggedTest::class.java,
34+
HasInheritedTestsFromClass::class.java,
35+
HasInheritedTestsFromInterface::class.java,
3436
)
35-
@ParameterizedTest
36-
fun `runner is created correctly for classes with valid jupiter test methods`(cls: Class<*>) =
37-
runTest(cls, expectSuccess = true)
38-
39-
@ValueSource(
40-
classes = [
41-
DoesntHaveTestMethods::class,
42-
HasJUnit4Tests::class,
43-
kotlin.time.Duration::class,
44-
]
37+
38+
@TestFactory
39+
fun `no runner is created if class has no jupiter test methods`() = runTest(
40+
expectSuccess = false,
41+
DoesntHaveTestMethods::class.java,
42+
HasJUnit4Tests::class.java,
43+
kotlin.time.Duration::class.java,
4544
)
46-
@ParameterizedTest
47-
fun `no runner is created if class has no jupiter test methods`(cls: Class<*>) =
48-
runTest(cls, expectSuccess = false)
4945

5046
/* Private */
5147

52-
private fun runTest(cls: Class<*>, expectSuccess: Boolean) {
53-
val runner = builder.runnerForClass(cls)
54-
if (expectSuccess) {
55-
assertThat(runner).isNotNull()
56-
} else {
57-
assertThat(runner).isNull()
48+
private fun runTest(expectSuccess: Boolean, vararg classes: Class<*>): List<DynamicNode> {
49+
// Generate a test container for each given class,
50+
// then create two sub-variants for testing both DummyJUnit5 and AndroidJUnit5
51+
return classes.map { cls ->
52+
dynamicContainer(
53+
/* displayName = */ cls.name,
54+
/* dynamicNodes = */ setOf(Build.VERSION_CODES.M, Build.VERSION_CODES.TIRAMISU).map { apiLevel ->
55+
dynamicTest("API Level $apiLevel") {
56+
withMockedInstrumentation {
57+
withApiLevel(apiLevel) {
58+
val runner = builder.runnerForClass(cls)
59+
if (expectSuccess) {
60+
assertThat(runner).isNotNull()
61+
} else {
62+
assertThat(runner).isNull()
63+
}
64+
}
65+
}
66+
}
67+
}
68+
)
5869
}
5970
}
6071
}

0 commit comments

Comments
 (0)