Skip to content

Commit 1ed9093

Browse files
committed
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. Class resolution is cached lazily on `SnapshotFileLayoutJUnit5` and threaded through to `findTestMethodsThatDidntRun` and `classExistsAndHasTests`.
1 parent 61526f5 commit 1ed9093

8 files changed

Lines changed: 202 additions & 38 deletions

File tree

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2025 DiffPlug
2+
* Copyright (C) 2023-2026 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,33 +18,6 @@ package com.diffplug.selfie.junit5
1818
import com.diffplug.selfie.ArrayMap
1919
import com.diffplug.selfie.guts.WithinTestGC
2020

21-
/** Search for any test annotation classes which are present on the classpath. */
22-
private val testAnnotations =
23-
listOf(
24-
"org.junit.jupiter.api.Test", // junit5,
25-
"org.junit.jupiter.api.TestFactory", // junit5,
26-
"org.junit.jupiter.params.ParameterizedTest",
27-
"org.junit.Test" // junit4
28-
)
29-
.mapNotNull {
30-
try {
31-
Class.forName(it).asSubclass(Annotation::class.java)
32-
} catch (e: ClassNotFoundException) {
33-
null
34-
}
35-
}
36-
private val testSuperclasses =
37-
listOf(
38-
"io.kotest.core.spec.Spec", // kotest4+
39-
)
40-
.mapNotNull {
41-
try {
42-
Class.forName(it)
43-
} catch (e: ClassNotFoundException) {
44-
null
45-
}
46-
}
47-
4821
/**
4922
* Searches the whole snapshot directory, finds all the `.ss` files, and prunes any which don't have
5023
* matching test files anymore.
@@ -54,21 +27,21 @@ internal fun findStaleSnapshotFiles(layout: SnapshotFileLayoutJUnit5): List<Stri
5427
walk
5528
.filter { it.name.endsWith(layout.extension) }
5629
.map { layout.subpathToClassname(layout.rootFolder.relativize(it)) }
57-
.filter { !classExistsAndHasTests(it) }
30+
.filter { !classExistsAndHasTests(it, layout) }
5831
.toMutableList()
5932
}
6033
}
61-
private fun classExistsAndHasTests(key: String): Boolean {
34+
private fun classExistsAndHasTests(key: String, layout: SnapshotFileLayoutJUnit5): Boolean {
6235
try {
6336
val clazz = Class.forName(key)
64-
val isTestClass = testSuperclasses.any { it.isAssignableFrom(clazz) }
37+
val isTestClass = layout.resolvedTestSuperclasses.any { it.isAssignableFrom(clazz) }
6538
if (isTestClass) {
6639
return true
6740
}
6841
val hasTestAnnotations =
6942
generateSequence(clazz) { it.superclass }
7043
.flatMap { it.declaredMethods.asSequence() }
71-
.any { method -> testAnnotations.any { method.isAnnotationPresent(it) } }
44+
.any { method -> layout.resolvedTestAnnotations.any { method.isAnnotationPresent(it) } }
7245
return hasTestAnnotations
7346
} catch (e: ClassNotFoundException) {
7447
// class doesn't exist, so it's definitely stale
@@ -78,9 +51,10 @@ private fun classExistsAndHasTests(key: String): Boolean {
7851
internal fun findTestMethodsThatDidntRun(
7952
className: String,
8053
testsThatRan: ArrayMap<String, WithinTestGC>,
54+
layout: SnapshotFileLayoutJUnit5,
8155
): Sequence<String> =
8256
generateSequence(Class.forName(className)) { it.superclass }
8357
.flatMap { it.declaredMethods.asSequence() }
8458
.filter { method -> !testsThatRan.containsKey(method.name) }
85-
.filter { method -> testAnnotations.any { method.isAnnotationPresent(it) } }
59+
.filter { method -> layout.resolvedTestAnnotations.any { method.isAnnotationPresent(it) } }
8660
.map { it.name }

jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SelfieSettingsAPI.kt

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2024 DiffPlug
2+
* Copyright (C) 2023-2026 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -109,6 +109,31 @@ open class SelfieSettingsAPI {
109109
open val javaDontUseTripleQuoteLiterals: Boolean
110110
get() = false
111111

112+
/**
113+
* The fully-qualified class names of annotations that mark a method as a test method. Selfie uses
114+
* this list to determine which snapshots are stale.
115+
*
116+
* Override this property to add custom test annotations or replace the defaults entirely. The
117+
* default list includes JUnit 5 and JUnit 4 test annotations.
118+
*/
119+
open val testAnnotations: List<String>
120+
get() =
121+
listOf(
122+
"org.junit.jupiter.api.Test",
123+
"org.junit.jupiter.api.TestFactory",
124+
"org.junit.jupiter.params.ParameterizedTest",
125+
"org.junit.Test")
126+
127+
/**
128+
* The fully-qualified class names of superclasses whose subclasses are considered test classes.
129+
* Selfie uses this list to determine which snapshot files are stale.
130+
*
131+
* Override this property to add custom test superclasses or replace the defaults entirely. The
132+
* default list includes Kotest's `Spec`.
133+
*/
134+
open val testSuperclasses: List<String>
135+
get() = listOf("io.kotest.core.spec.Spec")
136+
112137
internal companion object {
113138
private val STANDARD_DIRS =
114139
listOf(

jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotFileLayoutJUnit5.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2025 DiffPlug
2+
* Copyright (C) 2023-2026 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -28,6 +28,24 @@ class SnapshotFileLayoutJUnit5(settings: SelfieSettingsAPI, override val fs: FS)
2828
AtomicReference<Throwable?>(
2929
if (settings is SelfieSettingsSmuggleError) settings.error else null)
3030
internal val settings = settings
31+
internal val resolvedTestAnnotations: List<Class<out Annotation>> by lazy {
32+
settings.testAnnotations.mapNotNull {
33+
try {
34+
Class.forName(it).asSubclass(Annotation::class.java)
35+
} catch (e: ClassNotFoundException) {
36+
null
37+
}
38+
}
39+
}
40+
internal val resolvedTestSuperclasses: List<Class<*>> by lazy {
41+
settings.testSuperclasses.mapNotNull {
42+
try {
43+
Class.forName(it)
44+
} catch (e: ClassNotFoundException) {
45+
null
46+
}
47+
}
48+
}
3149
override val rootFolder: TypedPath by lazy {
3250
TypedPath.ofFolder(settings.rootFolder.absolutePath)
3351
}

jvm/selfie-runner-junit5/src/main/kotlin/com/diffplug/selfie/junit5/SnapshotSystemJUnit5.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023-2025 DiffPlug
2+
* Copyright (C) 2023-2026 DiffPlug
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -45,6 +45,7 @@ internal object FSJava : FS {
4545
override fun fileWriteBinary(typedPath: TypedPath, content: ByteArray) =
4646
typedPath.toPath().writeBytes(content)
4747
override fun fileReadBinary(typedPath: TypedPath) = typedPath.toPath().readBytes()
48+
4849
/** Walks the files (not directories) which are children and grandchildren of the given path. */
4950
override fun <T> fileWalk(typedPath: TypedPath, walk: (Sequence<TypedPath>) -> T): T =
5051
Files.walk(typedPath.toPath()).use { paths ->
@@ -192,6 +193,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
192193
system.startThreadLocal(this, test)
193194
}
194195
}
196+
195197
/**
196198
* Stops assigning this thread to store snapshots within this file at `test`, and if successful
197199
* 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
216218
if (file != null) {
217219
val staleSnapshotIndices =
218220
WithinTestGC.findStaleSnapshotsWithin(
219-
file.snapshots, tests, findTestMethodsThatDidntRun(className, tests))
221+
file.snapshots, tests, findTestMethodsThatDidntRun(className, tests, system.layout))
220222
if (staleSnapshotIndices.isNotEmpty() || file.wasSetAtTestTime) {
221223
file.removeAllIndices(staleSnapshotIndices)
222224
val snapshotPath = system.layout.snapshotPathForClass(className)
@@ -232,7 +234,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
232234
}
233235
} else {
234236
// we never read or wrote to the file
235-
val everyTestInClassRan = findTestMethodsThatDidntRun(className, tests).none()
237+
val everyTestInClassRan = findTestMethodsThatDidntRun(className, tests, system.layout).none()
236238
val isStale =
237239
everyTestInClassRan && success && tests.values.all { it.succeededAndUsedNoSnapshots() }
238240
if (isStale) {
@@ -241,6 +243,7 @@ internal class SnapshotFileProgress(val system: SnapshotSystemJUnit5, val classN
241243
}
242244
}
243245
}
246+
244247
// the methods below are called from the test thread for I/O on snapshots
245248
fun keep(test: String, suffixOrAll: String?) {
246249
assertNotTerminated()
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright (C) 2026 DiffPlug
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.diffplug.selfie.junit5
17+
18+
import kotlin.test.Test
19+
import org.junit.jupiter.api.MethodOrderer
20+
import org.junit.jupiter.api.Order
21+
import org.junit.jupiter.api.TestMethodOrder
22+
import org.junitpioneer.jupiter.DisableIfTestFails
23+
24+
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
25+
@DisableIfTestFails
26+
class CustomAnnotationGCTest : HarnessJUnit() {
27+
28+
@Test @Order(1)
29+
fun setup() {
30+
ut_snapshot().deleteIfExists()
31+
ut_snapshot().assertDoesNotExist()
32+
}
33+
34+
@Test @Order(2)
35+
fun writeBothSnapshots() {
36+
gradleWriteSS()
37+
ut_snapshot()
38+
.assertContent(
39+
"""
40+
╔═ withCustomAnnotation ═╗
41+
custom
42+
╔═ withStandardAnnotation ═╗
43+
standard
44+
╔═ [end of file] ═╗
45+
46+
"""
47+
.trimIndent())
48+
}
49+
50+
@Test @Order(3)
51+
fun defaultSettingsPrunesCustomAnnotationSnapshot() {
52+
// Run only withStandardAnnotation. Selfie doesn't recognize @MyTest by default.
53+
// Its snapshot has no gc entry and is treated as an orphan → pruned.
54+
runOnlyMethod = "withStandardAnnotation"
55+
gradleWriteSS()
56+
runOnlyMethod = null
57+
ut_snapshot()
58+
.assertContent(
59+
"""
60+
╔═ withStandardAnnotation ═╗
61+
standard
62+
╔═ [end of file] ═╗
63+
64+
"""
65+
.trimIndent())
66+
}
67+
68+
@Test @Order(4)
69+
fun writeBothSnapshotsAgain() {
70+
gradleWriteSS()
71+
ut_snapshot()
72+
.assertContent(
73+
"""
74+
╔═ withCustomAnnotation ═╗
75+
custom
76+
╔═ withStandardAnnotation ═╗
77+
standard
78+
╔═ [end of file] ═╗
79+
80+
"""
81+
.trimIndent())
82+
}
83+
84+
@Test @Order(5)
85+
fun customSettingsPreservesCustomAnnotationSnapshot() {
86+
// Run only withStandardAnnotation. With SelfieSettingsWithMyTest, selfie knows @MyTest is a
87+
// test annotation. Its snapshot is kept.
88+
runOnlyMethod = "withStandardAnnotation"
89+
gradlew(
90+
"test",
91+
"-PunderTest=true",
92+
"-Pselfie=overwrite",
93+
"-Pselfie.settings=undertest.junit5.SelfieSettingsWithMyTest")
94+
?.let {
95+
throw AssertionError(
96+
"Expected overwrite with custom settings to succeed, but it failed", it)
97+
}
98+
runOnlyMethod = null
99+
ut_snapshot()
100+
.assertContent(
101+
"""
102+
╔═ withCustomAnnotation ═╗
103+
custom
104+
╔═ withStandardAnnotation ═╗
105+
standard
106+
╔═ [end of file] ═╗
107+
108+
"""
109+
.trimIndent())
110+
}
111+
112+
@Test @Order(6)
113+
fun cleanup() {
114+
ut_snapshot().deleteIfExists()
115+
ut_snapshot().assertDoesNotExist()
116+
}
117+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package undertest.junit5
2+
3+
@org.junit.jupiter.api.Test
4+
annotation class MyTest
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package undertest.junit5
2+
3+
import com.diffplug.selfie.junit5.SelfieSettingsAPI
4+
5+
class SelfieSettingsWithMyTest : SelfieSettingsAPI() {
6+
override val testAnnotations: List<String>
7+
get() = super.testAnnotations + "undertest.junit5.MyTest"
8+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package undertest.junit5
2+
// spotless:off
3+
import com.diffplug.selfie.Selfie.expectSelfie
4+
import org.junit.jupiter.api.Test
5+
// spotless:on
6+
7+
class UT_CustomAnnotationGCTest {
8+
@Test fun withStandardAnnotation() {
9+
expectSelfie("standard").toMatchDisk()
10+
}
11+
12+
@MyTest fun withCustomAnnotation() {
13+
expectSelfie("custom").toMatchDisk()
14+
}
15+
}

0 commit comments

Comments
 (0)