jvmMain.com.bkahlert.kommons.test.junit.DynamicTestBuilder.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of kommons-test Show documentation
Show all versions of kommons-test Show documentation
Kommons Test is a Kotlin Multiplatform Library to ease testing.
package com.bkahlert.kommons.test.junit
import com.bkahlert.kommons.test.KommonsTest
import com.bkahlert.kommons.test.junit.DynamicTestDisplayNameGenerator.FOR
import com.bkahlert.kommons.test.junit.DynamicTestDisplayNameGenerator.assertingDisplayName
import com.bkahlert.kommons.test.junit.DynamicTestDisplayNameGenerator.catchingDisplayName
import com.bkahlert.kommons.test.junit.DynamicTestDisplayNameGenerator.displayNameFor
import com.bkahlert.kommons.test.junit.DynamicTestDisplayNameGenerator.expectingDisplayName
import com.bkahlert.kommons.test.junit.DynamicTestDisplayNameGenerator.throwingDisplayName
import com.bkahlert.kommons.test.junit.Mode.CREATE
import com.bkahlert.kommons.test.junit.Mode.REPLACE
import com.bkahlert.kommons.test.junit.PathSource.Companion.sourceUri
import io.kotest.assertions.asClue
import io.kotest.assertions.assertionCounter
import io.kotest.assertions.failure
import io.kotest.assertions.withClue
import io.kotest.mpp.bestName
import org.junit.jupiter.api.DynamicContainer
import org.junit.jupiter.api.DynamicContainer.dynamicContainer
import org.junit.jupiter.api.DynamicNode
import org.junit.jupiter.api.DynamicTest.dynamicTest
import java.net.URI
import java.util.stream.Stream
import kotlin.coroutines.Continuation
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED
import kotlin.coroutines.intrinsics.createCoroutineUnintercepted
import kotlin.coroutines.intrinsics.suspendCoroutineUninterceptedOrReturn
import kotlin.coroutines.resume
import kotlin.reflect.KClass
import kotlin.streams.asStream
/** Assertions that can be applied to a subject. */
public typealias Assertions = (T) -> Unit
/** Builder that allows adding additional assertions using [it] or [that]. */
@JvmInline
public value class AssertionsBuilder(
/** Function that can be used to verify assertions passed to [it] or [that]. */
public val applyAssertions: suspend (Assertions) -> Unit,
) {
/** Specifies [assertions] with the subject in the receiver `this`. */
public suspend infix fun it(assertions: T.() -> Unit): Unit = applyAssertions(assertions)
/** Specifies [assertions] with the subject passed the single parameter `it`. */
public suspend infix fun that(assertions: (T) -> Unit): Unit = applyAssertions(assertions)
}
/** Builds tests with no subjects using a [TestsWithoutSubjectScope]. */
public fun testing(init: suspend TestsWithoutSubjectScope.() -> Unit): Stream =
buildTestNodeSequence(init) { DynamicTestsWithoutSubjectBuilder(it) }.asNonEmptyStream()
/** Scope for building tests (and test containers) with no subjects. */
public interface TestsWithoutSubjectScope {
/**
* Expects the subject returned by [action] to fulfil the
* [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expecting { } it { }`
*
* **Usage:** `expecting { } that { it. }`
*/
public suspend fun expecting(description: String? = null, action: () -> R): AssertionsBuilder
/**
* Expects the [Result] returned by [action] to fulfil the
* [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expectCatching { } it { }`
*
* **Usage:** `expectCatching { } that { it. }`
*/
public suspend fun expectCatching(action: () -> R): AssertionsBuilder>
/**
* Expects an exception [E] to be thrown when running [action]
* and to optionally fulfil the [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expectThrows { }`
*
* **Usage:** `expectThrows { } it { }`
*
* **Usage:** `expectThrows { } that { it. }`
*/
public suspend fun expectThrows(type: KClass, action: () -> Any?): AssertionsBuilder
}
/**
* Expects an exception [E] to be thrown when running [action]
* and to optionally fulfil the [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expectThrows { }`
*
* **Usage:** `expectThrows { } it { }`
*
* **Usage:** `expectThrows { } that { it. }`
*/
public suspend inline fun TestsWithoutSubjectScope.expectThrows(noinline action: () -> Any?): AssertionsBuilder =
expectThrows(E::class, action)
/** Builder for tests (and test containers) with no subjects. */
private class DynamicTestsWithoutSubjectBuilder(
val yieldNode: suspend (Mode, DynamicNode) -> Unit,
) : TestsWithoutSubjectScope {
override suspend fun expecting(description: String?, action: () -> R): AssertionsBuilder {
val caller = KommonsTest.locateCall()
val displayName = description ?: caller.expectingDisplayName(action)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { throw IllegalUsageException("expecting", caller.sourceUri) })
return AssertionsBuilder { assertions: Assertions ->
yieldNode(REPLACE, dynamicTest(displayName, caller.sourceUri) { action().asClue(assertions) })
}
}
override suspend fun expectCatching(action: () -> R): AssertionsBuilder> {
val caller = KommonsTest.locateCall()
val displayName = caller.catchingDisplayName(action)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { throw IllegalUsageException("expectCatching", caller.sourceUri) })
return AssertionsBuilder { assertions: Assertions> ->
yieldNode(REPLACE, dynamicTest(displayName, caller.sourceUri) { runCatching(action).asClue(assertions) })
}
}
override suspend fun expectThrows(type: KClass, action: () -> Any?): AssertionsBuilder {
val caller = KommonsTest.locateCall()
val displayName = throwingDisplayName(type)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { shouldThrow(type, action).asClue({}) })
return AssertionsBuilder { assertions: Assertions ->
yieldNode(REPLACE, dynamicTest(displayName, caller.sourceUri) { shouldThrow(type, action).asClue(assertions) })
}
}
}
/**
* Copy of [io.kotest.assertions.throwables.shouldThrow] that allows passing
* the [expectedExceptionClass] as a [KClass] instance.
*/
private fun shouldThrow(expectedExceptionClass: KClass, block: () -> Any?): T {
assertionCounter.inc()
val thrownThrowable = try {
block()
null // Can't throw failure here directly, as it would be caught by the catch clause, and it's an AssertionError, which is a special case
} catch (thrown: Throwable) {
thrown
}
@Suppress("UNCHECKED_CAST")
return when {
thrownThrowable == null -> {
throw failure("Expected exception ${expectedExceptionClass.bestName()} but no exception was thrown.")
}
expectedExceptionClass.isInstance(thrownThrowable) -> {
// This should be before `is AssertionError`. If the user is purposefully trying to verify `shouldThrow{}` this will take priority
thrownThrowable as T
}
thrownThrowable is AssertionError -> {
throw thrownThrowable
}
else -> {
throw failure(
"Expected exception ${expectedExceptionClass.bestName()} but a ${thrownThrowable::class.simpleName} was thrown instead.",
thrownThrowable
)
}
}
}
/**
* Builds tests with the specified [subject] using a [TestsWithSubjectScope].
*/
public fun testing(subject: T, init: suspend TestsWithSubjectScope.() -> Unit): Stream =
buildTestNodeSequence(init) { DynamicTestsWithSubjectBuilder(subject, it) }.asNonEmptyStream()
/**
* Builds tests for each of the specified [subjects] using a [TestsWithSubjectScope].
*
* The name for each container is heuristically derived but can also be explicitly specified using [containerNamePattern]
* which supports curly placeholders `{}` like [SLF4J] does.
*/
public fun testingAll(
vararg subjects: T,
containerNamePattern: String? = null,
init: suspend TestsWithSubjectScope.() -> Unit,
): Stream = subjects.asIterable().testingAll(containerNamePattern, init)
/**
* Builds tests for each subject of this [Collection] using a [TestsWithSubjectScope].
*
* The name for each container is heuristically derived but can also be explicitly specified using [containerNamePattern]
* which supports curly placeholders `{}` like [SLF4J] does.
*/
public fun Iterable.testingAll(
containerNamePattern: String? = null,
init: suspend TestsWithSubjectScope.() -> Unit,
): Stream = map { subject ->
dynamicContainer(
"$FOR ${displayNameFor(subject, containerNamePattern)}",
PathSource.currentUri,
testing(subject, init)
)
}.asSequence().asNonEmptyStream()
/**
* Builds tests for each subject of this [Sequence] using a [TestsWithSubjectScope].
*
* The name for each container is heuristically derived but can also be explicitly specified using [containerNamePattern]
* which supports curly placeholders `{}` like [SLF4J] does.
*/
public fun Sequence.testingAll(
containerNamePattern: String? = null,
init: TestsWithSubjectScope.() -> Unit,
): Stream = asIterable().testingAll(containerNamePattern, init)
/**
* Builds tests for each entry of this [Map] using a [TestsWithSubjectScope].
*
* The name for each container is heuristically derived but can also be explicitly specified using [containerNamePattern]
* which supports curly placeholders `{}` like [SLF4J] does.
*/
public fun Map.testingAll(
containerNamePattern: String? = null,
init: TestsWithSubjectScope>.() -> Unit,
): Stream = entries.testingAll(containerNamePattern, init)
/** Scope for building tests (and test containers) with a subject. */
public interface TestsWithSubjectScope {
/**
* Expects the subject to fulfil the given [assertions].
*
* **Usage:** `it { π΄πΆπ£π«π¦π€π΅. }`
*/
public suspend fun it(assertions: T.() -> Unit)
/**
* Expects the subject to fulfil the given [assertions].
*
* **Usage:** `that { it. }`
*/
public suspend fun that(assertions: Assertions)
/**
* Expects the subject transformed by [action] to fulfil the
* [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expecting { π΄πΆπ£π«π¦π€π΅. } it { }`
*
* **Usage:** `expecting { π΄πΆπ£π«π¦π€π΅. } that { it. }`
*/
public suspend fun expecting(description: String? = null, action: T.() -> R): AssertionsBuilder
/**
* Expects the [Result] of the subject transformed by [action] to fulfil the
* [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expectCatching { π΄πΆπ£π«π¦π€π΅. } it { }`
*
* **Usage:** `expectCatching { π΄πΆπ£π«π¦π€π΅. } that { it. }`
*/
public suspend fun expectCatching(action: T.() -> R): AssertionsBuilder>
/**
* Expects an exception [E] to be thrown when transforming the subject with [action]
* and to optionally fulfil the [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expectThrows { π΄πΆπ£π«π¦π€π΅. }`
*
* **Usage:** `expectThrows { π΄πΆπ£π«π¦π€π΅. } it { }`
*
* **Usage:** `expectThrows { π΄πΆπ£π«π¦π€π΅. } that { it. }`
*/
public suspend fun expectThrows(type: KClass, action: T.() -> Any?): AssertionsBuilder
}
/**
* Expects an exception [E] to be thrown when transforming the subject with [action]
* and to optionally fulfil the [Assertions] returned by [AssertionsBuilder].
*
* **Usage:** `expectThrows { π΄πΆπ£π«π¦π€π΅. }`
*
* **Usage:** `expectThrows { π΄πΆπ£π«π¦π€π΅. } it { }`
*
* **Usage:** `expectThrows { π΄πΆπ£π«π¦π€π΅. } that { it. }`
*/
public suspend inline fun TestsWithSubjectScope.expectThrows(noinline action: T.() -> Any?): AssertionsBuilder =
expectThrows(E::class, action)
/** Builder for tests (and test containers) with the specified [subject]. */
private class DynamicTestsWithSubjectBuilder(
val subject: T,
val yieldNode: suspend (Mode, DynamicNode) -> Unit,
) : TestsWithSubjectScope {
override suspend fun it(assertions: T.() -> Unit) {
val caller = KommonsTest.locateCall()
val displayName = caller.assertingDisplayName(subject, assertions)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { subject.asClue(assertions) })
}
override suspend fun that(assertions: Assertions) {
val caller = KommonsTest.locateCall()
val displayName = caller.assertingDisplayName(subject, assertions)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { subject.asClue(assertions) })
}
override suspend fun expecting(description: String?, action: T.() -> R): AssertionsBuilder {
val caller = KommonsTest.locateCall()
val displayName = description ?: caller.expectingDisplayName(action)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { throw IllegalUsageException("expecting", caller.sourceUri) })
return AssertionsBuilder { assertions: Assertions ->
yieldNode(REPLACE, dynamicTest(displayName, caller.sourceUri) {
withClue(subject) { subject.action().asClue(assertions) }
})
}
}
override suspend fun expectCatching(action: T.() -> R): AssertionsBuilder> {
val caller = KommonsTest.locateCall()
val displayName = caller.catchingDisplayName(action)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) { throw IllegalUsageException("expectCatching", caller.sourceUri) })
return AssertionsBuilder { assertions: Assertions> ->
yieldNode(REPLACE, dynamicTest(displayName, caller.sourceUri) {
withClue(subject) { subject.runCatching(action).asClue(assertions) }
})
}
}
override suspend fun expectThrows(type: KClass, action: T.() -> Any?): AssertionsBuilder {
val caller = KommonsTest.locateCall()
val displayName = throwingDisplayName(type)
yieldNode(CREATE, dynamicTest(displayName, caller.sourceUri) {
withClue(subject) { shouldThrow(type) { subject.action() }.asClue({}) }
})
return AssertionsBuilder { assertions: Assertions ->
yieldNode(REPLACE, dynamicTest(displayName, caller.sourceUri) {
withClue(subject) { shouldThrow(type) { subject.action() }.asClue(assertions) }
})
}
}
}
/** Exception that is thrown if the API is incorrectly used. */
public class IllegalUsageException(function: String, caller: URI?) : IllegalArgumentException(
"$function { β¦ } call was not finished with \"that { β¦ }\"".let {
caller?.let { uri -> "$it at " + uri.path + ":" + uri.query.takeLastWhile { it.isDigit() } } ?: it
}
)
private fun Sequence.asNonEmptyStream() =
ifEmpty { throw IllegalStateException("No tests were created.") }.asStream()
private enum class Mode { CREATE, REPLACE }
/**
* Builds a sequence of dynamic nodes using [init]
* operating on the scope/builder [S].
*
* [initBuilder] has to provide a new builder
* that can use the passed suspend function to yield
* dynamic nodes and if necessary, [REPLACE] the
* previously yielded one.
*/
private fun buildTestNodeSequence(
init: suspend S.() -> Unit,
initBuilder: (suspend (Mode, DynamicNode) -> Unit) -> S,
): Sequence = unrestrictedSequence {
var scheduledNode: DynamicNode? = null
initBuilder { mode, newNode ->
when (mode) {
CREATE -> {
scheduledNode?.also { yield(it) }
scheduledNode = newNode
}
REPLACE -> {
scheduledNode?.also {
if (!it.testSourceUri.equals(newNode.testSourceUri)) {
throw IllegalStateException("${newNode.testSourceUri} attempts to replace the supposedly not fully built test ${it.testSourceUri}")
}
}
scheduledNode = newNode
}
}
}.init()
scheduledNode?.also { yield(it) }
}
/**
* Simplified copy of [kotlin.sequences.SequenceScope] to
* support building tests while streaming them.
*/
private abstract class SequenceScope {
/** @see kotlin.sequences.SequenceScope.yield */
abstract suspend fun yield(value: T)
/** @see kotlin.sequences.SequenceScope.yieldAll */
abstract suspend fun yieldAll(iterator: Iterator)
/** @see kotlin.sequences.SequenceScope.yieldAll */
suspend fun yieldAll(elements: Iterable) {
if (elements is Collection && elements.isEmpty()) return
return yieldAll(elements.iterator())
}
/** @see kotlin.sequences.SequenceScope.yieldAll */
suspend fun yieldAll(sequence: Sequence): Unit = yieldAll(sequence.iterator())
}
private fun unrestrictedSequence(@BuilderInference block: suspend SequenceScope.() -> Unit): Sequence = Sequence { unrestrictedIterator(block) }
private fun unrestrictedIterator(@BuilderInference block: suspend SequenceScope.() -> Unit): Iterator {
val iterator = SequenceBuilderIterator()
iterator.nextStep = block.createCoroutineUnintercepted(receiver = iterator, completion = iterator)
return iterator
}
private typealias State = Int
private const val State_NotReady: State = 0
private const val State_ManyNotReady: State = 1
private const val State_ManyReady: State = 2
private const val State_Ready: State = 3
private const val State_Done: State = 4
private const val State_Failed: State = 5
private class SequenceBuilderIterator : SequenceScope(), Iterator, Continuation {
private var state = State_NotReady
private var nextValue: T? = null
private var nextIterator: Iterator? = null
var nextStep: Continuation? = null
override fun hasNext(): Boolean {
while (true) {
when (state) {
State_NotReady -> {}
State_ManyNotReady ->
if (nextIterator!!.hasNext()) {
state = State_ManyReady
return true
} else {
nextIterator = null
}
State_Done -> return false
State_Ready, State_ManyReady -> return true
else -> throw exceptionalState()
}
state = State_Failed
val step = nextStep!!
nextStep = null
step.resume(Unit)
}
}
override fun next(): T {
when (state) {
State_NotReady, State_ManyNotReady -> return nextNotReady()
State_ManyReady -> {
state = State_ManyNotReady
return nextIterator!!.next()
}
State_Ready -> {
state = State_NotReady
@Suppress("UNCHECKED_CAST")
val result = nextValue as T
nextValue = null
return result
}
else -> throw exceptionalState()
}
}
private fun nextNotReady(): T {
if (!hasNext()) throw NoSuchElementException() else return next()
}
private fun exceptionalState(): Throwable = when (state) {
State_Done -> NoSuchElementException()
State_Failed -> IllegalStateException("Iterator has failed.")
else -> IllegalStateException("Unexpected state of the iterator: $state")
}
override suspend fun yield(value: T) {
nextValue = value
state = State_Ready
return suspendCoroutineUninterceptedOrReturn { c ->
nextStep = c
COROUTINE_SUSPENDED
}
}
override suspend fun yieldAll(iterator: Iterator) {
if (!iterator.hasNext()) return
nextIterator = iterator
state = State_ManyReady
return suspendCoroutineUninterceptedOrReturn { c ->
nextStep = c
COROUTINE_SUSPENDED
}
}
// Completion continuation implementation
override fun resumeWith(result: Result) {
result.getOrThrow() // just rethrow exception if it's there
state = State_Done
}
override val context: CoroutineContext
get() = EmptyCoroutineContext
}