Lightweight library suggesting a TDD implementation with Kotlin
Kotlin-TDD provides a way to write your unit test (or acceptance test) as you will write an acceptance criteria in natural english language.
Write your test by following the Given When Then pattern.
import io.github.ludorival.kotlintdd.SimpleGivenWhenThen.given
class MyTest {
@Test
fun `I can write my test with my custom DSL`() {
given {
1
} and {
2
} `when` {
`I perform their sum`
} then {
`I expect the result is`(3)
}
}
}
Or by using the Assume Act Assert pattern
import io.github.ludorival.kotlintdd.SimpleAssumeActAssert.assume
class MyTest {
@Test
fun `I can write my test with my custom DSL`() {
assume {
`the number`(1)
} and {
`the number`(2)
} act {
`I perform their sum`
} assert {
`I expect the result is`(3)
}
}
}
The library supports also coroutine functions.
import io.github.ludorival.kotlintdd.coroutine.CoSimpleGivenWhenThen.given
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@OptIn(ExperimentalCoroutinesApi::class)
class MyTest {
@Test
fun `I can write my Coroutine test with my custom DSL`() = runTest {
given {
1
} and {
2
} `when` {
`I perform their sum`
} then {
`I expect the result is`(3)
}
}
}
Kotlin-TDD is available via Maven Central. Just add the dependency to your Maven POM or Gradle build config.
Gradle
testImplementation "io.github.ludorival:kotlin-tdd:$kotlintddVersion"
Maven
<dependency>
<groupId>io.github.ludorival</groupId>
<artifactId>kotlin-tdd</artifactId>
<version>${kotlintddVersion}</version>
<scope>test</scope>
</dependency>
Kotlin-TDD is not just a simple list of new functions for TDD. It has also a context management between steps without stored anything statically. Indeed, each step stores the result as its field, and it is linked to the previous step. It allows to:
- remove completely the need to create intermediary variable
- navigate between the different results obtained after each step
- focus on test readability, and the effort to have organized test
Let's compare with different approaches when writing tests:
Basic test | Kotlin-TDD test | Cucumber test |
---|---|---|
@Test
fun shouldInsertANewItemInTodoList() {
// Given
val todo = TodoList()
val item = Item("Eat banana")
// When
val addedItem = todo.add(item)
// Then
assertTrue(todo.contains(item))
} |
@Test
fun `I should be able to insert a new item in my todo list`() {
given {
TodoList()
} and {
Item("Eat banana")
} `when` {
first<TodoList>().add(it)
} then {
assertTrue(first<TodoList>().contains(first<Item>()))
}
} |
Scenario: I should be able to insert a new item in my todo list
GIVEN a new Todo list
AND the following item to add "Eat banana"
WHEN I add this item in my todo list
THEN I expect this item is well present in my todo list |
|
|
|
Kotlin-TDD provides a useful mechanism to save the test context without managing static values.
Steps can be organized by three contexts:
- Assumption: a list of operations to create assumptions
- Action: a list of operations to mutate assumptions to a result
- Assertion: a list of operations to verify the result
As you can see, Kotlin TDD has its own data structure closed to a Linked list to manage the context. There are some useful operations what you can do inside a context.
After a step declaration, you can get the result from the previous step with result
.
Example :
@Test
fun `I can use the previous result`() {
given {
1
} `when` {
it + 2 // result is 1
} then {
assertEquals(3, it) // result is 3
}
}
From a step, you can access to the first result matching a given type with first<T>
.
Example :
@Test
fun `I can access to the first result`() {
given {
"a string"
} and {
1 // <-- the first Int
} and {
2
} `when` {
first<Int>() + it // first<Int>() -> 1
} then {
assertEquals(3, it) // result is 3
}
}
This is possible to pass a predicate function as argument to check which is the first result matching this predicate.
@Test
fun `I can access to the first result matching a predicate`() {
given {
1
} and {
2 // <-- first int greater than 1
} and {
3
} `when` {
first<Int> { it > 1 } + it// first<Int>{ it > 1 } -> 2
} then {
assertEquals(2 + 3, it) // result is 5
}
}
firstOrNull<T>
does the same except it can return a nullable value if any type and predicate match
This is the reverse operation than first<T>
described above.
Example :
@Test
fun `I can access to the last integer result`() {
given {
1 // <-- the first Int
} and {
2 // <-- the last Int
} and {
"a string"
} `when` {
first<Int>() + last<Int>() // 1 + 2
} then {
assertEquals(3, it) // result is 3
}
}
You can also pass a predicate function:
@Test
fun `I can access to the last result matching a predicate`() {
given {
1
} and {
2 // <-- last int lower than 3
} and {
3
} `when` {
it + last<Int> { it < 3 } // 3 + 2
} then {
assertEquals(3 + 2, it) // result is 5
}
}
If you know exactly in which step to get your result, you can use get<T>(index)
to access
Example
@Test
fun `I can access to a result thanks to its position`() {
given {
1 // index 0
} and {
println("Unit result are skipped") // skipped
} and {
"Hello" // index 1
} and {
3.5 // index 2
} `when` {
println(get<String>(1)) // print "Hello"
}
}
β οΈ All seen operations and the next one always skip Unit result type from their response.
You can get all previous results matching a given type thanks to results<T>()
function.
Example
@Test
fun `I can access to all integer results from top to bottom`() {
given {
1
} and {
"Hello"
} and {
2
} `when` {
results<Int>().reduce(Int::plus) // results -> [1, 2]
} then {
assertEquals(3, it)
}
}
The same operation is possible in reverse mode:
Example
@Test
fun `I can access to all integer results from bottom to top`() {
given {
1
} and {
"Hello"
} and {
2
} `when` {
reversedRsults<Int>().reduce(Int::plus) // results -> [2, 1]
} then {
assertEquals(3, it)
}
}
anyResults
andresults<Any>
are equivalent and allow to return all results except Unit
It can be convenient to factorize any built context in different tests.
Imagine that you have two tests using the same snippet code
@Test
fun test1() {
given {
1
} and {
2
} `when` {
action.sum(results) // 1 + 2
} then {
assertEquals(1 + 2, it)
}
}
@Test
fun test2() {
given {
1
} and {
2
} and {
3
} `when` {
action.sum(results) // 1 + 2 + 3
} then {
assertEquals(1 + 2 + 3, result)
}
}
Let's create a function which factorize common code:
fun commonContext() =
given {
1
} and {
2
}
@Test
fun test1() {
given {
commonContext()
} `when` {
action.sum(results) // 1 + 2
} then {
assertEquals(1 + 2, it)
}
}
@Test
fun test2() {
given {
commonContext()
} and {
3
} `when` {
action.sum(results) // 1 + 2 + 3
} then {
assertEquals(1 + 2 + 3, it)
}
}
All nested context will be merged into the current context.
With this approach, you will be able to factorize common assumptions in your test code. They can also be shared between acceptance tests and unit test.
Kotlin-TDD allows to have access to an Assumption, Action and * Assertion* instance in each step. This is very convenient to organize your tests in function of what it produces. Let's create a new class Assumption for example
class Assumption : WithContext() {
val `a todo list` get() = TodoList()
fun `an item`(name: String) = TodoList.Item(name)
}
An Action class
class Action : WithContext() {
fun sum(value1: Int, value2: Int) = value1 + value2
fun sum(list: List<Int>) = list.reduce(Int::plus)
fun divide(list: List<Int>) = list.reduce(Int::div)
}
And an Assumption class
According to your flavor (GWT or AAA pattern), you will have to implement a dedicated interface.
Given When Then
Create a file named UnitTest.kt
for example and extends the class GivenWhenThen
:
// UnitTest.kt
object UnitTest : GivenWhenThen<Assumption, Action, Assertion>(
assumption = Assumption(),
action = Action(),
assertion = Assertion()
)
// defines the entrypoint on file-level to be automatically recognized by your IDE
fun <R> given(block: Assumption.() -> R) = UnitTest.given(block)
fun <R> `when`(block: Action.() -> R) = UnitTest.`when`(block)
Assume Act Assert
This time you will have to extend the class AssumeActAssert
:
// UnitTest.kt
object UnitTest : AssumeActAssert<Assumption, Action, Assertion>(
assumption = Assumption(),
action = Action(),
assertion = Assertion()
)
// defines the entrypoint on file-level to be automatically recognized by your IDE
fun <R> assume(block: AAAContext<Action, Unit>.() -> R) = UnitTest.assume(block)
fun <R> act(block: AAAContext<Action, Unit>.() -> R) = UnitTest.act(block)
In the various examples we saw, the step do not write in a natural language. Thanks to powerful extendability of Kotlin, we can provide our custom DSL.
Here is the example without DSL of the TodoList test.
@Test
fun `I should be able to insert a new item in my todo list`() {
given {
TodoList()
} and {
Item("Eat banana")
} `when` {
first<TodoList>().add(it)
} then {
assertTrue(first<TodoList>().contains(first<Item>()))
}
}
We can rewrite it with a custom DSL
@Test
fun `I should be able to insert a new item in my todo list`() {
given {
`a todo list`
} and {
`an item`("Eat banana")
} `when` {
`I add the last item into my todo list`
} then {
`I expect this item is present in my todo list`
}
}
And your DSL can be written like this
// Assumption.kt
class Assumption : WithContext() {
val `a todo list` get() = TodoList()
fun `an item`(name: String) = Item(name)
}
// Action.kt
class Action : WithContext() {
val `I add the last item into my todo list`
get() =
last<TodoList>().items.add(last())
}
// Assertion.kt
class Assertion : WithContext() {
val `I expect this item is present in my todo list`
get() = Assertions.assertTrue {
last<TodoList>().items.contains(
last()
)
}
}
MIT License