Skip to Content
Course content

154: Behavior-Driven Testing with Kotest Specs

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When I first started using Kotest, I treated BehaviorSpec like a standard JUnit test class. I figured I could just instantiate my objects at the top of the spec and call it a day. It seems intuitive, but in the world of BDD (Behavior-Driven Development) specs, this is a recipe for "flaky" tests that pass in isolation but fail when run as a suite. Let me show you what I mean.

class ShoppingCartSpec : BehaviorSpec({
    val cart = ShoppingCart() // The " innocent" mistake

    Given("a shopping cart") {
        When("an item is added") {
            cart.addItem("Kotlin in Action")
            Then("the cart should contain 1 item") {
                cart.size shouldBe 1
            }
        }

        When("another item is added") {
            cart.addItem("Clean Code")
            Then("the cart should contain 1 item") {
                // This fails! It actually contains 2 items.
                cart.size shouldBe 1 
            }
        }
    }
})

If you run the first When block, it passes. But the second one fails miserably. Why? Because val cart was initialized once when the spec was created. The first test modified the cart object, and since that same instance is shared, the second test inherits the state from the first. This is a state leak, and it's one of the most frustrating bugs to debug in a large test suite.

The Shared State Trap

The problem here is a misunderstanding of how Kotest's BehaviorSpec handles its lifecycle. In a BDD spec, the Given, When, and Then blocks aren't just labels; they are nested scopes. However, variables declared in the outer scope of the BehaviorSpec constructor are effectively singletons for the duration of that spec's execution.

You might think, "Why not just call cart.clear() at the start of every block?" You could, but that's manual labor that leads to more bugs. If you forget to clear the state in just one test, you've reintroduced the leak. We want our tests to be isolated.

Isolating State within the Given Block

The fix is to move the instantiation inside the Given block. In Kotest, the Given block is designed to set up the context for the behaviors that follow. By moving the variable inside, we ensure that we are defining the state within the scope of the behavior.

class ShoppingCartSpec : BehaviorSpec({
    Given("a shopping cart") {
        // Now the cart is scoped to this Given block
        val cart = ShoppingCart() 

        When("an item is added") {
            cart.addItem("Kotlin in Action")
            Then("the cart should contain 1 item") {
                cart.size shouldBe 1
            }
        }

        When("another item is added") {
            // Wait—this still feels wrong, right?
            // If we want a FRESH cart for every 'When', 
            // we need to rethink the nesting.
        }
    }
})

Actually, I just walked you into a second trap. Even inside the Given block, if you have multiple When blocks, they are still sharing that one cart instance because they are siblings under the same Given. To truly isolate every single scenario, you have two choices: create the object inside the When block, or change the IsolationMode.

I prefer using IsolationMode.InstancePerLeaf. This tells Kotest to create a brand new instance of the entire spec class for every single Then block. It's the "nuclear option" for isolation, and it makes your tests incredibly robust.

class ShoppingCartSpec : BehaviorSpec({
    isolationMode = IsolationMode.InstancePerLeaf

    val cart = ShoppingCart() // Now this is safe!

    Given("a shopping cart") {
        When("an item is added") {
            cart.addItem("Kotlin in Action")
            Then("the cart should contain 1 item") {
                cart.size shouldBe 1
            }
        }

        When("another item is added") {
            cart.addItem("Clean Code")
            Then("the cart should contain 1 item") {
                cart.size shouldBe 1 // Now this passes!
            }
        }
    }
})

By setting the isolationMode, Kotest effectively clones the environment for every single leaf node (the Then blocks). Now, you can write your BDD specs naturally without worrying about whether a previous test left a "mess" in your objects.




📋 Practical Task

Implementing a User Permission System with BehaviorSpec

You need to test a PermissionManager class. This class has a method grantPermission(user: String, permission: String) and hasPermission(user: String, permission: String): Boolean.

Build a BehaviorSpec test suite that validates the following behaviors:

  • Given an empty permission manager, when a permission is granted to "Alice", then Alice should have that permission.
  • Given an empty permission manager, when a permission is granted to "Alice", then "Bob" should NOT have that permission.
  • Given a permission manager where Alice already has "READ" access, when she is granted "WRITE" access, she should now have both permissions.

Constraint: You must use IsolationMode.InstancePerLeaf to ensure that the permissions granted in one test case do not leak into the others, and your tests must follow the Given -> When -> Then structure strictly.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.