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

Spek Extensions #115

Closed
raniejade opened this issue Sep 6, 2016 · 5 comments
Closed

Spek Extensions #115

raniejade opened this issue Sep 6, 2016 · 5 comments
Assignees
Milestone

Comments

@raniejade
Copy link
Member

raniejade commented Sep 6, 2016

Spek lacks support for integrating third party libraries (Spring, PowerMock, etc ...), this is one of main the reasons why people can't move out from the JUnit 4 based runner.

At a minimum we should allow extensions access to the execution lifecycle.

DSL

DSL classes are renamed and will take advantage of Kotlin/KEEP#57 (eventually - need to wait until Kotlin 1.1 is used widely) and Kotlin/KEEP#25.

@SpekDsl
interface TestBody

@SpekDsl
interface TestContainer {
    fun test(description: String, pending: Pending = Pending.No, body: TestBody.() -> Unit)
}

@SpekDsl
interface SpecBody {
    fun group(description: String, pending: Pending = Pending.No, body: SpecBody.() -> Unit)
    fun action(description: String, pending: Pending = Pending.No, body: ActionBody.() -> Unit)
    fun <T> memoized(mode: CachingMode = CachingMode.TEST, factory: () -> T): LifecycleAware<T>

    fun beforeEachTest(callback: () -> Unit)
    fun afterEachTest(callback: () -> Unit)
}

@SpekDsl
interface Spec: SpecBody {
    fun registerListener(listener: LifecycleListener)
}

Terminology

Scopes (blocks or whatever you want to call them)

interface Scope {
    val parent: GroupScope?
}

// describe, given
interface GroupScope: Scope
// on
interface ActionScope: GroupScope
// it
interface TestScope: Scope {
    override val parent: GroupScope
}

LifecycleListener

Extensions are basically LifecycleListeners, which can only be registered on the outer-most block of your spec.

interface LifecycleListener {
    fun beforeExecuteTest(test: TestScope) { }
    fun afterExecuteTest(test: TestScope) { }
    fun beforeExecuteGroup(group: GroupScope) { }
    fun afterExecuteGroup(group: GroupScope) { }
    fun beforeExecuteAction(action: ActionScope) { }
    fun afterExecuteAction(action: ActionScope) { }
}

Memoized values (LifecycleAware)

Values that are tied-up to your spec's lifecycle. To create one, use memoized(mode, factory).

interface LifecycleAware<T>: ReadOnlyProperty<LifecycleAware<T>, T> {
    operator fun invoke(): T
}

CachingMode Specifies how memoized values are cached. tests under action scopes will always share the same instance, regardless of what mode is chosen upon creation.

enum class CachingMode {
    /**
     * Instance will be shared within the group it was declared.
     */
    GROUP,
    /**
     * Each test will get their own unique instance.
     */
    TEST
}

Memoized values should only be used inside fixtures (beforeEachTest and afterEachTest), action and test scopes.

InstanceFactory

Introduced for a very specific use-case, to load a spec using a custom classloader (PowerMock, Robolectric, etc..). It should support class and object (#109) instances. Annotate to your spec with @CreateWith(MyInstanceFactory::class).

interface InstanceFactory {
    fun <T: Spek> create(spek: KClass<T>): T
}

Sample Extensions

Subjects

Reworked subject support as an extension (requires #143).

API

DSL
interface SubjectDsl<T>: Spec {
    val subject: T

    fun subject(): LifecycleAware<T>
}
interface SubjectProviderDsl<T>: SubjectDsl<T> {
    fun subject(mode: CachingMode = CachingMode.TEST, factory: () -> T): LifecycleAware<T>
}
Custom Spek class
abstract class SubjectSpek<T>(val subjectSpec: SubjectProviderDsl<T>.() -> Unit): Spek({
    subjectSpec.invoke(SubjectProviderDslImpl(this))
})
Shared subjects
infix fun <T, K: T> SubjectDsl<K>.itBehavesLike(spec: SubjectSpek<T>) {
    include(Spek.wrap {
        val value: SubjectProviderDsl<T> = object: SubjectProviderDsl<T>, Spec by this {
            val adapter = object: LifecycleAware<T> {
                override fun getValue(thisRef: LifecycleAware<T>, property: KProperty<*>): T {
                    return this()
                }

                override fun invoke(): T {
                    return this@itBehavesLike.subject().invoke()
                }

            }

            override fun subject() = adapter
            override fun subject(mode: CachingMode, factory: () -> T) = adapter
            override val subject: T
                get() = adapter()

        }
        spec.spec(value)
    })
}

Internals

abstract class SubjectDslImpl<T>(val root: Spec): SubjectDsl<T>, Spec by root
internal class SubjectProviderDslImpl<T>(spec: Spec): SubjectDslImpl<T>(spec), SubjectProviderDsl<T> {
    var adapter: LifecycleAware<T> by Delegates.notNull()
    override val subject: T
        get() = adapter()

    override fun subject(): LifecycleAware<T> = adapter

    override fun subject(mode: CachingMode, factory: () -> T): LifecycleAware<T> {
        return memoized(mode, factory).apply {
            adapter = this
        }
    }
}

Usage

Just extend org.jetbrains.spek.subject.SubjectSpek instead of Spek.

CalculatorSpec

package org.jetbrains.spek.samples.subject

import com.natpryce.hamkrest.assertion.assertThat
import com.natpryce.hamkrest.throws
import org.jetbrains.spek.api.dsl.context
import org.jetbrains.spek.api.dsl.describe
import org.jetbrains.spek.api.dsl.it
import org.jetbrains.samples.Calculator
import org.jetbrains.spek.subject.SubjectSpek
import kotlin.test.assertEquals

object CalculatorSpec: SubjectSpek<Calculator>({
    subject { Calculator() }

    describe("addition") {
        it("should return the result of adding the first number to the second number") {
            val sum = subject.add(2, 4)
            assertEquals(6, sum)
        }
    }

    describe("subtract") {
        it("should return the result of subtracting the second number from the first number") {
            val subtract = subject.subtract(4, 2)
            assertEquals(2, subtract)
        }
    }

    describe("division") {
        it("should return the result of dividing the first number by the second number") {
            assertEquals(2, subject.divide(4, 2))
        }

        context("division by zero") {
            it("should throw an exception") {
                assertThat({
                    subject.divide(2, 0)
                }, throws<IllegalArgumentException>())
            }
        }
    }
})

AdvancedCalculatorSpec

package org.jetbrains.spek.samples.subject

import org.jetbrains.samples.AdvancedCalculator
import org.jetbrains.spek.api.dsl.describe
import org.jetbrains.spek.api.dsl.it
import org.jetbrains.spek.subject.SubjectSpek
import org.jetbrains.spek.subject.itBehavesLike
import kotlin.test.assertEquals

object AdvancedCalculatorSpec: SubjectSpek<AdvancedCalculator>({
    subject { AdvancedCalculator() }
    itBehavesLike(CalculatorSpec)

    describe("pow") {
        it("should return the power of base raise to exponent") {
            assertEquals(subject.pow(2, 2), 4)
        }
    }
})

Mockito

inline fun <reified T> SpecBody.mock(mode: CachingMode = CachingMode.TEST) {
	return memoized(mode) {
    	    Mockito.mock(T::class.java)
        }
}

Usage:

object MySpec: Spek({
    val mock: Foo by mock() // kotlin 1.1
    // val mock: Foo = mock()
    
    it("should do something") {
       verify(mock).someMethod() // kotlin 1.1
       // verify(mock()).someMethod() 
    }
})

Some DI framework

class Injector(configuration: Configuration, scope: SpecBody) {
	private val context = Context(configuration)
	operator fun <reified T> invoke(mode: CachingMode = CachingMode.TEST): LifecycleAware<T> {
        return scope.memoized(mode) {
            context.inject(T::class.java)
        }
    }
}

inline fun SpecBody.injector(configuration: Configuration) = Injector(configuration, this)

Usage

class UserServiceSpec: SubjectSpek<UserService>({
    val injector = injector(SomeDefaultConfiguration())
    val db: DB by injector()
    
    subject {
    	DefaultUserService(db)
    }
})
@mfulton26
Copy link

Suggestions/Questions:

  1. Remove beforeExecuteSpec/afterExecuteSpec in lieu of beforeExecuteGroup/afterExecuteGroup. A specification is a group, correct?
  2. s/ExtensionContext/Context/g. Can "TestExtensionContext" simply be "TestContext"? I realize there may be internal context types with the same names but if they are not public then I don't believe "ExtensionContext" is any more clear than simply "Context" as far as the public API is concerned.
  3. I can't think of any use cases for it but might there be a need to have an extension that is not applied to an entire spec? i.e. Should extension points be allowed on individual groups and/or tests?
  4. I can think of some extensions that if you have it on your classpath then you want it to apply to all specifications (e.g. additional metrics gathering, etc.). I like how JUnit 5 test engines tell the framework what it executes instead of the user specifying @RunWith on everything. Could Spek extensions support something similar? Then it would be up to such extensions to determine what criteria it uses for conditionally applying the extension or not (e.g. annotations, throwable types, etc.).

@raniejade
Copy link
Member Author

Remove beforeExecuteSpec/afterExecuteSpec in lieu of beforeExecuteGroup/afterExecuteGroup. A specification is a group, correct?

Makes sense, I did it that way to differentiate normal "groups" and a specification. If it doesn't have any use case I'm glad to remove it.

s/ExtensionContext/Context/g. Can "TestExtensionContext" simply be "TestContext"? I realize there may be internal context types with the same names but if they are not public then I don't believe "ExtensionContext" is any more clear than simply "Context" as far as the public API is concerned.

Actually, internally we use the term scopes, should we use that instead?

I can't think of any use cases for it but might there be a need to have an extension that is not applied to an entire spec? i.e. Should extension points be allowed on individual groups and/or tests?

One way we can do this is adding support for tags. Based on the tags present, the extension can apply itself or just ignore that group/test.

I can think of some extensions that if you have it on your classpath then you want it to apply to all specifications (e.g. additional metrics gathering, etc.). I like how JUnit 5 test engines tell the framework what it executes instead of the user specifying @RunWith on everything. Could Spek extensions support something similar? Then it would be up to such extensions to determine what criteria it uses for conditionally applying the extension or not (e.g. annotations, throwable types, etc.).

I still prefer being explicit on what extensions to apply. Specifications will be way too fragile if we automatically apply them, there might be instances where conflicts may happen.

@raniejade
Copy link
Member Author

I've added a sample extension for some mocking framework.

@raniejade
Copy link
Member Author

Finally got some time to work on this, initial proposal is now obsolete. Basically there will be no more annotations, we will just expose a way to listen to Spek's lifecycle. I also changed subject to be purely an extension. Will update the description for more details about the change.

@raniejade raniejade self-assigned this Dec 4, 2016
@raniejade raniejade added this to the 1.1 milestone Dec 4, 2016
@raniejade
Copy link
Member Author

I've updated the description, opinions please? :)

raniejade added a commit that referenced this issue Jan 1, 2017
This introduces extensions in Spek, see #115 on how to implement one.

It also includes the following changes:

- Renamed several DSL classes (breaking change)
- Reworked subject support as an extension
- Rebrand lazy groups to action groups
FaustXVI pushed a commit to FaustXVI/spek that referenced this issue Jan 4, 2017
This introduces extensions in Spek, see spekframework#115 on how to implement one.

It also includes the following changes:

- Renamed several DSL classes (breaking change)
- Reworked subject support as an extension
- Rebrand lazy groups to action groups
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants