Kotest Test Factories

Sometimes you may want to write a common set of tests and reuse it for specific inputs. In Kotest, you can do this with test factories, which generate tests that can be included in one or more specs.

Overview

Suppose you want to create your own collection library. This is a somewhat trivial example, but it works well for documentation purposes.

You could create an IndexedSeq interface that will have two implementations, List and Vector.

interface IndexedSeq<T> {

    // returns the size of t
    fun size(): Int

    // returns a new seq with t added
    fun add(t: T): IndexedSeq<T>

    // returns true if this seq contains t
    fun contains(t: T): Boolean
}

If you want to test the List implementation, you can do this:

class ListTest : WordSpec({

   val empty = List<Int>()

   "List" should {
      "increase size as elements are added" {
         empty.size() shouldBe 0
         val plus1 = empty.add(1)
         plus1.size() shouldBe 1
         val plus2 = plus1.add(2)
         plus2.size() shouldBe 2
      }
      "contain an element after it is added" {
         empty.contains(1) shouldBe false
         empty.add(1).contains(1) shouldBe true
         empty.add(1).contains(2) shouldBe false
      }
   }
})

Now, to test Vector, you would need to copy and paste the tests. As you add more implementations and more tests, the test suite is likely to become fragmented and fall out of sync.

This problem can be solved by creating a test factory that accepts IndexedSeq as a parameter.

To create a test factory, use builder functions such as funSpec, wordSpec, and so on. Builder functions exist for each spec style.

To convert the previous test into a test factory, do the following:

fun <T> indexedSeqTests(name: String, empty: IndexedSeq<T>) = wordSpec {
   name should {
      "increase size as elements are added" {
         empty.size() shouldBe 0
         val plus1 = empty.add(1)
         plus1.size() shouldBe 1
         val plus2 = plus1.add(2)
         plus2.size() shouldBe 2
      }
      "contain an element after it is added" {
         empty.contains(1) shouldBe false
         empty.add(1).contains(1) shouldBe true
         empty.add(1).contains(2) shouldBe false
      }
   }
}

Then, to use it, include it one or more times in a spec, or in multiple specs.

class IndexedSeqTestSuite : WordSpec({
   include(indexedSeqTests("vector"), Vector())
   include(indexedSeqTests("list"), List())
})

A test class can include several types of factories as well as regular inline tests. For example:

class HugeTestFile : FunSpec({

   test("first test") {
     // test here
   }

   include(factory1("foo"))
   include(factory2(1, 4))

   test("another test") {
     //  testhere
   }
})

Each included test appears in test output and reports as if it had been defined individually.

Listeners

Test factories support the usual before and after test callbacks. All callbacks added to a factory are added in order to the spec that includes that factory.

However, callbacks apply only to tests generated by that factory. In other words, you can create standalone factories with their own lifecycle methods and be confident that they do not conflict with lifecycle methods defined in other factories or specs.

For example:

val factory1 = funSpec {
  beforeTest {
     println("Executing $it")
  }
  test("a") {  }
  test("b") {  }
}

class LifecycleExample : FunSpec({
   include(factory1)
   test("c")
   test("d")
})

When the test suite runs, the following is printed:

Executing a
Executing b

As you can see, the beforeTest block added to factory1 applies only to tests defined in that factory, and not to tests defined in the spec where it is included.


References