diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7755f7f..3bd45d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ parameterize = "0.4.1" # https://github.com/BenWoodworth/Parameterize/rel ktor = "3.5.1" # https://ktor.io/docs/releases.html#release-details -kotest = "6.2.2" # https://github.com/kotest/kotest/releases +kotest = "6.2.3" # https://github.com/kotest/kotest/releases kti = { strictly = "0.1.0" } # https://gitlab.com/opensavvy/groundwork/kotlin-test-initiative/-/releases diff --git a/runners/runner-kotest/README.md b/runners/runner-kotest/README.md index d6e11f8..a79aa54 100644 --- a/runners/runner-kotest/README.md +++ b/runners/runner-kotest/README.md @@ -6,6 +6,8 @@ Execute Prepared tests alongside tests written using Kotest. Prepared tests are declared using the helper [preparedSuite][opensavvy.prepared.runner.kotest.preparedSuite]. +It integrates with Kotest using its [custom spec style](https://kotest.io/docs/framework/custom-styles.html) API. + ```kotlin class FooTest : StringSpec({ // Declare tests normally using the Kotest syntax diff --git a/runners/runner-kotest/src/commonMain/kotlin/PreparedSpec.kt b/runners/runner-kotest/src/commonMain/kotlin/PreparedSpec.kt index f8f3001..77cbd85 100644 --- a/runners/runner-kotest/src/commonMain/kotlin/PreparedSpec.kt +++ b/runners/runner-kotest/src/commonMain/kotlin/PreparedSpec.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025, OpenSavvy and contributors. + * Copyright (c) 2024-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,9 +16,166 @@ package opensavvy.prepared.runner.kotest -import io.kotest.core.spec.style.StringSpec +import io.kotest.common.KotestInternal +import io.kotest.core.names.TestNameBuilder +import io.kotest.core.spec.AbstractSpec +import io.kotest.core.spec.TestDefinition +import io.kotest.core.spec.TestDefinitionBuilder +import io.kotest.core.spec.style.TestRunnable +import io.kotest.core.test.AbstractTestScope +import io.kotest.core.test.TestScope +import io.kotest.core.test.TestType +import io.kotest.engine.coroutines.coroutineTestScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import opensavvy.prepared.runner.kotest.KotestSuiteDsl.Companion.suiteToTestDefinition +import opensavvy.prepared.runner.kotest.KotestSuiteDsl.Companion.testToTestDefinition import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.TestDsl +import opensavvy.prepared.suite.config.* +import opensavvy.prepared.suite.runTestDslSuspend -abstract class PreparedSpec(body: SuiteDsl.() -> Unit) : StringSpec({ - preparedSuite { body() } -}) +/** + * Declares a Prepared test suite using the Kotest framework. + * + * ### Example + * + * ```kotlin + * class MyTest : PreparedSpec({ + * test("A regular test") { + * // … + * } + * + * suite("A suite") { + * test("Another test") { + * // … + * } + * } + * }) + * ``` + * + * @see SuiteDsl Declaring tests + */ +abstract class PreparedSpec( + body: PreparedSpec.() -> Unit, + private val config: TestConfig = TestConfig.Empty, +) : AbstractSpec(), SuiteDsl { + + init { + body() + } + + @TestRunnable + override fun suite( + name: String, + config: TestConfig, + block: SuiteDsl.() -> Unit, + ) { + add( + suiteToTestDefinition( + name = name, + config = this@PreparedSpec.config + config, + block = block, + ) + ) + } + + @TestRunnable + override fun test( + name: String, + config: TestConfig, + block: suspend TestDsl.() -> Unit, + ) { + add( + testToTestDefinition( + name = name, + config = this@PreparedSpec.config + config, + block = block, + ) + ) + } +} + +/** + * An implementation of Prepared's [SuiteDsl] that is recognized by the [Kotest IntelliJ plugin](https://kotest.io/docs/intellij/intellij-plugin.html). + * + * To create an instance of this class, use [PreparedSpec.suite] or [preparedSuite]. + */ +class KotestSuiteDsl internal constructor( + private val delegate: TestScope, + private val parentConfig: TestConfig, +) : AbstractTestScope(delegate), SuiteDsl { + + @TestRunnable + override fun suite( + name: String, + config: TestConfig, + block: SuiteDsl.() -> Unit, + ) { + launch(Dispatchers.Unconfined) { + registerTest( + suiteToTestDefinition( + name = name, + config = this@KotestSuiteDsl.parentConfig + config, + block = block, + ) + ) + } + } + + @TestRunnable + override fun test( + name: String, + config: TestConfig, + block: suspend TestDsl.() -> Unit, + ) { + launch(Dispatchers.Unconfined) { + registerTest( + testToTestDefinition( + name = name, + config = this@KotestSuiteDsl.parentConfig + config, + block = block, + ) + ) + } + } + + @OptIn(KotestInternal::class) + companion object { + + internal fun suiteToTestDefinition( + name: String, + config: TestConfig, + block: KotestSuiteDsl.() -> Unit, + ): TestDefinition = + TestDefinitionBuilder + .builder(TestNameBuilder.builder(name).build(), TestType.Container) + .build { + KotestSuiteDsl( + delegate = this, + parentConfig = config, + ).block() + } + + internal fun testToTestDefinition( + name: String, + config: TestConfig, + block: suspend TestDsl.() -> Unit, + ): TestDefinition { + val kotestConfig = io.kotest.core.test.config.TestConfig( + enabled = config[Ignored] == null, + tags = config[Tag] + .mapTo(HashSet()) { io.kotest.core.Tag(it.name) }, + coroutineTestScope = true, + coroutineDebugProbes = true, + ) + + return TestDefinitionBuilder + .builder(TestNameBuilder.builder(name).build(), TestType.Test) + .withConfig(kotestConfig) + .build { + coroutineTestScope.runTestDslSuspend(name, config, block) + } + } + } +} diff --git a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt index a955865..cfde41f 100644 --- a/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt +++ b/runners/runner-kotest/src/commonMain/kotlin/PreparedSuite.kt @@ -16,23 +16,29 @@ package opensavvy.prepared.runner.kotest -import io.kotest.core.names.TestName -import io.kotest.core.spec.KotestTestScope +import io.kotest.common.KotestInternal +import io.kotest.core.spec.AbstractSpec import io.kotest.core.spec.style.StringSpec -import io.kotest.core.spec.style.TestXMethod +import io.kotest.core.spec.style.TestRunnable import io.kotest.core.spec.style.scopes.RootScope -import io.kotest.core.spec.style.scopes.addTest -import io.kotest.engine.coroutines.coroutineTestScope +import opensavvy.prepared.runner.kotest.KotestSuiteDsl.Companion.suiteToTestDefinition import opensavvy.prepared.suite.SuiteDsl -import opensavvy.prepared.suite.TestDsl -import opensavvy.prepared.suite.config.* -import opensavvy.prepared.suite.runTestDslSuspend +import opensavvy.prepared.suite.config.TestConfig + +@OptIn(KotestInternal::class) +@Deprecated("The preparedSuite function's receiver has been changed from RootScope to AbstractSpec. Your code should not need to be updated. If you had a RootScope that isn't an AbstractSpec, please contact the Prepared maintainers.", level = DeprecationLevel.HIDDEN) +fun RootScope.preparedSuite( + config: TestConfig = TestConfig.Empty, + block: SuiteDsl.() -> Unit, +) { + // To our knowledge, there are no RootScope instances that aren't also AbstractSpec instances, so this should be safe for any existing user. + (this as AbstractSpec).preparedSuite(config, block) +} /** - * Executes a Prepared [SuiteDsl] in a Kotest suite. + * Executes a Prepared [SuiteDsl] in an existing Kotest suite. * - * Since Kotest cannot represent nested non-suspending test suites, all nested suites are declared at the top-level - * of the provided [RootScope]. The name of the suite is appended at the start of the tests, so the suite structure is not lost. + * To create a Prepared-specific suite, see [PreparedSpec]. * * ### Example * @@ -56,64 +62,16 @@ import opensavvy.prepared.suite.runTestDslSuspend * }) * ``` */ -@KotestTestScope -fun RootScope.preparedSuite( +@TestRunnable +fun AbstractSpec.preparedSuite( config: TestConfig = TestConfig.Empty, - block: SuiteDsl.() -> Unit, + block: KotestSuiteDsl.() -> Unit, ) { - NonNestedSuite(this, config).block() -} - -private class NonNestedSuite(private val root: RootScope, private val parentConfig: TestConfig, private val prefix: String? = null) : SuiteDsl { - override fun suite(name: String, config: TestConfig, block: SuiteDsl.() -> Unit) { - NonNestedSuite(root, parentConfig + config, prefix child name).block() - } - - override fun test(name: String, config: TestConfig, block: suspend TestDsl.() -> Unit) { - val thisConfig = parentConfig + config - - val kotestConfig = io.kotest.core.test.config.TestConfig( - enabled = thisConfig[Ignored] == null, - tags = config[Tag] - .mapTo(HashSet()) { io.kotest.core.Tag(it.name) }, - coroutineTestScope = true, - coroutineDebugProbes = true, + add( + suiteToTestDefinition( + name = "Prepared", + config = config, + block = block, ) - - val testName = prefix child name - - root.addTest( - testName = TestName( - name = testName, - focus = testName.startsWith(FOCUS_PREFIX), - bang = testName.startsWith(BANG_PREFIX), - prefix = null, - suffix = null, - defaultAffixes = false, - ), - config = kotestConfig, - xmethod = when { - testName.startsWith(FOCUS_PREFIX) -> TestXMethod.FOCUSED - testName.startsWith(BANG_PREFIX) -> TestXMethod.DISABLED - else -> TestXMethod.NONE - }, - ) { - coroutineTestScope.runTestDslSuspend(name, config, block) - } - } + ) } - -private const val FOCUS_PREFIX = "f:" -private const val BANG_PREFIX = "!" - -/** - * Appends [name] at the end of `this`, handling the case where `this` is `null`. - */ -private infix fun String?.child(name: String) = - when { - // See https://kotest.io/docs/framework/conditional/conditional-tests-with-focus-and-bang.html - this != null && name.startsWith(FOCUS_PREFIX) -> "$FOCUS_PREFIX$this • $name" - this != null && name.startsWith(BANG_PREFIX) -> "$BANG_PREFIX$this • $name" - this != null -> "$this • $name" - else -> name - } diff --git a/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt b/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt index e8a6c6b..55bfa37 100644 --- a/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt +++ b/runners/runner-kotest/src/commonTest/kotlin/KotestTest.kt @@ -64,6 +64,12 @@ class KotestTest : StringSpec({ }) class KotestTest2 : PreparedSpec({ + suite("Another") { + test("This is a specific test") { + + } + } + suite("Test") { test("Test") { check(true)