From d23cf110455502ff7757af2a9634012610440890 Mon Sep 17 00:00:00 2001 From: Nick-VdP Date: Thu, 30 Apr 2026 14:48:20 +0200 Subject: [PATCH] Allow users to configure which annotations/superclasses identify test methods Selfie's GC logic used hardcoded lists of test annotations and superclasses to determine which snapshots are stale. Move these into overridable `testAnnotations` and `testSuperclasses` properties on `SelfieSettingsAPI` so users with custom composed annotations or Spec-like base classes can register them. --- .../com/diffplug/selfie/junit5/SelfieGC.kt | 40 ++---- .../selfie/junit5/SelfieSettingsAPI.kt | 27 +++- .../selfie/junit5/SnapshotFileLayoutJUnit5.kt | 20 ++- .../selfie/junit5/SnapshotSystemJUnit5.kt | 9 +- .../selfie/junit5/CustomAnnotationGCTest.kt | 117 ++++++++++++++++++ .../test/kotlin/undertest/junit5/MyTest.kt | 4 + .../junit5/SelfieSettingsWithMyTest.kt | 8 ++ .../junit5/UT_CustomAnnotationGCTest.kt | 15 +++ 8 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 jvm/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/CustomAnnotationGCTest.kt create mode 100644 jvm/undertest-junit5/src/test/kotlin/undertest/junit5/MyTest.kt create mode 100644 jvm/undertest-junit5/src/test/kotlin/undertest/junit5/SelfieSettingsWithMyTest.kt create mode 100644 jvm/undertest-junit5/src/test/kotlin/undertest/junit5/UT_CustomAnnotationGCTest.kt diff --git a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieGC.kt b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieGC.kt index dce505e40..317fc246f 100644 --- a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieGC.kt +++ b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieGC.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023-2025 DiffPlug + * Copyright (C) 2023-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,33 +18,6 @@ package com.diffplug.selfie.junit5 import com.diffplug.selfie.ArrayMap import com.diffplug.selfie.guts.WithinTestGC -/** Search for any test annotation classes which are present on the classpath. */ -private val testAnnotations = - listOf( - "org.junit.jupiter.api.Test", // junit5, - "org.junit.jupiter.api.TestFactory", // junit5, - "org.junit.jupiter.params.ParameterizedTest", - "org.junit.Test" // junit4 - ) - .mapNotNull { - try { - Class.forName(it).asSubclass(Annotation::class.java) - } catch (e: ClassNotFoundException) { - null - } - } -private val testSuperclasses = - listOf( - "io.kotest.core.spec.Spec", // kotest4+ - ) - .mapNotNull { - try { - Class.forName(it) - } catch (e: ClassNotFoundException) { - null - } - } - /** * Searches the whole snapshot directory, finds all the `.ss` files, and prunes any which don't have * matching test files anymore. @@ -54,21 +27,21 @@ internal fun findStaleSnapshotFiles(layout: SnapshotFileLayoutJUnit5): List testAnnotations.any { method.isAnnotationPresent(it) } } + .any { method -> layout.resolvedTestAnnotations.any { method.isAnnotationPresent(it) } } return hasTestAnnotations } catch (e: ClassNotFoundException) { // class doesn't exist, so it's definitely stale @@ -78,9 +51,10 @@ private fun classExistsAndHasTests(key: String): Boolean { internal fun findTestMethodsThatDidntRun( className: String, testsThatRan: ArrayMap, + layout: SnapshotFileLayoutJUnit5, ): Sequence = generateSequence(Class.forName(className)) { it.superclass } .flatMap { it.declaredMethods.asSequence() } .filter { method -> !testsThatRan.containsKey(method.name) } - .filter { method -> testAnnotations.any { method.isAnnotationPresent(it) } } + .filter { method -> layout.resolvedTestAnnotations.any { method.isAnnotationPresent(it) } } .map { it.name } diff --git a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieSettingsAPI.kt b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieSettingsAPI.kt index 1e02899ac..546d52168 100644 --- a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieSettingsAPI.kt +++ b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieSettingsAPI.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023-2024 DiffPlug + * Copyright (C) 2023-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -109,6 +109,31 @@ open class SelfieSettingsAPI { open val javaDontUseTripleQuoteLiterals: Boolean get() = false + /** + * The fully-qualified class names of annotations that mark a method as a test method. Selfie uses + * this list to determine which snapshots are stale. + * + * Override this property to add custom test annotations or replace the defaults entirely. The + * default list includes JUnit 5 and JUnit 4 test annotations. + */ + open val testAnnotations: List + get() = + listOf( + "org.junit.jupiter.api.Test", + "org.junit.jupiter.api.TestFactory", + "org.junit.jupiter.params.ParameterizedTest", + "org.junit.Test") + + /** + * The fully-qualified class names of superclasses whose subclasses are considered test classes. + * Selfie uses this list to determine which snapshot files are stale. + * + * Override this property to add custom test superclasses or replace the defaults entirely. The + * default list includes Kotest's `Spec`. + */ + open val testSuperclasses: List + get() = listOf("io.kotest.core.spec.Spec") + internal companion object { private val STANDARD_DIRS = listOf( diff --git a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotFileLayoutJUnit5.kt b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotFileLayoutJUnit5.kt index e620fcdff..63fd431b2 100644 --- a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotFileLayoutJUnit5.kt +++ b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotFileLayoutJUnit5.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023-2025 DiffPlug + * Copyright (C) 2023-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,6 +28,24 @@ class SnapshotFileLayoutJUnit5(settings: SelfieSettingsAPI, override val fs: FS) AtomicReference( if (settings is SelfieSettingsSmuggleError) settings.error else null) internal val settings = settings + internal val resolvedTestAnnotations: List> by lazy { + settings.testAnnotations.mapNotNull { + try { + Class.forName(it).asSubclass(Annotation::class.java) + } catch (e: ClassNotFoundException) { + null + } + } + } + internal val resolvedTestSuperclasses: List> by lazy { + settings.testSuperclasses.mapNotNull { + try { + Class.forName(it) + } catch (e: ClassNotFoundException) { + null + } + } + } override val rootFolder: TypedPath by lazy { TypedPath.ofFolder(settings.rootFolder.absolutePath) } diff --git a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotSystemJUnit5.kt b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotSystemJUnit5.kt index a0358d0d5..23868ecbe 100644 --- a/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotSystemJUnit5.kt +++ b/jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotSystemJUnit5.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023-2025 DiffPlug + * Copyright (C) 2023-2026 DiffPlug * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,6 +45,7 @@ internal object FSJava : FS { override fun fileWriteBinary(typedPath: TypedPath, content: ByteArray) = typedPath.toPath().writeBytes(content) override fun fileReadBinary(typedPath: TypedPath) = typedPath.toPath().readBytes() + /** Walks the files (not directories) which are children and grandchildren of the given path. */ override fun fileWalk(typedPath: TypedPath, walk: (Sequence) -> T): T = Files.walk(typedPath.toPath()).use { paths -> @@ -192,6 +193,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN system.startThreadLocal(this, test) } } + /** * Stops assigning this thread to store snapshots within this file at `test`, and if successful * marks that all other snapshots of this test can be pruned. A single test can be run multiple @@ -216,7 +218,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN if (file != null) { val staleSnapshotIndices = WithinTestGC.findStaleSnapshotsWithin( - file.snapshots, tests, findTestMethodsThatDidntRun(className, tests)) + file.snapshots, tests, findTestMethodsThatDidntRun(className, tests, system.layout)) if (staleSnapshotIndices.isNotEmpty() || file.wasSetAtTestTime) { file.removeAllIndices(staleSnapshotIndices) val snapshotPath = system.layout.snapshotPathForClass(className) @@ -232,7 +234,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN } } else { // we never read or wrote to the file - val everyTestInClassRan = findTestMethodsThatDidntRun(className, tests).none() + val everyTestInClassRan = findTestMethodsThatDidntRun(className, tests, system.layout).none() val isStale = everyTestInClassRan && success && tests.values.all { it.succeededAndUsedNoSnapshots() } if (isStale) { @@ -241,6 +243,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN } } } + // the methods below are called from the test thread for I/O on snapshots fun keep(test: String, suffixOrAll: String?) { assertNotTerminated() diff --git a/jvm/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/CustomAnnotationGCTest.kt b/jvm/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/CustomAnnotationGCTest.kt new file mode 100644 index 000000000..06e093940 --- /dev/null +++ b/jvm/selfie-runner-junit5/src/test/kotlin/com/diffplug/selfie/junit5/CustomAnnotationGCTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 DiffPlug + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.diffplug.selfie.junit5 + +import kotlin.test.Test +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.TestMethodOrder +import org.junitpioneer.jupiter.DisableIfTestFails + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +@DisableIfTestFails +class CustomAnnotationGCTest : HarnessJUnit() { + + @Test @Order(1) + fun setup() { + ut_snapshot().deleteIfExists() + ut_snapshot().assertDoesNotExist() + } + + @Test @Order(2) + fun writeBothSnapshots() { + gradleWriteSS() + ut_snapshot() + .assertContent( + """ + ╔═ withCustomAnnotation ═╗ + custom + ╔═ withStandardAnnotation ═╗ + standard + ╔═ [end of file] ═╗ + + """ + .trimIndent()) + } + + @Test @Order(3) + fun defaultSettingsPrunesCustomAnnotationSnapshot() { + // Run only withStandardAnnotation. Selfie doesn't recognize @MyTest by default. + // Its snapshot has no gc entry and is treated as an orphan → pruned. + runOnlyMethod = "withStandardAnnotation" + gradleWriteSS() + runOnlyMethod = null + ut_snapshot() + .assertContent( + """ + ╔═ withStandardAnnotation ═╗ + standard + ╔═ [end of file] ═╗ + + """ + .trimIndent()) + } + + @Test @Order(4) + fun writeBothSnapshotsAgain() { + gradleWriteSS() + ut_snapshot() + .assertContent( + """ + ╔═ withCustomAnnotation ═╗ + custom + ╔═ withStandardAnnotation ═╗ + standard + ╔═ [end of file] ═╗ + + """ + .trimIndent()) + } + + @Test @Order(5) + fun customSettingsPreservesCustomAnnotationSnapshot() { + // Run only withStandardAnnotation. With SelfieSettingsWithMyTest, selfie knows @MyTest is a + // test annotation. Its snapshot is kept. + runOnlyMethod = "withStandardAnnotation" + gradlew( + "test", + "-PunderTest=true", + "-Pselfie=overwrite", + "-Pselfie.settings=undertest.junit5.SelfieSettingsWithMyTest") + ?.let { + throw AssertionError( + "Expected overwrite with custom settings to succeed, but it failed", it) + } + runOnlyMethod = null + ut_snapshot() + .assertContent( + """ + ╔═ withCustomAnnotation ═╗ + custom + ╔═ withStandardAnnotation ═╗ + standard + ╔═ [end of file] ═╗ + + """ + .trimIndent()) + } + + @Test @Order(6) + fun cleanup() { + ut_snapshot().deleteIfExists() + ut_snapshot().assertDoesNotExist() + } +} diff --git a/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/MyTest.kt b/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/MyTest.kt new file mode 100644 index 000000000..6637e3855 --- /dev/null +++ b/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/MyTest.kt @@ -0,0 +1,4 @@ +package undertest.junit5 + +@org.junit.jupiter.api.Test +annotation class MyTest diff --git a/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/SelfieSettingsWithMyTest.kt b/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/SelfieSettingsWithMyTest.kt new file mode 100644 index 000000000..9fa983557 --- /dev/null +++ b/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/SelfieSettingsWithMyTest.kt @@ -0,0 +1,8 @@ +package undertest.junit5 + +import com.diffplug.selfie.junit5.SelfieSettingsAPI + +class SelfieSettingsWithMyTest : SelfieSettingsAPI() { + override val testAnnotations: List + get() = super.testAnnotations + "undertest.junit5.MyTest" +} diff --git a/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/UT_CustomAnnotationGCTest.kt b/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/UT_CustomAnnotationGCTest.kt new file mode 100644 index 000000000..f86714245 --- /dev/null +++ b/jvm/undertest-junit5/src/test/kotlin/undertest/junit5/UT_CustomAnnotationGCTest.kt @@ -0,0 +1,15 @@ +package undertest.junit5 +// spotless:off +import com.diffplug.selfie.Selfie.expectSelfie +import org.junit.jupiter.api.Test +// spotless:on + +class UT_CustomAnnotationGCTest { + @Test fun withStandardAnnotation() { + expectSelfie("standard").toMatchDisk() + } + + @MyTest fun withCustomAnnotation() { + expectSelfie("custom").toMatchDisk() + } +}