Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add native support for JUnit 4 #515

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions .github/workflows/jvm-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: gradle caching
uses: gradle/actions/setup-gradle@v4
- run: git fetch origin main
# workaround parallel test suite bugs
- run: ./gradlew :selfie-runner-junit5:test --no-configuration-cache
- run: ./gradlew :selfie-runner-junit4:test --no-configuration-cache
- run: ./gradlew build --no-configuration-cache
- name: junit result
uses: mikepenz/action-junit-report@v4
Expand Down
17 changes: 17 additions & 0 deletions jvm/selfie-runner-junit4/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# selfie-runner-junit4

JUnit 4 test runner for [Selfie](https://selfie.dev).

## Status

This module is currently a placeholder. Coroutine support is not yet implemented for JUnit4.

## Usage

Add the dependency:

```groovy
testImplementation 'com.diffplug.selfie:selfie-runner-junit4:LATEST'
```

The runner will automatically detect and run for JUnit4 tests. No additional configuration is needed.
31 changes: 31 additions & 0 deletions jvm/selfie-runner-junit4/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
}
repositories {
mavenCentral()
}
ext {
maven_name = 'selfie-runner-junit4'
maven_desc = 'JUnit 4 test runner for Selfie'
}

apply from: 干.file('base/changelog.gradle')
apply from: rootProject.file('gradle/spotless.gradle')
apply plugin: 'java-library'
dependencies {
api project(':selfie-lib')
implementation 'junit:junit:4.13.2'
compileOnly "io.kotest:kotest-framework-engine:$ver_KOTEST"

testImplementation "com.squareup.okio:okio:$ver_OKIO"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$ver_KOTLIN_TEST"
testImplementation "io.kotest:kotest-assertions-core:$ver_KOTEST"
testImplementation gradleTestKit()
}
test {
useJUnit()
getInputs().dir('../undertest-junit4/src/test')
}
// it all needs to get published and formatted
apply from: 干.file('base/maven.gradle')
apply from: 干.file('base/sonatype.gradle')
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Copyright (C) 2024-2025 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.junit4

import java.io.File

open class SelfieSettingsAPI {
open val allowMultipleEquivalentWritesToOneLocation: Boolean
get() = true

open val snapshotFolderName: String?
get() = null

open val rootFolder: File
get() {
val userDir = File(System.getProperty("user.dir"))
for (standardDir in STANDARD_DIRS) {
val candidate = userDir.resolve(standardDir)
if (candidate.isDirectory) {
return candidate
}
}
throw AssertionError(
"Could not find a standard test directory, 'user.dir' is equal to $userDir, looked in $STANDARD_DIRS")
}

open val otherSourceRoots: List<File>
get() {
return buildList {
val rootDir = rootFolder
val userDir = File(System.getProperty("user.dir"))
for (standardDir in STANDARD_DIRS) {
val candidate = userDir.resolve(standardDir)
if (candidate.isDirectory && candidate != rootDir) {
add(candidate)
}
}
}
}

open val javaDontUseTripleQuoteLiterals: Boolean
get() = false

companion object {
private val STANDARD_DIRS =
listOf(
"src/test/java",
"src/test/kotlin",
"src/test/groovy",
"src/test/scala",
"src/test/resources")
internal fun initialize(): SelfieSettingsAPI {
try {
val settings = System.getProperty("selfie.settings")
if (settings != null && settings.isNotBlank()) {
try {
return instantiate(Class.forName(settings))
} catch (e: ClassNotFoundException) {
throw Error(
"The system property selfie.settings was set to $settings, but that class could not be found.",
e)
}
}
try {
return instantiate(Class.forName("selfie.SelfieSettings"))
} catch (e: ClassNotFoundException) {
return SelfieSettingsAPI()
}
} catch (e: Throwable) {
return SelfieSettingsSmuggleError(e)
}
}
private fun instantiate(clazz: Class<*>): SelfieSettingsAPI {
try {
return clazz.getDeclaredConstructor().newInstance() as SelfieSettingsAPI
} catch (e: InstantiationException) {
throw AssertionError(
"Unable to instantiate ${clazz.name}, is it abstract? Does it require arguments?", e)
}
}
}
}

internal class SelfieSettingsSmuggleError(val error: Throwable) : SelfieSettingsAPI()
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2024-2025 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.junit4

import org.junit.runner.Description
import org.junit.runner.notification.Failure
import org.junit.runner.notification.RunListener

/** This is automatically registered at runtime thanks to `META-INF/services`. */
class SelfieTestListener : RunListener() {
private val system = SnapshotSystemJUnit4
private val activeTests = mutableMapOf<String, Boolean>()
override fun testStarted(description: Description) {
try {
system.testListenerRunning.set(true)
val className = description.className
val testName = description.methodName
val key = "$className#$testName"
activeTests[key] = true
system.forClass(className).startTest(testName, false)
} catch (e: Throwable) {
system.smuggledError.set(e)
}
}
override fun testFinished(description: Description) {
try {
val className = description.className
val testName = description.methodName
val key = "$className#$testName"
val wasSuccessful = activeTests.remove(key) ?: false
system.forClass(className).finishedTestWithSuccess(testName, false, wasSuccessful)
} catch (e: Throwable) {
system.smuggledError.set(e)
}
}
override fun testFailure(failure: Failure) {
val description = failure.description
val key = "${description.className}#${description.methodName}"
activeTests[key] = false
}
override fun testRunFinished(result: org.junit.runner.Result) {
system.finishedAllTests()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2024-2025 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.junit4

import com.diffplug.selfie.guts.TypedPath
import java.util.concurrent.atomic.AtomicReference

class SnapshotFileLayoutJUnit4(val className: String) {
val smuggledError = AtomicReference<Throwable>()
private val extension: String = ".ss"
fun snapshotPathForClass(className: String): TypedPath {
val lastDot = className.lastIndexOf('.')
val classFolder: TypedPath
val filename: String
if (lastDot == -1) {
classFolder = rootFolder
filename = className + extension
} else {
classFolder = rootFolder.resolveFolder(className.substring(0, lastDot).replace('.', '/'))
filename = className.substring(lastDot + 1) + extension
}
val parentFolder = snapshotFolderName?.let { classFolder.resolveFolder(it) } ?: classFolder
return parentFolder.resolveFile(filename)
}
fun incrementContainers() {
TODO("Coroutine support not implemented for JUnit4")
}
fun startTest(testName: String, isContainer: Boolean) {
checkForSmuggledError()
// Basic test tracking without coroutine support
}
fun finishedTestWithSuccess(testName: String, isContainer: Boolean, wasSuccessful: Boolean) {
checkForSmuggledError()
// Basic test completion tracking without coroutine support
}
private fun checkForSmuggledError() {
smuggledError.get()?.let { throw it }
}

companion object {
private val rootFolder = TypedPath.ofFolder(System.getProperty("user.dir"))
private val snapshotFolderName: String? = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2024-2025 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.junit4

import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference

object SnapshotSystemJUnit4 {
val testListenerRunning = AtomicBoolean(false)
val smuggledError = AtomicReference<Throwable>()
private val fileLayouts = ConcurrentHashMap<String, SnapshotFileLayoutJUnit4>()
fun forClass(className: String): SnapshotFileLayoutJUnit4 {
return fileLayouts.computeIfAbsent(className) { SnapshotFileLayoutJUnit4(it) }
}
fun finishedAllTests() {
testListenerRunning.set(false)
fileLayouts.clear()
}
fun checkForSmuggledError() {
smuggledError.get()?.let { throw it }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.diffplug.selfie.junit4.SelfieTestListener
2 changes: 2 additions & 0 deletions jvm/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ blowdryerSetup {
// Core library
include 'selfie-lib'
// JUnit 4 & 5
include 'selfie-runner-junit4'
include 'selfie-runner-junit5'
include 'undertest-junit4'
include 'undertest-junit5'
include 'undertest-junit5-kotest'
include 'undertest-junit-vintage'
Expand Down
37 changes: 37 additions & 0 deletions jvm/undertest-junit4/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
plugins {
id 'org.jetbrains.kotlin.jvm'
id 'com.diffplug.spotless'
}
repositories {
mavenCentral()
}
spotless {
enforceCheck false
kotlin {
target 'src/**/*.kt'
toggleOffOn()
licenseHeader ''
ktfmt()
replaceRegex("test one-liner", "@Test\n(\\s*)fun ", "@Test fun ")
replaceRegex("test harness comments", "\n(\\s)*//", "\n//")
}
}

dependencies {
testImplementation project(':selfie-runner-junit4')
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$ver_KOTLIN_TEST"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
testImplementation "junit:junit:4.13.2"
}
// this project is just a test environment for a different project
test {
useJUnit()
enabled = findProperty("underTest") == "true"

testLogging.showStandardStreams = true
// the snapshots are both output and input, for this harness best if the test just always runs
outputs.upToDateWhen { false }
// defaults to 'write'
systemProperty 'selfie', findProperty('selfie')
systemProperty 'selfie.settings', findProperty('selfie.settings')
}
1 change: 1 addition & 0 deletions jvm/undertest-junit4/src/test/java/duplicate_tobefile.data
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
twins
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
╔═ git_makes_carriage_returns_unrepresentable ═╗
hard
to
preserve
this

╔═ [end of file] ═╗
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
╔═ shouldPass ═╗
twins
╔═ [end of file] ═╗
Loading