Skip to Content
Course content

56: Unit Testing with JUnit in Kotlin

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

I've seen a lot of developers treat unit testing as a checkbox exercise—something they do at the end of a feature to satisfy a manager or a CI pipeline. When that happens, the tests usually become a burden rather than a tool. They break for the wrong reasons, they're hard to read, and eventually, the team just starts ignoring the failures because "that test always fails, just ignore it." We need to avoid that.

Let's look at a specific example. Suppose we're building a DiscountCalculator that applies a percentage discount to a total, but only if the total is above a certain threshold. It's a simple piece of logic, but it's exactly where bugs love to hide.

class DiscountCalculator {
    fun calculateDiscount(total: Double, discountPercent: Double): Double {
        if (total < 100.0) return 0.0
        return total * (discountPercent / 100.0)
    }
}

The temptation of the "God Test"

When people first start with JUnit, they often write what I call the "God Test." This is one giant test method that tries to verify every single scenario for a class in one go. It looks something like this:

class DiscountCalculatorTest {
    @Test
    fun testDiscountLogic() {
        val calc = DiscountCalculator()
        
        // Test under threshold
        assertEquals(0.0, calc.calculateDiscount(50.0, 10.0))
        
        // Test over threshold
        assertEquals(15.0, calc.calculateDiscount(150.0, 10.0))
        
        // Test exactly at threshold
        assertEquals(0.0, calc.calculateDiscount(100.0, 10.0)) 
        
        // Test zero discount
        assertEquals(0.0, calc.calculateDiscount(200.0, 0.0))
    }
}

At first glance, this seems efficient. You've covered four scenarios in ten lines of code. But here's where it breaks: JUnit stops executing a test method the moment it hits the first failure. If the "under threshold" check fails, you have no idea if the "over threshold" or "zero discount" logic is actually working. You're flying blind. Plus, the test name testDiscountLogic tells you absolutely nothing about what actually failed when you see a red bar in your IDE.

Atomic tests and descriptive intent

The better way is to treat each test as a specific requirement. If I'm your lead dev, I don't want to see "test logic"; I want to see "should return zero when total is under threshold." By splitting these into atomic tests, you turn your test suite into a living specification of your business rules.

In Kotlin, we can make this even cleaner using backtick method names. It's a feature that allows us to write full sentences as function names, which is a lifesaver for readability.

class DiscountCalculatorTest {
    private val calc = DiscountCalculator()

    @Test
    fun `should return zero discount when total is below 100`() {
        val result = calc.calculateDiscount(50.0, 10.0)
        assertEquals(0.0, result)
    }

    @Test
    fun `should apply discount when total is above 100`() {
        val result = calc.calculateDiscount(150.0, 10.0)
        assertEquals(15.0, result)
    }

    @Test
    fun `should return zero discount when total is exactly 100`() {
        val result = calc.calculateDiscount(100.0, 10.0)
        assertEquals(0.0, result)
    }
}

Now, if the "exactly 100" case fails, the other tests still run. You know exactly which edge case is broken without having to debug the entire method. I've found that this approach drastically reduces the time spent in the debugger because the test name essentially tells you where the bug is.

Handling repetitive data without the bloat

You might be thinking, "Great, but now I have twenty different methods for twenty different price points." If you find yourself writing the same test structure over and over with different numbers, don't go back to the "God Test." Instead, use a @ParameterizedTest.

This allows you to run the same logic against a stream of different inputs. It's the professional way to handle boundary testing without cluttering your file.

class DiscountCalculatorTest {
    private val calc = DiscountCalculator()

    @ParameterizedTest
    @CsvSource(
        "50.0, 10.0, 0.0",
        "99.9, 10.0, 0.0",
        "100.0, 10.0, 0.0",
        "100.1, 10.0, 10.01",
        "200.0, 20.0, 40.0"
    )
    fun `calculateDiscount should return correct value for various inputs`(
        total: Double, percent: Double, expected: Double
    ) {
        assertEquals(expected, calc.calculateDiscount(total, percent), 0.001)
    }
}

Notice the third argument in assertEquals? That's the delta. When comparing doubles or floats, always use a delta. Floating point math is notoriously imprecise, and if you don't provide a tolerance, your tests will eventually fail because of a 0.00000000001 difference, which is a frustrating way to spend a Friday afternoon.




📋 Practical Task

Exercise: Fixing the Fragile TaxCalculator Tests

You've inherited a project with a TaxCalculator class and a very poorly written test suite. The current tests use the "God Test" pattern, making it impossible to tell which specific tax rule is failing.

Your Task:

  • Refactor the TaxCalculatorTest class.
  • Break the single testAllTaxes() method into multiple atomic tests using Kotlin's backtick naming convention (e.g., `should apply luxury tax for items over 1000`).
  • Identify and add a test case for the boundary condition: exactly 1000.0.
  • Implement a @ParameterizedTest to verify multiple standard tax rates (e.g., 5%, 10%, 15%) to ensure the basic percentage calculation is robust.
// The class you are testing
class TaxCalculator {
    fun calculateTax(amount: Double, rate: Double): Double {
        if (amount <= 0) return 0.0
        if (amount > 1000.0) return (amount * (rate / 100.0)) + 50.0 // Luxury surcharge
        return amount * (rate / 100.0)
    }
}

// The naive test you need to refactor
class TaxCalculatorTest {
    @Test
    fun testAllTaxes() {
        val calc = TaxCalculator()
        assertEquals(0.0, calc.calculateTax(-10.0, 10.0))
        assertEquals(10.0, calc.calculateTax(100.0, 10.0))
        assertEquals(150.0, calc.calculateTax(1000.0, 10.0)) // Potential bug here!
        assertEquals(150.0, calc.calculateTax(1100.0, 10.0)) // (110 + 50)
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.