-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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 Kotlin contracts to exposed Kotlin API #3259
base: main
Are you sure you want to change the base?
Conversation
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think fun fail(message: (() -> String)?)
should also have a contract added to it?
Also, can contracts be added to vararg executables: () -> Unit
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
callsInPlace
supports only non-nullable arguments.
It would be great to have something like:
fun fail(message: (() -> String)?): Nothing {
contract {
if (message != null) {
callsInPlace(message, EXACTLY_ONCE)
}
}
Assertions.fail<Nothing>(message)
}
but if
is not allowed in a contract definition.
Similar thing applies to vararg executables: () -> Unit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could the if
be partially resolved like this? So that more (most) calls end up using the contract description for analysis?
// Covers method references `fail(::foo)`, inline lambdas `fail { }`.
fun fail(message: () -> String): Nothing {
contract {
callsInPlace(message, EXACTLY_ONCE)
}
Assertions.fail<Nothing>(message)
}
// Covers backwards compatibility and potential edge cases like `fail(foo.bar)` where bar is a nullable functional type.
fun fail(message: (() -> String)?): Nothing {
Assertions.fail<Nothing>(message)
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The method signatures are same from JVM perspective.
Platform declaration clash: The following declarations have the same JVM signature (fail(Lkotlin/jvm/functions/Function0;)Ljava/lang/Void;):
fun fail(message: (() -> String)?): Nothing defined in org.junit.jupiter.api in file Assertions.kt
fun fail(message: () -> String): Nothing defined in org.junit.jupiter.api in file Assertions.kt
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh, I didn't get that when I tried. Anyway, @JvmName usually helps. The question is if this is a feasible solution to get contract in for most, and still keep supporting other edge cases. What's the use case for nullable lambda here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, creating a separate method with a contract and non-nullable lambda allows kotlin compiler to carry out contract checks when it's sure the passed lambda is not null.
I assume, lambda is made nullable to resemble existing java api where messageSupplier
can be nullable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
callsInPlace
for fail
method is also specified with InvocationKind.UNKNOWN
.
If we set it to EXACTLY_ONCE
the following code compiles:
val expectedValue = "value"
val value: String
try {
fail {
value = expectedValue
"message"
}
} catch (_: AssertionFailedError) {
value = "another value"
}
assertEquals(expectedValue, value)
Here we reassign value
albeit it is val
. I reckon it isn't desirable to allow this code to compile
Assertions.assertTimeoutPreemptively(timeout, executable) | ||
fun <R> assertTimeoutPreemptively(timeout: Duration, executable: () -> R): R { | ||
contract { | ||
callsInPlace(executable, EXACTLY_ONCE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it actually true for things that can/expected to throw?
(I have a feeling we can't say "exactly once", but I might be misinterpreting the enum.)
Examples where it would not have compiled without the contract, and now it's a runtime error:
val value: String
// Swapped version of fun `assertThrows with value initialization in lambda`() in this PR
assertThrows<AssertionError> {
Assertions.fail("message")
value = "string"
}
assertEquals("string", value) // AssertionFailedError: expected: <string> but was: <null>
val x: String
assertThrows<IOException> {
File("//").readText() // Deterministic IOException due to invalid name.
x = "abc" // Will never execute, because it always throws.
}
// Kotlin thinks x is initialized here, because of the EXACTLY_ONCE.
assertEquals(3, x.length) // Consistent NPE, due to "effectively unreachable code" above.
val x: String
assertThrows<AssertionError> {
assertTimeoutPreemptively(Duration.seconds(1)) {
Thread.sleep(2000)
x = "" // Will never execute, because it always times out.
}
}
// Kotlin thinks x is initialized here, because of the chain of EXACTLY_ONCE.
println(x.length) // Consistent NPE, due to "effectively unreachable code" above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What happens if you write this in Kotlin with try/catch? I presume the compiler pitches a fit?
val x: String
try {
File("//").readText() // Deterministic IOException due to invalid name.
x = "abc" // Will never execute, because it always throws.
} catch (e: IOException) {
}
assertEquals(3, x.length)
What about this?
val x: String
File("//").apply {
readText() // Deterministic IOException due to invalid name.
x = "abc" // Will never execute, because it always throws.
}
assertEquals(3, x.length)
If the apply
case fails in the same way as your example, I think it's probably fine?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like it will compile fine:
import java.io.File
var x: String
File("//").apply {
readText() // Deterministic IOException due to invalid name.
x = "abc" // Will never execute, because it always throws.
}
if (3 == x.length) {
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh, very good point! 😁
try-catch example will not compile, but because not all code paths initialize x, but the point is valid there too, you can resolve this thread (I can't).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could theoretically reimplement this logic, in kotlin, and expose it as an inline function. Then the try/catch will be inlined. Then there will be no need for the contract
junit5/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertThrows.java
Lines 49 to 74 in ffab3a3
private static <T extends Throwable> T assertThrows(Class<T> expectedType, Executable executable, | |
Object messageOrSupplier) { | |
try { | |
executable.execute(); | |
} | |
catch (Throwable actualException) { | |
if (expectedType.isInstance(actualException)) { | |
return (T) actualException; | |
} | |
else { | |
UnrecoverableExceptions.rethrowIfUnrecoverable(actualException); | |
throw assertionFailure() // | |
.message(messageOrSupplier) // | |
.expected(expectedType) // | |
.actual(actualException.getClass()) // | |
.reason("Unexpected exception type thrown") // | |
.cause(actualException) // | |
.build(); | |
} | |
} | |
throw assertionFailure() // | |
.message(messageOrSupplier) // | |
.reason(format("Expected %s to be thrown, but nothing was thrown.", getCanonicalName(expectedType))) // | |
.build(); | |
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense, I would be explicit with second param though, just for readability. If the Kotlin compiler is improved, is it easy to swap to a "better exactly once"? Or would it break users?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't noticed any improvements in contracts. InvocationKind.UNKNOWN
is set for assertTimeoutPreemptively
methods because of the same reasons as for assertThrows
methods
|
||
assertThrows<AssertionError> { | ||
value = "string" | ||
Assertions.fail("message") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this just be
Assertions.fail("message") | |
fail("message") |
considering this is Kotlin code and there's a : Nothing
signature top level fail
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Contracts work really weird with Nothing
return type. Compiler warns that expression after assertThrows
is not reachable while it is.
In the thread above I proposed not to specify InvocationKind
for assertThrows
methods. If there's no InvocationKind
specified, value assignment in lambda will be forbidden and this test will make no sense
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case I agree with UNKNOWN
for invocation kind, because not being able to call production code that declares Nothing
as return type is a big restriction. The point of assertThrows
is that it tests exceptions thrown from a method, and Nothing
declares exactly that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In such case I'll set InvocationKind.UNKNOWN
for assertThrows
methods
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks to be in a reasonable state. Have some thoughts though.
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
Assertions.assertTimeoutPreemptively(timeout, executable) | ||
fun <R> assertTimeoutPreemptively(timeout: Duration, executable: () -> R): R { | ||
contract { | ||
callsInPlace(executable, EXACTLY_ONCE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense, I would be explicit with second param though, just for readability. If the Kotlin compiler is improved, is it easy to swap to a "better exactly once"? Or would it break users?
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did a full review, wrote down what I noticed. Feel free to ignore some of them :)
Please resolve the conversations that are resolved, I can't do it, because only the PR Author and repo Members can.
And then we'll probably need a round of review from Marc or Jonathan.
inline fun <reified T : Throwable> assertThrows(message: String, executable: () -> Unit): T { | ||
contract { | ||
callsInPlace(executable, UNKNOWN) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to test for this contract
?
As in: if it's removed, something should stop compiling or fail a test, right?
Probably the one in the related convo: #3259 (comment)
(This is probably true for all of them.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't found a way to test callsInPlace(..., UNKNOWN)
contracts.
I though having another function with a contract inside could work, but it seems, kotlin compiler doesn't check whether a function is really called in place in the functions it's passed to.
fun notWorkingTest(value: () -> Unit) {
contract {
callsInPlace(value, UNKNOWN)
}
assertThrows<AssertionError>(value) // works fine even when assertThrows doesn't have a contract
}
val error = assertThrows<AssertionFailedError> { | ||
assertInstanceOf<String>(null) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels meta, using a Kotlin assertion to test a Kotlin assertion, but it's probably ok, since they have different use cases for these two.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have a few tests for assertThrows
. Also, a similar approach is used for assertDoesNotThrow
tests
junit-jupiter-engine/src/test/kotlin/org/junit/jupiter/api/KotlinAssertionsTests.kt
Outdated
Show resolved
Hide resolved
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
junit-jupiter-api/src/main/kotlin/org/junit/jupiter/api/Assertions.kt
Outdated
Show resolved
Hide resolved
fun assertAll(heading: String?, vararg executables: () -> Unit) = | ||
assertAll(heading, executables.toList().stream()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reading this on GitHub makes me confused about the return type.
Again, not your code, but all your return types are nice and clean, probably worth aligning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've noticed that return type is not specified only for one-liners that return Unit
. I assume, to resemble usual methods, where it isn't mandatory to specify Unit
return type. For one-liners that return anything besides Unit
, a return type is already specified
@@ -134,6 +355,11 @@ inline fun <reified T : Throwable> assertThrows(message: String, executable: () | |||
* @see Assertions.assertThrows | |||
*/ | |||
inline fun <reified T : Throwable> assertThrows(noinline message: () -> String, executable: () -> Unit): T { | |||
contract { | |||
callsInPlace(executable, UNKNOWN) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this unknown?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Contracts doesn't work well as being discussed in this conversation.
Perhaps, not specifying contracts for assertThrows
and assertTimeoutPreemptively
is a better option. For example, kotlin's runCatching method doesn't specify any contracts.
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Contracts have been removed for all lambdas that may throw an exception
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be worth adding a comment into those methods that they intentionally don't have contracts and why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Imo, adding comments regarding a contract absence everywhere adds nothing but noise. A person who's concerned by the missing contracts can check the commits and see why they aren't in place
Is there anything else that should be added/adjusted? Otherwise, I'd say the pr is in its final form |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
junit-jupiter-engine/src/test/kotlin/org/junit/jupiter/api/KotlinAssertionsTests.kt
Outdated
Show resolved
Hide resolved
What do you think about the pr, @marcphilipp, @JLLeitschuh? |
Hi. I believe the change is still relevant and the pr is ready to be reviewed. |
We'll consider it for 5.11, just need to find some time to review. |
Bump, we're really missing this PR in Kotlin world. What needs to be done still? |
I think the PR needs to be updated to resolve the conflicts, checked for completeness, and then reviewed by someone from the core team. @awelless Do you have time to do a rebase? |
I'd still love to see these added, happy to do a re-review after someone does a rebase |
@marcphilipp |
537a0f2
to
265a855
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@marcphilipp @JLLeitschuh
I've rebased the PR.
The changes are put under the version 5.12 (in the release notes and @API
annotation).
Since assertInstanceOf
has been added in the meantime, I removed my implementations of this assertion. I updated the KDoc's and added a few tests to verify the smart casting capabilities.
Would you give the PR another round of review, please?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please also add an example to KotlinAssertionsDemo
for the User Guide? Maybe showing assertNotNull
and the smart cast that follows?
@@ -43,7 +43,7 @@ JUnit repository on GitHub. | |||
current worktree status are now included in the XML report, if applicable. | |||
- A section containing JUnit-specific metadata about each test/container to the HTML | |||
report is now written by open-test-reporting when added to the classpath/module path | |||
|
|||
* Introduced kotlin contracts for kotlin assertion methods. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
* Introduced kotlin contracts for kotlin assertion methods. | |
* Introduced contracts for Kotlin-specific assertion methods. | |
Overview
Resolves #1866.
Introduced
assertNull
,assertNotNull
methods.Introduced contracts for
assertNull
,assertNotNull
,assertThrows
andassertDoesNotThrow
methods.Added smart casting tests for
assertInstanceOf
methods.I hereby agree to the terms of the JUnit Contributor License Agreement.
Definition of Done
@API
annotations