Skip to Content
Course content

50: Testing Kotlin Code with Kotest

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

Imagine you've just hired a building inspector to check out a house you're buying. A bad inspector walks through the front door, looks around for five minutes, and says, "Yeah, looks fine." That's useless. A great inspector has a checklist of specific expectations: "The kitchen faucet should not leak," "The circuit breaker should trip when overloaded," and "The front door should lock from the inside."

That's exactly what we're doing with Kotest. Standard JUnit tests often feel like that "looks fine" inspector—they're functional, but they're rigid and sometimes a bit clinical. Kotest, on the other hand, allows us to write tests as a set of specifications. We aren't just checking if a function returns true; we're describing how the system should behave in plain English.

Let's map this: the "checklist item" is your test description, the "inspection" is the code execution, and the "pass/fail" is the assertion. Instead of assertEquals(expected, actual), which feels like a math equation, Kotest uses actual shouldBe expected, which feels like a requirement.

Writing Tests That Read Like Sentences

One of the first things you'll notice about Kotest is that it doesn't force you into one way of writing tests. It provides "Styles." While there are many, I usually lean toward StringSpec for most of my projects because it's clean and gets out of the way.

Let's say we're building a ShoppingCart. We don't want a test called testCalc(). We want something descriptive. Look at how this feels compared to traditional testing:

class ShoppingCartTest : StringSpec({
    "shopping cart should calculate the total price of all items" {
        val cart = ShoppingCart()
        cart.addItem(Item("Kotlin Book", 29.99))
        cart.addItem(Item("Coffee Mug", 12.50))

        cart.totalPrice() shouldBe 42.49
    }

    "shopping cart should be empty when initialized" {
        val cart = ShoppingCart()
        cart.items shouldBe emptyList()
    }
})

Notice how the test name is just a string? I can write exactly what I'm expecting. If this fails in a CI/CD pipeline, the error message tells me exactly which business requirement was broken, not just that testCalc() failed.

Ditching the Boilerplate with Matchers

I've spent way too many years staring at assertTrue(list.contains(item)). It's clunky. Kotest's matchers are designed to be intuitive. They allow you to express intent clearly.

If you're checking a list of products, you don't have to loop through them or check sizes manually. You can use a variety of "should" statements that make your intent obvious to anyone reading your code:

// Instead of assertEquals(3, products.size)
products shouldHaveSize 3

// Instead of assertTrue(products.contains(laptop))
products shouldContain laptop

// Checking for nulls or types is just as fluid
paymentMethod.shouldNotBeNull()
paymentMethod shouldBeInstanceOf CreditCard::class

It might feel like "syntactic sugar" at first, but when you're debugging a failing test at 2 AM, having a failure message that says "Collection should contain 'Laptop' but was ['Mouse', 'Keyboard']" is a lifesaver compared to "Expected true but was false".

Testing the Edge Cases with Property-Based Testing

Here is where Kotest really leaves other frameworks in the dust: Property-Based Testing. Usually, we pick a few "golden" numbers to test—like 0, 1, and -1. But what if the bug only happens when the input is 1,452,382? You'd never think to test that manually.

With Kotest's Arb (Arbitrary) generators, we can tell the framework: "I don't care what the number is; as long as it's a positive integer, this logic should always hold true."

class DiscountTest : StringSpec({
    "discount should never make the price negative regardless of the input" {
        checkAll(Arb.double(0.0..10000.0), Arb.double(0.0..1.0)) { price, discountPercent ->
            val finalPrice = PriceCalculator.applyDiscount(price, discountPercent)
            finalPrice shouldBeGreaterThanOrEqual 0.0
        }
    }
})

In the example above, Kotest will run that test 1,000 times with random combinations of prices and percentages. It's like hiring a chaotic monkey to try and break your code so that your actual users don't have to.




📋 Practical Task

Implementing a Loyalty Points Validator with Kotest

You are tasked with building a LoyaltyPointsCalculator for a retail store. The rules are:

  • Users earn 1 point for every $10 spent (rounded down).
  • If the user is a "Gold" member, they earn 1.5 points per $10 (rounded down).
  • Points cannot be negative.

Your goal:

  1. Create a LoyaltyPointsCalculator class with a function calculatePoints(amount: Double, isGoldMember: Boolean): Int.
  2. Create a test class using StringSpec.
  3. Write at least three descriptive tests: one for a standard member, one for a gold member, and one ensuring that negative spend amounts result in 0 points.
  4. (Bonus) Use checkAll from the property-based testing library to ensure that no matter how large the spend amount is, the points are never negative.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.