diff --git a/README.md b/README.md index d89b5c9..03ccab4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,167 @@ # xemantic-kotlin-test + Kotlin multiplatform testing library providing power-assert compatible assertions + +## Why? + +I am mostly using [kotest](https://kotest.io/) library for writing test assertions +in my projects. When [power-assert](https://kotlinlang.org/docs/power-assert.html) +became the official Kotlin compiler plugin, I also realized that most of the kotest +assertions can be replaced with something which suits my purposes better. +Instead of writing: + +```kotlin +x shouldBeGreaterThanOrEqualTo 42 +``` + +I could write: + +```kotlin +assert(x >= 5) +``` + +Unfortunately the [assert](https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/assert.html) +function is supported at the moment only for `JVM` and `Native` out of all the Kotlin +multiplatform targets. So for my multiplatform libraries it would rather be +[assertTrue](https://kotlinlang.org/api/core/kotlin-test/kotlin.test/assert-true.html), but +... it is becoming too verbose. + +Quite often I am asserting the state of hierarchical data +structures, therefore I came up with this syntax: + +```kotlin +message should { + have(id == 42) + have(content.size == 2) + content[0] should { + be() + have(type == "text") + have("Hello" in text) + } + content[1] should { + be() + have(type == "image") + have(width >= 800) + have(height >= 600) + mediaType should { + have(type == "image/png") + } + } +} +``` + +Now, if the image `mediaType.type` is not `PNG`, it will show: + +```text +Message(id=42, content=[Text(text=Hello there), Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/jpeg))]) + containing: +Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/jpeg)) + containing: +MediaType(type=image/jpeg) + should: +have(type == "image/png") + | | + | false + image/jpeg +``` + +## Usage + +### Setting up Gradle for Kotlin JVM project + +In your `build.gradle.kts`: + +```kotlin +plugins { + kotlin("jvm") version "2.1.0" + kotlin("plugin.power-assert") version "2.1.0" // replace with the latest kotlin version +} + +dependencies { + testImplementation("com.xemantic.kotlin:xemantic-kotlin-test:0.1-SNAPSHOT") +} + +powerAssert { + functions = listOf( + "com.xemantic.kotlin.test.have" + ) +} +``` + +### Setting up Gradle for Kotlin Multiplatform project + +In your `build.gradle.kts`: + +```kotlin +plugins { + kotlin("multiplatform") version "2.1.0" + kotlin("plugin.power-assert") version "2.1.0" // replace with the latest kotlin version +} + +kotlin { + + sourceSets { + + commonTest { + depencencies { + implementation("com.xemantic.kotlin:xemantic-kotlin-test:0.1-SNAPSHOT") + } + } + + } +} + +powerAssert { + functions = listOf( + "com.xemantic.kotlin.test.have" + ) +} +``` + +### Basic Assertions + +The library introduces the [should](src/commonMain/kotlin/Assertions.kt) infix function, which allows you to chain assertions on an object: + +```kotlin +someObject should { + // assertions go here +} +``` + +### Type Assertions + +You can assert the type of object using the [be](src/commonMain/kotlin/Assertions.kt) function: + +```kotlin +someObject should { + be() +} +``` + +> ![TIP] +> After calling `be` function with expected type, all the subsequent calls within +> `should {}` will have access to the properties of the expected type, +> like if `this`, representing `someObject`, was cast to the expected type. + +### Condition Assertions + +Use the `have` function to assert conditions: + +```kotlin +someObject should { + have(someProperty == expectedValue) +} +``` + +## Nested Assertions + +You can nest assertions for complex objects: + +```kotlin +complexObject should { + have(property1 == expectedValue1) + nestedObject should { + have(nestedProperty == expectedValue2) + } +} +``` diff --git a/build.gradle.kts b/build.gradle.kts index 5915ffe..b16b011 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -147,7 +147,6 @@ tasks.withType { powerAssert { functions = listOf( -// "com.xemantic.kotlin.test.should", "com.xemantic.kotlin.test.have" ) } diff --git a/src/commonMain/kotlin/Assertions.kt b/src/commonMain/kotlin/Assertions.kt index c25bf6d..01a4ca1 100644 --- a/src/commonMain/kotlin/Assertions.kt +++ b/src/commonMain/kotlin/Assertions.kt @@ -19,7 +19,7 @@ package com.xemantic.kotlin.test import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract -import kotlin.test.assertIs +import kotlin.reflect.typeOf import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -29,19 +29,40 @@ public infix fun T?.should(block: T.() -> Unit) { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } assertNotNull(this) - block() + try { + block() + } catch (e : AssertionError) { + throw ShouldAssertionError( + message = if (e is ShouldAssertionError) { + "$this\n containing:\n${e.message}" + } else { + "$this\n should:${e.message}" + }, + cause = e + ) + } } @OptIn(ExperimentalContracts::class) -public inline fun Any?.be( - message: String? = null -) { +public inline fun Any?.be() { contract { returns() implies (this@be is T) } - assertIs(this, message) + if (this !is T) { + throw AssertionError( + " be of type <${typeOf()}>, actual <${this!!::class}>" + ) + } } -public fun have(condition: Boolean, message: String? = null) { +public fun have( + condition: Boolean, + message: String? = null +) { assertTrue(condition, message) } + +public class ShouldAssertionError( + message: String, + cause: AssertionError +) : AssertionError(message, cause) diff --git a/src/commonTest/kotlin/AssertionsTest.kt b/src/commonTest/kotlin/AssertionsTest.kt index 9ec6578..9b9d6ba 100644 --- a/src/commonTest/kotlin/AssertionsTest.kt +++ b/src/commonTest/kotlin/AssertionsTest.kt @@ -23,6 +23,13 @@ import kotlin.test.assertFailsWith class AssertionsTest { + // test classes to test assertions against + + data class Message( + val id: Int, + val content: List + ) + interface Content { val type: String } @@ -30,22 +37,30 @@ class AssertionsTest { data class Text( val text: String, ) : Content { + override val type = "text" + } data class Image( val path: String, val width: Int, - val height: Int + val height: Int, + val mediaType: MediaType ) : Content { + override val type = "image" - } - data class Message( - val id: Int, - val content: List - ) + data class MediaType( + val type: String + ) + } + + /** + * Message is our main test class - the root in hierarchical structure. + * It is nullable, because we also want to test if `should` works with nullable instances. + */ val message: Message? = Message( id = 42, content = listOf( @@ -53,13 +68,15 @@ class AssertionsTest { Image( path = "image.png", width = 1024, - height = 768 + height = 768, + mediaType = Image.MediaType("image/png") ) ) ) + @Test - fun `Should pass`() { + fun `Should pass all assertions on default message instance`() { message should { have(id == 42) have(content.size == 2) @@ -73,6 +90,9 @@ class AssertionsTest { have(type == "image") have(width >= 800) have(height >= 600) + mediaType should { + have(type == "image/png") + } } } } @@ -80,22 +100,44 @@ class AssertionsTest { @Test fun `Should fail when asserting on null object`() { val nullMessage: Message? = null - assertFailsWith { + val exception = assertFailsWith { nullMessage should {} - } should { - have(message == "actual value is null") } + assertEquals( + expected = "actual value is null", + actual = exception.message + ) } @Test fun `Should fail when asserting wrong instance type`() { - assertFailsWith { + val exception = assertFailsWith { message should { be() } - } should { - assertContains(message!!, "Expected value to be of type") } + assertContains(exception.message!!, "should: be of type") + } + + @Test + fun `Should fail when asserting wrong message id`() { + val exception = assertFailsWith { + message should { + have(id == 0) + } + } + assertEquals( + expected = """ + |Message(id=42, content=[Text(text=Hello there), Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/png))]) + | should: + |have(id == 0) + | | | + | | false + | 42 + | + """.trimMargin(), + actual = exception.message + ) } @Test @@ -105,15 +147,67 @@ class AssertionsTest { have(content.isEmpty()) } } - assertEquals(""" - | - |have(content.isEmpty()) - | | | - | | false - | [Text(text=Hello there), Image(path=image.png, width=1024, height=768)] - | + assertEquals( + expected = """ + |Message(id=42, content=[Text(text=Hello there), Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/png))]) + | should: + |have(content.isEmpty()) + | | | + | | false + | [Text(text=Hello there), Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/png))] + | + """.trimMargin(), + actual = exception.message + ) + } + + @Test + fun `Should fail when asserting wrong content type`() { + val exception = assertFailsWith { + message should { + content[0] should { + be() + } + } + } + assertEquals( + expected = $$""" + |Message(id=42, content=[Text(text=Hello there), Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/png))]) + | containing: + |Text(text=Hello there) + | should: be of type , actual + """.trimMargin(), + actual = exception.message + ) + } + + @Test + fun `Should fail when asserting wrong message-image-mediaType`() { + val exception = assertFailsWith { + message should { + content[1] should { + be() + mediaType should { + have(type == "image/jpeg") + } + } + } + } + assertEquals( + expected = """ + |Message(id=42, content=[Text(text=Hello there), Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/png))]) + | containing: + |Image(path=image.png, width=1024, height=768, mediaType=MediaType(type=image/png)) + | containing: + |MediaType(type=image/png) + | should: + |have(type == "image/jpeg") + | | | + | | false + | image/png + | """.trimMargin(), - exception.message + actual = exception.message ) }