Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -54,21 +27,21 @@ internal fun findStaleSnapshotFiles(layout: SnapshotFileLayoutJUnit5): List<Stri
walk
.filter { it.name.endsWith(layout.extension) }
.map { layout.subpathToClassname(layout.rootFolder.relativize(it)) }
.filter { !classExistsAndHasTests(it) }
.filter { !classExistsAndHasTests(it, layout) }
.toMutableList()
}
}
private fun classExistsAndHasTests(key: String): Boolean {
private fun classExistsAndHasTests(key: String, layout: SnapshotFileLayoutJUnit5): Boolean {
try {
val clazz = Class.forName(key)
val isTestClass = testSuperclasses.any { it.isAssignableFrom(clazz) }
val isTestClass = layout.resolvedTestSuperclasses.any { it.isAssignableFrom(clazz) }
if (isTestClass) {
return true
}
val hasTestAnnotations =
generateSequence(clazz) { it.superclass }
.flatMap { it.declaredMethods.asSequence() }
.any { method -> 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
Expand All @@ -78,9 +51,10 @@ private fun classExistsAndHasTests(key: String): Boolean {
internal fun findTestMethodsThatDidntRun(
className: String,
testsThatRan: ArrayMap<String, WithinTestGC>,
layout: SnapshotFileLayoutJUnit5,
): Sequence<String> =
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 }
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<String>
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<String>
get() = listOf("io.kotest.core.spec.Spec")

internal companion object {
private val STANDARD_DIRS =
listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -28,6 +28,24 @@ class SnapshotFileLayoutJUnit5(settings: SelfieSettingsAPI, override val fs: FS)
AtomicReference<Throwable?>(
if (settings is SelfieSettingsSmuggleError) settings.error else null)
internal val settings = settings
internal val resolvedTestAnnotations: List<Class<out Annotation>> by lazy {
settings.testAnnotations.mapNotNull {
try {
Class.forName(it).asSubclass(Annotation::class.java)
} catch (e: ClassNotFoundException) {
null
}
}
}
internal val resolvedTestSuperclasses: List<Class<*>> by lazy {
settings.testSuperclasses.mapNotNull {
try {
Class.forName(it)
} catch (e: ClassNotFoundException) {
null
}
}
}
override val rootFolder: TypedPath by lazy {
TypedPath.ofFolder(settings.rootFolder.absolutePath)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 <T> fileWalk(typedPath: TypedPath, walk: (Sequence<TypedPath>) -> T): T =
Files.walk(typedPath.toPath()).use { paths ->
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package undertest.junit5

@org.junit.jupiter.api.Test
annotation class MyTest
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package undertest.junit5

import com.diffplug.selfie.junit5.SelfieSettingsAPI

class SelfieSettingsWithMyTest : SelfieSettingsAPI() {
override val testAnnotations: List<String>
get() = super.testAnnotations + "undertest.junit5.MyTest"
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading