05 · Testing at Scale & CI¶
A handful of tests in one file is easy to keep green. Hundreds of tests
across dozens of files, run on every push, need structure: clear
pass/fail reporting, tests organized by what they exercise, and a CI
pipeline that fails the build the moment something breaks. This module
builds a small banking domain, a JUnit5-shaped test suite for it (using a
minimal hand-rolled runner so every example stays self-contained and
runnable with only kotlinc), and a GitHub Actions workflow that runs it
on every push.
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("io.kotest:kotest-assertions-core:5.9.0")
}
The code under test¶
class InsufficientFundsException(message: String) : RuntimeException(message)
class Account(val id: String, initialBalance: Long) {
var balance: Long = initialBalance
private set
fun deposit(amount: Long) {
require(amount > 0) { "deposit amount must be positive" }
balance += amount
}
fun withdraw(amount: Long) {
require(amount > 0) { "withdraw amount must be positive" }
if (amount > balance) throw InsufficientFundsException("balance $balance < requested $amount")
balance -= amount
}
}
class TransferService(private val fee: Long = 10) {
fun transfer(from: Account, to: Account, amount: Long) {
from.withdraw(amount + fee)
to.deposit(amount)
}
}
In a real project this is src/main/kotlin/Account.kt; tests live in the
parallel src/test/kotlin/ source set that Gradle's kotlin("jvm")
plugin wires up automatically, and ./gradlew test compiles and runs
only that tree.
Structuring the tests¶
With real JUnit5, each of these would be a @Test fun inside a class,
using assertEquals/assertThrows from org.junit.jupiter.api.Assertions
or Kotest's fluent matchers. The shape below — named cases collected into
a list, each running independently and reporting its own pass/fail — is
exactly what those frameworks do under the hood, kept dependency-free so
it compiles with plain kotlinc.
class TestFailure(message: String) : AssertionError(message)
fun assertEquals(expected: Any?, actual: Any?, label: String) {
if (expected != actual) throw TestFailure("$label: expected=$expected actual=$actual")
}
fun assertThrows(label: String, block: () -> Unit) {
try {
block()
throw TestFailure("$label: expected an exception, none was thrown")
} catch (e: TestFailure) {
throw e
} catch (e: Exception) {
// expected
}
}
data class TestCase(val name: String, val run: () -> Unit)
val tests = listOf(
TestCase("deposit increases balance") {
val acc = Account("A1", 100)
acc.deposit(50)
assertEquals(150L, acc.balance, "balance after deposit")
},
TestCase("withdraw decreases balance") {
val acc = Account("A2", 100)
acc.withdraw(30)
assertEquals(70L, acc.balance, "balance after withdraw")
},
TestCase("withdraw beyond balance throws") {
val acc = Account("A3", 50)
assertThrows("overdraft") { acc.withdraw(51) }
},
TestCase("transfer moves amount and deducts fee from sender") {
val a = Account("A4", 1000)
val b = Account("A5", 0)
TransferService(fee = 10).transfer(a, b, 200)
assertEquals(790L, a.balance, "sender balance")
assertEquals(200L, b.balance, "receiver balance")
},
TestCase("transfer fails loudly when sender lacks funds for amount+fee") {
val a = Account("A6", 100)
val b = Account("A7", 0)
assertThrows("insufficient for fee") { TransferService(fee = 10).transfer(a, b, 100) }
},
// Deliberately wrong, to show what a real CI failure looks like below.
TestCase("intentionally wrong expectation") {
val acc = Account("A8", 10)
acc.deposit(5)
assertEquals(999L, acc.balance, "balance after deposit (bad expectation)")
},
)
Running the suite and reporting results¶
fun main() {
var passed = 0
var failed = 0
for (t in tests) {
try {
t.run()
println("PASS ${t.name}")
passed++
} catch (e: Throwable) {
println("FAIL ${t.name} -- ${e.message}")
failed++
}
}
println("---")
println("$passed passed, $failed failed, ${tests.size} total")
if (failed > 0) kotlin.system.exitProcess(1)
}
PASS deposit increases balance
PASS withdraw decreases balance
PASS withdraw beyond balance throws
PASS transfer moves amount and deducts fee from sender
PASS transfer fails loudly when sender lacks funds for amount+fee
FAIL intentionally wrong expectation -- balance after deposit (bad expectation): expected=999 actual=15
---
5 passed, 1 failed, 6 total
The process also exits with code 1 when any test fails — this is the
detail that makes CI actually work: ./gradlew test (or this compiled
jar) failing means the shell command failed, which is what GitHub
Actions checks to mark a workflow run red. A test runner that always
prints a summary but exits 0 regardless would never fail a build no
matter how many tests failed.
Wiring it into GitHub Actions¶
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Run tests
run: ./gradlew test --no-daemon
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: build/test-results/test/
if: always() on the upload step is what makes failed-run debugging
possible — without it, the artifact upload is skipped the moment test
fails (the default), so the very run you need the report for is the one
that never uploads it.
Kotlin-specific traps¶
- A test that only exercises the "happy path" through a
sealed class/whenmisses the exhaustiveness guarantee entirely. The compiler enforces you handle every subtype in awhen; it does not enforce you test every subtype. Structure test cases per subtype deliberately (as this module tests both the success andInsufficientFundsExceptionpaths ofwithdraw). @BeforeEach(JUnit5) creates a fresh instance per test unless@TestInstance(Lifecycle.PER_CLASS)is used — Kotlin test classes default to JUnit's per-method instantiation, so sharedvarstate declared as a class property resets between tests unless you opt into per-class lifecycle explicitly, which surprises developers coming from languages where classes are more commonly reused across tests.- Extension functions used only in tests still need a real receiver.
A test helper like
fun Account.creditForTest(amt: Long)compiles fine but silently does nothing useful if called on a mock/fake that doesn't share the real class's invariants — prefer testing through the public API (deposit/withdraw) over reaching around it. - Coroutine code needs
kotlinx-coroutines-testandrunTest, notrunBlocking, in tests —runTestuses a virtual clock, so adelay(10_000)inside code under test completes instantly instead of making the test suite actually wait ten seconds; forgetting this dependency and usingrunBlockingfor coroutine tests is a common source of a CI suite that "works" but takes minutes longer than it should. - Flaky tests fail differently on CI than locally because CI runners
are typically slower and more resource-constrained — a test relying on
wall-clock timing (
Thread.sleepraces) rather than deterministic synchronization (channels,join(),runTest's virtual time) is the most common source of a suite that's green on a laptop and red in Actions.
How It Actually Works¶
var balance: Long = initialBalance private set demonstrates something
about Kotlin properties worth making explicit: the get()/set() pair on a
property can each carry independent visibility. The compiler still
generates one field plus a getter and a setter as always, but here it emits
the getter as public and the setter as private — meaning account.balance
reads fine from any calling code, while account.balance = 500 only
compiles from inside Account's own class body. This is why deposit()
and withdraw() (both members of Account) can freely write balance +=
amount, while TransferService, an outside class, can only call
from.withdraw(...) and never touch the field directly — the compiler
enforces the account's invariant (balance changes only via validated
methods) the same way private/public normally enforces encapsulation,
just split per-accessor instead of per-property.
The hand-rolled TestCase/assertEquals/assertThrows here is a faithful
miniature of what JUnit 5 actually does internally, not a toy simplification
of something fundamentally different: JUnit's real
Assertions.assertEquals is exactly this shape — compare two values,
throw an AssertionError subtype with a descriptive message on mismatch —
and its test runner is exactly this shape too, a loop over discovered test
units, each invoked independently inside a try/catch so one failure's
thrown exception doesn't stop the remaining tests from running, with
pass/fail counted and reported at the end. The main things real JUnit adds
on top are reflection-based discovery (finding @Test-annotated methods,
as covered in the JUnit basics module) and a pluggable test-engine
architecture — the actual assert-and-report mechanics are the same
straightforward exception-based logic reproduced here in plain Kotlin.
Cheat sheet¶
| Concern | Approach |
|---|---|
| Assert equality | assertEquals/Kotest matchers, not println + eyeballing |
| Assert an exception is thrown | assertThrows<T> { } |
| Coroutine tests | runTest from kotlinx-coroutines-test, not runBlocking |
| Fail the build on failing tests | Ensure the test process exits non-zero |
| CI trigger | on: push/pull_request in .github/workflows/*.yml |
| Debugging a red CI run | if: always() on an artifact-upload step for reports |
Exercise¶
Add a seventh TestCase that verifies deposit throws when given a
negative amount (acc.deposit(-5)), using assertThrows. Run the suite
again and confirm the total goes from 6 to 7 while the pass count for
that new case is correct — then fix the deliberately-wrong test from this
module (change its expectation to 15L) and confirm the suite reports
0 failed.