Skip to content
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

Added lazy mountable extensions #3187

Merged
merged 4 commits into from Oct 2, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -27,11 +27,54 @@ interface MountableExtension<CONFIG, MATERIALIZED> : Extension {
fun mount(configure: CONFIG.() -> Unit): MATERIALIZED
}

/**
* A [LazyMountableExtension] is an [Extension] that can return a materialized value to the
* user and allows for a configuration block.
*
* This allows extensions to return control objects which differ from the extension itself.
*
* For example:
*
* class MyTest : FunSpec() {
* init {
* val kafka = install(EmbeddedKafka) {
* port = 9092
* }
* }
* }
*
* Here `kafka` is a materialized value that contains details of the host/port of the
* started kafka instance and `EmbeddedKafka` is the extension itself.
*
*/
interface LazyMountableExtension<CONFIG, MATERIALIZED> : Extension {
// cannot be suspending as it is invoked by install that is used in constructors
fun mount(configure: CONFIG.() -> Unit): LazyMaterialized<MATERIALIZED>
}

data class LazyMaterialized<MATERIALIZED>(private var value: MATERIALIZED) {

fun set(value: MATERIALIZED) {
this.value = value
}

fun get() = value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this not be protected from incorrect access during initialisation? It can only be safely accessed from TestScope? That actually depends on how the underlying Extension initialises it, right?

This would work for my use-case, but the API is rather unsafe.

In the PR with the original use-case here there is LazyMaterialized with suspend fun get() so it can only be accessed from suspend TestScope.() -> Unit. If it's access before it needs to be somehow in a suspend context and will get loaded lazily then. If it's accessed after clear/close is called (on afterSpec or similar) then it'll throw an IllegalStateException.

Should we have a similarly "safer" API? I.e. the example here Kafka also involves some heavy I/O, blocking code both on start and close.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, short-answer: yes this works.
Long-answer: should we try to make the API safer?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree the API isn't great, but I don't know how we can limit it to test scope, or even if we care about that, as long as it supports suspension.
Like you might want to initialize in a before spec listener. That's a suspendable context.
Let me think more on this.

Copy link
Member Author

@sksamuel sksamuel Oct 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I have followed your guidelines and it is much clearer now.

A LazyMountableExtension returns a LazyMaterialized which has a SAM.

interface LazyMaterialized<MATERIALIZED> {
   suspend fun get(): MATERIALIZED
}

Once get is invoked, the implementation can initialize if required.

Here is the example from the tests:

object : LazyMaterialized<String> {
  var state: String? = null
  override suspend fun get(): String {
    delay(1)
    if (state == null) state = "ready"
    return state ?: error("Must be initialized")
  }
}

}

// cannot be suspending as it is used in constructors
fun <CONFIG, MATERIALIZED> Spec.install(
mountable: MountableExtension<CONFIG, MATERIALIZED>,
ext: MountableExtension<CONFIG, MATERIALIZED>,
configure: CONFIG.() -> Unit = {}
): MATERIALIZED {
extensions(mountable)
return mountable.mount(configure)
extensions(ext)
return ext.mount(configure)
}

// cannot be suspending as it is used in constructors
fun <CONFIG, MATERIALIZED> Spec.install(
ext: LazyMountableExtension<CONFIG, MATERIALIZED>,
configure: CONFIG.() -> Unit = {},
): LazyMaterialized<MATERIALIZED> {
extensions(ext)
return ext.mount(configure)
}
@@ -0,0 +1,36 @@
package com.sksamuel.kotest.engine.extensions

import io.kotest.core.extensions.LazyMaterialized
import io.kotest.core.extensions.LazyMountableExtension
import io.kotest.core.extensions.install
import io.kotest.core.listeners.BeforeSpecListener
import io.kotest.core.spec.Spec
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.delay

class LazyMountableExtensionTest : FunSpec() {

private val mountable = MyLazyMountable()
private val m: LazyMaterialized<String> = install(mountable)

init {
test("lazy materialized values") {
m.get() shouldBe "ready"
}
}
}

class MyLazyMountable : LazyMountableExtension<Unit, String>, BeforeSpecListener {

private val m = LazyMaterialized("notready")

override suspend fun beforeSpec(spec: Spec) {
delay(5) // simulate slow db
m.set("ready")
}

override fun mount(configure: (Unit) -> Unit): LazyMaterialized<String> {
return m
}
}