junit-team / junit5

✅ The 5th major version of the programmer-friendly testing framework for Java and the JVM
https://junit.org
Other
6.32k stars 1.47k forks source link

Add Kotlin contracts to exposed Kotlin API #1866

Open JLLeitschuh opened 5 years ago

JLLeitschuh commented 5 years ago

Foreward

First off, I want to thank the Junit 5 team from being so willing to officially support Kotlin as a first-class citizen in the Junit 5 library. It has been absolutely wonderful being able to use my own contributions in all of my Kotlin projects.

Feature Request

I believe that this API can be further enhanced with the new Kotlin 1.3 feature, Contracts.

Contracts are making guarantees to the compiler that various methods have certain characteristics.

Here's an example from the Kotlin Std-Lib:

/**
 * Throws an [IllegalStateException] if the [value] is null. Otherwise
 * returns the not null value.
 *
 * @sample samples.misc.Preconditions.failCheckWithLazyMessage
 */
@kotlin.internal.InlineOnly
public inline fun <T : Any> checkNotNull(value: T?): T {
    contract {
        returns() implies (value != null)
    }
    return checkNotNull(value) { "Required value was null." }
}

Before Kotlin Contracts, the following code wouldn't have compiled:

fun validateString(aString: String?): String {
    checkNotNull(aString)
    return aString
}

I believe that JUnit 5 has a few places where these contracts would be valuable.

Examples

assertNotNull

@ExperimentalContracts
fun <T: Any> assertNonNull(actual: T?, message: String): T {
    contract {
        returns() implies (actual != null)
    }
    Assertions.assertNotNull(actual, message)
    return actual!!
}

The above would allow something like this:

val exception = assertThrows<IllegalStateException { /** whatever **/}
val message = exception.message
assertNotNull(message)
assertTrue(message.contains("some expected substring")) 

Alternatively, it would also allow for this sort of use case:

val message = assertNotNull(exception.message)

assertThrows / assertDoesNotThrow

Since the callable passed to assertThrows is only ever called once, we can expose that in the contract.

@ExperimentalContracts
inline fun <reified T : Throwable> assertThrows(noinline message: () -> String, noinline executable: () -> Unit): T {
    contract {
        callsInPlace(executable, InvocationKind.EXACTLY_ONCE)
    }
    return Assertions.assertThrows(T::class.java, Executable(executable), Supplier(message))
}

Similar

This would for something like this:

val something: Int
val somethingElse: String
assertDoesNotThrow {
    something = somethingThatDoesntThrow()
    somethingElse = gettingSomethingElse()
}

Caveats

Kotlin Contracts are only supported in Kotlin 1.3 and higher. This would require a discussion regarding what version of Kotlin the Junit 5 team want's to officially support.

Deliverables

stale[bot] commented 3 years ago

This issue has been automatically marked as stale because it has not had recent activity. Given the limited bandwidth of the team, it will be automatically closed if no further activity occurs. Thank you for your contribution.

jbduncan commented 3 years ago

This sounds like a useful usability feature!

stale[bot] commented 2 years ago

This issue has been automatically marked as stale because it has not had recent activity. Given the limited bandwidth of the team, it will be automatically closed if no further activity occurs. Thank you for your contribution.

ephemient commented 2 years ago

Please do this.

stale[bot] commented 2 years ago

This issue has been automatically closed due to inactivity. If you have a good use case for this feature, please feel free to reopen the issue.

TWiStErRob commented 2 years ago

@junit-team What is missing to move this forward?

@JLLeitschuh I think these might also be deliverables for full coverage:

awelless commented 1 year ago

Is anyone working on this issue?

JLLeitschuh commented 1 year ago

Not currently, but I figure at this point it would be safe to do so as Kotlin 1.3 is pretty EOL as of 2 years ago: https://endoflife.date/kotlin

JLLeitschuh commented 1 year ago

Also, this library currently supports Kotlin versions 1.3 and above, so it would now be safe to add:

https://github.com/junit-team/junit5/blob/e188b1eae321866f8fe87c9736f8c5d6e41cba24/gradle/plugins/common/src/main/kotlin/junitbuild.kotlin-library-conventions.gradle.kts#L8-L14

If someone wants to open a pull request to add support for this, I'm more than happy to review it

awelless commented 1 year ago

Okay. I could start working on it. However, it might take some time

TWiStErRob commented 1 year ago

@JLLeitschuh how can this be tested with automation? Because contracts should induce Kotlin compilation failures. I guess the positive cases of smart casts as results of contract can be tested:

val foo: String? = ...
assertNotNull(foo)
assertEquals(1, foo.size)

(if the contracts stop working, the above test will not compile)

JLLeitschuh commented 1 year ago

As you suggested, I would use the compiler as your validation

awelless commented 1 year ago

I think these might also be deliverables for full coverage: ...

  • assertInstanceOf (similar to non-null, but with implies (actualValue is T))

Contracts with reified generics are supported only since kotlin 1.4. Therefore, assertInstanceOf can't be implemented for now

TWiStErRob commented 1 year ago

I wondered if the that error is suppressed will it emit 1.4 compatible bytecode for the contract? Therefore making it possible to use the same jar binary from both 1.3 and 1.4.

so I tested it... but doesn't seem so (For reference the Kotlin version is declared in [kotlin-library-conventions.gradle.kts](https://github.com/junit-team/junit5/blob/r5.9.3/buildSrc/src/main/kotlin/kotlin-library-conventions.gradle.kts#L10-L11)) ```kotlin // kotlinc -language-version 1.3 -api-version 1.3 -d assertIsInstance.jar -opt-in=kotlin.contracts.ExperimentalContracts assertIsInstance.kt package test import kotlin.contracts.contract inline fun assertIsInstance(value: Any?) { contract { returns() implies (value is @Suppress("ERROR_IN_CONTRACT_DESCRIPTION") T) } require(T::class.java.isInstance(value)) } ``` ```kotlin // kotlinc -language-version 1.4 -api-version 1.4 -cp assertIsInstance.jar usage.kt import test.assertIsInstance fun main() { val cs: CharSequence = "" assertIsInstance(cs) val str: String = cs println(str) } // usage.kt:6:23: error: type mismatch: inferred type is CharSequence but String was expected // val str: String = cs // ^ ``` If the `assertIsInstance.jar` is compiled with 1.4, then `usage.kt` compiles as expected. Funny thing: if `assertIsInstance.jar` is compiled with 1.4, but usage is compiled with 1.3, it works! So the 1.3 target understands the 1.4 contract, if it's correctly encoded in the .class file in the first place. In the end this information is really for the compiler to know which functions/types to bind to during compilation. It probably works this way because the actual `kotlinc` compiler I'm using is 1.7.10.
awelless commented 1 year ago

Since kotlin version has been bumped to 1.6, it should be possible to implement assertInstanceOf with a contract specified