Unit testing with JUnit and Kotlin
Code without tests is code you hope works. A unit test is a small program that runs a piece of your code with known inputs and checks the output is what you expect — automatically, every time, forever. Tests are not a chore bolted on at the end; they are how professionals know their code works and stays working. This lesson writes real tests against the capstone project, and shows the bugs tests catch.
What a test is
A unit test calls one piece of your code and asserts something about the result. Here is one from the Trip Splitter capstone, and every test you write has this shape:
import kotlin.test.Test
import kotlin.test.assertEquals
class SettlementTest {
@Test
fun `an equal split leaves everyone even`() {
// A pays ₹300, shared 3 ways -> each owes ₹100, so A is owed ₹200
val expenses = listOf(Expense("x", Person("A"), 30000, listOf(Person("A"), Person("B"), Person("C"))))
val balances = netBalances(expenses)
assertEquals(20000, balances[Person("A")]) // A is owed ₹200 (20000 paise)
assertEquals(-10000, balances[Person("B")]) // B owes ₹100
}
}
Three parts, always:
@Testmarks a function as a test the framework should run.- The body sets up inputs and calls the code — here, some expenses and
netBalances. - Assertions check the result —
assertEquals(expected, actual)fails the test if they differ.
Kotlin lets you name test functions in backticks with spaces — `an equal split leaves everyone even` — so the test name reads as a sentence describing the behaviour. When a test fails, that
sentence tells you exactly what broke. Use descriptive names; a test called test1 tells you nothing
when it goes red.
The assertions you will use
kotlin.test gives you the handful you need:
assertEquals(expected, actual) // they must be equal (order matters: expected first)
assertTrue(condition) // condition must be true
assertFalse(condition) // condition must be false
assertNull(value) // value must be null
assertNotNull(value) // value must not be null
assertFailsWith<SomeException> { ... } // the block must throw that exception
assertEquals is the workhorse. assertFailsWith is the important one people forget: you must test
the failure paths too, not just the happy path. A function that is supposed to reject bad input is
only proven correct when you have a test showing it does reject it:
@Test
fun `a negative amount is rejected`() {
assertFailsWith<IllegalArgumentException> {
Expense("bad", Person("A"), -100, listOf(Person("A"))) // must throw
}
}
This test passes only if constructing an Expense with a negative amount throws — which is exactly
the require from the classes module doing its job. Testing that your validation fires is as
important as testing that valid input works.
Running tests, and reading a failure
Run them with Gradle or the green arrow in IntelliJ:
./gradlew test
BUILD SUCCESSFUL
5 tests, 0 failures, 100% successful
When a test fails, the output tells you what and where:
SettlementTest > an equal split leaves everyone even FAILED
expected: <20000> but was: <15000>
Read it: the test named in a sentence, then expected: <20000> but was: <15000> — you expected 20000,
the code produced 15000. That message points straight at the bug, which is the whole value: a
failing test does not just say "something is wrong", it says exactly what was wrong and where.
What tests are really for
Three reasons tests earn their keep, beyond "checking it works once":
They catch regressions. The real payoff comes later. You change some code six months from now, run the tests, and one goes red — you just caught a bug you would otherwise have shipped. Tests are a safety net that lets you change code without fear, because if you break something, a test tells you immediately. Code with good tests is code you can refactor confidently; code without is code you are afraid to touch.
They document behaviour. A well-named test suite is a specification: `net balances always sum to zero`, `no expenses means no transfers`. Reading the tests tells you what the code is
supposed to do, more reliably than a comment, because a test that lies fails.
They find bugs as you write. Writing a test forces you to think about edge cases — the empty list, the negative number, the single participant — and those are exactly where bugs hide. Often the act of writing the test reveals the bug before you have even run it.
The one rule that makes tests worth having
A test is only valuable if it would actually fail when the code is wrong. A test that passes no matter what proves nothing — worse, it gives false confidence. Two ways tests fail this rule:
- Testing the wrong thing — asserting something trivially true (
assertEquals(4, 2 + 2)) that does not exercise your code. - Not asserting — running the code but checking nothing, so it "passes" by not crashing.
The way to trust a test: make it fail on purpose once. Break the code (change a + to a -), run
the test, and confirm it goes red. If it stays green, the test is not testing what you think. A test
you have seen fail for the right reason is a test you can trust. This is the testing module's version
of the whole course's habit — verify by running, do not assume.
What to test, and what not to
You cannot test everything, and you should not try. Focus on:
- Logic with rules and edge cases — the settlement math, validation, parsing, calculations. This is where bugs live and where tests pay off most.
- The failure paths — bad input, empty collections, boundaries.
- Anything that has broken before — a bug you fixed deserves a test so it cannot come back.
Do not exhaustively test trivial getters, or code with no logic to get wrong. The goal is confidence where it matters, not a coverage number for its own sake. A handful of tests on the tricky logic is worth more than a hundred on the trivial parts.
Check your work
What a unit test is. A small program that runs a piece of your code with known inputs and asserts the output.
The three parts of a test. @Test, setup-and-call, and assertions.
Why backtick test names. They read as a sentence describing the behaviour, so a failure tells you what broke.
The key assertions. assertEquals, assertTrue/assertFalse, assertNull/assertNotNull, and
assertFailsWith for failure paths.
Why test failure paths. Validation is only proven correct when a test shows it rejects bad input.
What a failure message tells you. The test name and expected: <x> but was: <y> — exactly what and
where.
Three things tests are really for. Catching regressions (change without fear), documenting behaviour, and finding bugs as you write.
The one rule. A test must actually fail when the code is wrong — prove it by breaking the code once and watching it go red.
What to test, and what not. Logic, edge cases, failure paths, and past bugs; not trivial getters or logicless code.
Practice
- Write a
SettlementTestwith the "equal split" test above and run it. Confirm it passes. - Add
assertFailsWithtests for a negative amount and an empty participant list. - Write a test
`net balances always sum to zero`and confirm it. Reason about why that invariant must hold. - Break the code on purpose (change a
+to a-innetBalances) and confirm a test goes red. Read the failure message. Fix it and confirm green. - Write a test that runs code but asserts nothing, and confirm it "passes". Explain why that is worthless.
- Add a test for the empty-input case (
settle(emptyList())). Confirm it behaves sensibly. - Fix a bug in any code you have, then write a test that would have caught it. Confirm the test fails before the fix and passes after.
Official documentation
- Kotlin — Testing with kotlin.test — The assertions and annotations.
- Kotlin — Test code using JUnit — Setting up and running tests with Gradle.
- JUnit 5 — User guide — The test platform underneath, for when you need more.
Next: structuring a Kotlin project, and where everything belongs.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship