Add contracts to Kotlin-specific assertions#3259
Add contracts to Kotlin-specific assertions#3259marcphilipp merged 2 commits intojunit-team:mainfrom
Conversation
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
There was a problem hiding this comment.
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.
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.
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.
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.
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.
In such case I'll set InvocationKind.UNKNOWN for assertThrows methods
TWiStErRob
left a comment
There was a problem hiding this comment.
Looks to be in a reasonable state. Have some thoughts though.
| Assertions.assertTimeoutPreemptively(timeout, executable) | ||
| fun <R> assertTimeoutPreemptively(timeout: Duration, executable: () -> R): R { | ||
| contract { | ||
| callsInPlace(executable, EXACTLY_ONCE) |
There was a problem hiding this comment.
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?
TWiStErRob
left a comment
There was a problem hiding this comment.
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.
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.
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.
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.
We have a few tests for assertThrows. Also, a similar approach is used for assertDoesNotThrow tests
| fun assertAll(heading: String?, vararg executables: () -> Unit) = | ||
| assertAll(heading, executables.toList().stream()) |
There was a problem hiding this comment.
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.
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
| */ | ||
| inline fun <reified T : Throwable> assertThrows(noinline message: () -> String, executable: () -> Unit): T { | ||
| contract { | ||
| callsInPlace(executable, UNKNOWN) |
There was a problem hiding this comment.
There was a problem hiding this comment.
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.
Contracts have been removed for all lambdas that may throw an exception
There was a problem hiding this comment.
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.
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 |
|
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 |
|
@awelless Thank you for your contribution! 🎉 |
| ): R = Assertions.assertTimeoutPreemptively(timeout, executable, message) | ||
| ): R { | ||
| contract { | ||
| callsInPlace(message, AT_MOST_ONCE) |
There was a problem hiding this comment.
Shouldn't executable also have a contract?
There was a problem hiding this comment.
It was ignored deliberately. Please, see this comment.
There was a problem hiding this comment.
Oh, I had forgotten that discussion. Thanks for the reminder! 👍
There was a problem hiding this comment.
Now I wonder, whether we should add the explanation why some of the lambdas don't have a contract. I'm not sure, if the actual code is the best place, though.
There was a problem hiding this comment.
I think that's a good idea and I don't know a better place. Do you have time to submit a PR?
There was a problem hiding this comment.
Could be a single sentence in code (each method), and a section at the top/bottom of the file?
There was a problem hiding this comment.
I think a single comment in each of those methods should suffice. Something like
// No contract for `executable` because ...
There was a problem hiding this comment.
Most likely I won't be able to do this in December.
I may take a look in January, if someone else hasn't done that by then.
|
@marcphilipp did this not make 5.11.4? |
|
No, it's targeting 5.12 which should be released in January if things go according to plan. |
|
After bumping Kotlin's @awelless @TWiStErRob @JLLeitschuh Does one of you have time to take a look? |
Overview
Resolves #1866.
Introduced
assertNull,assertNotNullmethods.Introduced contracts for
assertNull,assertNotNull,assertThrowsandassertDoesNotThrowmethods.Added smart casting tests for
assertInstanceOfmethods.I hereby agree to the terms of the JUnit Contributor License Agreement.
Definition of Done
@APIannotations