Unit Testing #
A unit test is code that verifies other code works correctly — every function, method, and class is tested separately from its dependencies. A good test isn’t just “making sure the code works now”, but also serves as living documentation that explains what the code is supposed to do, and a safety net that immediately screams when a change breaks already-correct behavior. Kotlin has two main testing ecosystems: JUnit 5 (the JVM industry standard, supported by all tools) and Kotest (built specifically for Kotlin with more expressive syntax). This article covers both in depth, including all available assertions, good testing patterns, parameterized tests, lifecycles, and coroutine testing.
Dependency Setup #
// build.gradle.kts
dependencies {
// JUnit 5
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
// Kotest (optional — an alternative or complement to JUnit)
testImplementation("io.kotest:kotest-runner-junit5:5.8.1")
testImplementation("io.kotest:kotest-assertions-core:5.8.1")
testImplementation("io.kotest:kotest-property:5.8.1")
// Coroutine testing
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}
tasks.test {
useJUnitPlatform() // required for JUnit 5 and Kotest
}
JUnit 5 — The Basics #
Test Anatomy #
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
class CalculatorTest {
// Instantiate the object under test here
private lateinit var calculator: Calculator
@BeforeEach // runs before EVERY test
fun setUp() {
calculator = Calculator()
}
@AfterEach // runs after every test
fun tearDown() {
// clean up resources if needed
}
@BeforeAll // runs ONCE before all tests in this class
companion object {
@JvmStatic
@BeforeAll
fun initializeClass() {
println("Preparing the test class")
}
}
@Test
fun `adding two positive numbers produces the correct sum`() {
// Arrange — prepare the data
val a = 5
val b = 3
// Act — run the code under test
val result = calculator.add(a, b)
// Assert — verify the result
assertEquals(8, result)
}
@Test
@DisplayName("Division by zero throws ArithmeticException")
fun divide_byZero_throwsException() {
assertThrows<ArithmeticException> {
calculator.divide(10, 0)
}
}
@Test
@Disabled("Not yet implemented — see ticket BUG-123")
fun `unfinished feature`() {
// this test is skipped when running
}
}
All JUnit 5 Assertions #
// assertEquals and assertNotEquals
assertEquals(5, 2 + 3)
assertEquals("Kotlin", language)
assertNotEquals(0, result)
// assertTrue and assertFalse
assertTrue(list.isNotEmpty())
assertFalse(user.isActive)
// assertNull and assertNotNull
assertNull(getFromCache("missing-key"))
assertNotNull(getUser(1))
// assertThrows — verify an exception
val exception = assertThrows<IllegalArgumentException> {
validateAge(-5)
}
assertEquals("Age must not be negative", exception.message)
// assertDoesNotThrow — verify no exception
assertDoesNotThrow {
validateAge(25)
}
// assertAll — run all assertions, report all failures at once
// (instead of stopping at the first failing assertion)
assertAll("product properties",
{ assertEquals("Laptop", product.name) },
{ assertEquals(15_000_000.0, product.price) },
{ assertTrue(product.stock >= 0) }
)
// assertIterableEquals — compare lists/iterables element by element
assertIterableEquals(
listOf(1, 2, 3, 4, 5),
result.sorted()
)
// assertTimeout — verify execution doesn't exceed a time limit
assertTimeout(java.time.Duration.ofMillis(100)) {
operationThatMustBeFast()
}
The AAA Pattern — Arrange, Act, Assert #
Every good test follows a three-part pattern: prepare the conditions, run the code under test, verify the result. Separate the three with comments or blank lines for readability:
// Code under test
class DiscountService {
fun calculateFinalPrice(price: Double, discountPercent: Int): Double {
require(price > 0) { "Price must be positive" }
require(discountPercent in 0..100) { "Discount must be between 0 and 100" }
return price * (1 - discountPercent / 100.0)
}
}
class DiscountServiceTest {
private val service = DiscountService()
@Test
fun `20 percent discount on a price of 100000 produces 80000`() {
// Arrange
val price = 100_000.0
val discount = 20
// Act
val finalPrice = service.calculateFinalPrice(price, discount)
// Assert
assertEquals(80_000.0, finalPrice)
}
@Test
fun `0 percent discount returns the original price`() {
val finalPrice = service.calculateFinalPrice(50_000.0, 0)
assertEquals(50_000.0, finalPrice)
}
@Test
fun `100 percent discount returns 0`() {
val finalPrice = service.calculateFinalPrice(75_000.0, 100)
assertEquals(0.0, finalPrice)
}
@Test
fun `negative price throws IllegalArgumentException`() {
val exception = assertThrows<IllegalArgumentException> {
service.calculateFinalPrice(-100.0, 20)
}
assertTrue(exception.message!!.contains("positive"))
}
@Test
fun `discount above 100 throws IllegalArgumentException`() {
assertThrows<IllegalArgumentException> {
service.calculateFinalPrice(100_000.0, 150)
}
}
}
Parameterized Tests — One Test, Many Data Sets #
Parameterized tests allow running the same test with various inputs without code duplication:
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.*
class EmailValidationTest {
private val validator = EmailValidator()
// ValueSource — for a single parameter
@ParameterizedTest
@ValueSource(strings = [
"[email protected]",
"[email protected]",
"[email protected]"
])
fun `valid emails are accepted`(email: String) {
assertTrue(validator.isValid(email), "Should be valid: $email")
}
@ParameterizedTest
@ValueSource(strings = [
"not-an-email",
"missing-at-sign",
"@no-local-part",
"space in [email protected]"
])
fun `invalid emails are rejected`(email: String) {
assertFalse(validator.isValid(email), "Should be invalid: $email")
}
// CsvSource — for multiple parameters
@ParameterizedTest(name = "price {0} discount {1}% = {2}")
@CsvSource(
"100000, 0, 100000.0",
"100000, 10, 90000.0",
"100000, 50, 50000.0",
"100000, 100, 0.0",
"75000, 20, 60000.0"
)
fun `calculate discount with various inputs`(
price: Double,
percent: Int,
expectedResult: Double
) {
val service = DiscountService()
assertEquals(expectedResult, service.calculateFinalPrice(price, percent))
}
// MethodSource — for complex data
@ParameterizedTest
@MethodSource("userDataSource")
fun `validate user with various scenarios`(
name: String,
age: Int,
valid: Boolean,
errorMessage: String?
) {
val validator = UserValidator()
val result = validator.validate(name, age)
if (valid) {
assertTrue(result.isSuccess)
} else {
assertTrue(result.isFailure)
assertEquals(errorMessage, result.exceptionOrNull()?.message)
}
}
companion object {
@JvmStatic
fun userDataSource() = listOf(
org.junit.jupiter.params.provider.Arguments.of("Budi", 25, true, null),
org.junit.jupiter.params.provider.Arguments.of("", 25, false, "Name must not be empty"),
org.junit.jupiter.params.provider.Arguments.of("Sari", -1, false, "Invalid age"),
org.junit.jupiter.params.provider.Arguments.of("Ahmad", 200, false, "Invalid age")
)
}
}
Nested Tests — Hierarchical Structure #
@Nested allows grouping related tests in inner classes, making tests more organized:
import org.junit.jupiter.api.Nested
class BankAccountTest {
private lateinit var account: BankAccount
@BeforeEach
fun setUp() {
account = BankAccount("ACC-001", initialBalance = 1_000_000.0)
}
@Nested
@DisplayName("Deposit Operations")
inner class Deposit {
@Test
fun `positive deposit increases the balance`() {
account.deposit(500_000.0)
assertEquals(1_500_000.0, account.balance)
}
@Test
fun `zero deposit doesn't change the balance`() {
assertThrows<IllegalArgumentException> { account.deposit(0.0) }
}
@Test
fun `negative deposit throws an exception`() {
assertThrows<IllegalArgumentException> { account.deposit(-100.0) }
}
}
@Nested
@DisplayName("Withdrawal Operations")
inner class Withdraw {
@Test
fun `withdrawal with sufficient balance succeeds`() {
account.withdraw(300_000.0)
assertEquals(700_000.0, account.balance)
}
@Test
fun `withdrawal exceeding the balance throws an exception`() {
assertThrows<IllegalStateException> {
account.withdraw(2_000_000.0)
}
}
@Test
fun `withdrawal down to zero succeeds`() {
account.withdraw(1_000_000.0)
assertEquals(0.0, account.balance)
}
}
}
Kotest — A More Expressive Alternative #
Kotest provides several spec styles to choose from according to preference:
import io.kotest.core.spec.style.*
import io.kotest.matchers.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.string.*
import io.kotest.assertions.throwables.*
// StringSpec — the simplest, one string per test
class CalculatorStringSpec : StringSpec({
val calculator = Calculator()
"adding two positive numbers produces the correct sum" {
calculator.add(2, 3) shouldBe 5
}
"dividing by zero throws ArithmeticException" {
shouldThrow<ArithmeticException> {
calculator.divide(10, 0)
}
}
})
// BehaviorSpec — BDD style with given-when-then
class ProductBehaviorSpec : BehaviorSpec({
val service = ProductService()
given("a product with available stock") {
val product = Product("Laptop", price = 15_000_000.0, stock = 5)
`when`("a user orders 3 units") {
service.order(product, quantity = 3)
then("the stock decreases to 2") {
product.stock shouldBe 2
}
}
`when`("a user tries to order more than the stock") {
then("an exception is thrown") {
shouldThrow<IllegalStateException> {
service.order(product, quantity = 10)
}
}
}
}
})
// DescribeSpec — similar to RSpec/Jest, suitable for developers coming from frontend
class ValidatorDescribeSpec : DescribeSpec({
describe("EmailValidator") {
val validator = EmailValidator()
describe("valid formats") {
it("accepts a standard email") {
validator.isValid("[email protected]") shouldBe true
}
it("accepts an email with a subdomain") {
validator.isValid("[email protected]") shouldBe true
}
}
describe("invalid formats") {
it("rejects an email without @") {
validator.isValid("notanemailcom") shouldBe false
}
it("rejects an email without a domain") {
validator.isValid("budi@") shouldBe false
}
}
}
})
Rich Kotest Matchers #
// String matchers
"Kotlin" shouldBe "Kotlin"
"Hello World" shouldContain "World"
"kotlin" shouldStartWith "kot"
"kotlin" shouldEndWith "lin"
"Kotlin 2024" shouldMatch Regex("""\w+ \d{4}""")
" ".shouldBeBlank()
"text".shouldNotBeBlank()
// Number matchers
42 shouldBe 42
3.14 shouldBeGreaterThan 3.0
10 shouldBeLessThan 20
5 shouldBeInRange 1..10
// Collection matchers
listOf(1, 2, 3) shouldHaveSize 3
listOf(1, 2, 3) shouldContain 2
listOf(1, 2, 3) shouldContainAll listOf(1, 3)
listOf(1, 2, 3) shouldNotContain 5
emptyList<Int>().shouldBeEmpty()
listOf(1).shouldNotBeEmpty()
listOf(1, 2, 3).shouldBeSorted()
// Nullable matchers
null.shouldBeNull()
"value".shouldNotBeNull()
// Exception matchers
shouldThrow<IllegalArgumentException> {
throw IllegalArgumentException("test")
}.message shouldBe "test"
shouldNotThrowAny {
val x = 1 + 1 // no exception
}
Coroutine Testing #
To test suspend functions, use runTest from the kotlinx-coroutines-test library:
import kotlinx.coroutines.test.*
import kotlinx.coroutines.*
class DataServiceTest {
@Test
fun `fetch data succeeds and returns the result`() = runTest {
// runTest replaces delay() with virtual time — tests stay fast
val service = DataService()
val result = service.fetchData()
assertEquals("Data from API", result)
}
@Test
fun `delay inside a coroutine doesn't make the test slow`() = runTest {
val start = System.currentTimeMillis()
// this function calls delay(5000) inside
val result = operationWithDelay()
val duration = System.currentTimeMillis() - start
assertEquals("done", result)
assertTrue(duration < 1000, "The test should finish fast with virtual time")
}
}
suspend fun operationWithDelay(): String {
delay(5000) // inside runTest, this finishes immediately (virtual time)
return "done"
}
TestDispatcher — Virtual Time Control #
import kotlinx.coroutines.test.*
class AdvancedCoroutineTest {
@Test
fun `advanceTimeBy moves virtual time forward`() = runTest {
val results = mutableListOf<Int>()
launch {
delay(1000)
results.add(1)
}
launch {
delay(2000)
results.add(2)
}
launch {
delay(500)
results.add(3)
}
// Move 600ms of virtual time — only the coroutine with a 500ms delay finishes
advanceTimeBy(600)
assertEquals(listOf(3), results)
// Move another 500ms — the coroutine with a 1000ms delay finishes
advanceTimeBy(500)
assertEquals(listOf(3, 1), results)
// Finish all remaining coroutines
advanceUntilIdle()
assertEquals(listOf(3, 1, 2), results)
}
@Test
fun `runCurrent runs all ready coroutines`() = runTest {
var executed = false
launch { executed = true }
assertFalse(executed)
runCurrent() // run the ready coroutines
assertTrue(executed)
}
}
Testing Flow #
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.test.*
class FlowTest {
@Test
fun `flow produces the correct values`() = runTest {
val flow = flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
}
val result = flow.toList()
assertEquals(listOf(1, 2, 3), result)
}
@Test
fun `stateFlow stores the latest value`() = runTest {
val state = MutableStateFlow(0)
state.value = 42
assertEquals(42, state.value)
state.update { it + 1 }
assertEquals(43, state.value)
}
}
Principles of Good Tests #
FIRST — A Guide to Writing Quality Tests #
F — Fast
Tests should run in milliseconds. Hundreds of tests should finish
in seconds, not minutes. Avoid real I/O, sleep, or network
operations in unit tests — use mocks or virtual time.
I — Isolated
Every test must be able to run on its own, not depending on other
tests or execution order. State from one test must not affect
another test.
R — Repeatable
Tests must give the same result every time they run, on any
machine. Avoid dependence on system time, external data, or
machine configuration.
S — Self-Validating
Tests must decide for themselves whether they pass or fail — not
from manually read output. Every test must have an assertion.
T — Timely
Write tests before or alongside production code, not after bugs
are found. Tests written late often don't cover the important
cases.
Descriptive Test Naming #
// ANTI-PATTERN: the name doesn't explain what's being tested
@Test
fun test1() { ... }
@Test
fun testAdd() { ... }
// CORRECT: the name explains the scenario and expected result
@Test
fun `adding two positive numbers produces the correct sum`() { ... }
@Test
fun `when the balance is insufficient, withdrawal throws InsufficientFundsException`() { ... }
@Test
fun `new users without purchase history get a 10 percent welcome discount`() { ... }
One Concept per Test #
// ANTI-PATTERN: a test that verifies too many things at once
@Test
fun `test calculator`() {
assertEquals(5, calculator.add(2, 3))
assertEquals(1, calculator.subtract(3, 2))
assertEquals(6, calculator.multiply(2, 3))
assertEquals(2.0, calculator.divide(6.0, 3.0))
assertThrows<ArithmeticException> { calculator.divide(1, 0) }
}
// CORRECT: split into focused tests
@Test fun `add 2 and 3 produces 5`() { assertEquals(5, calculator.add(2, 3)) }
@Test fun `subtract 3 from 5 produces 2`() { assertEquals(2, calculator.subtract(5, 3)) }
@Test fun `divide by zero throws an exception`() { assertThrows<ArithmeticException> { calculator.divide(1, 0) } }
Summary #
- JUnit 5 for the industry standard — the widest tool support: IDEs, CI, Gradle, Maven. Use
@Test,assertEquals,assertThrows,@ParameterizedTestas the foundation.- Kotest for expressiveness — rich matchers (
shouldBe,shouldContain,shouldThrow) and several spec styles (StringSpec, BehaviorSpec, DescribeSpec). Can be combined with JUnit 5.- The AAA pattern — every test follows Arrange (prepare data), Act (run the code), Assert (verify the result). Separate the three with comments or blank lines.
- Parameterized tests for many scenarios —
@ValueSource,@CsvSource,@MethodSourceeliminate test duplication with different inputs. One test, many data sets.@Nestedfor organization — group related tests in inner classes with@Nested. Makes test output more structured and readable.runTestfor coroutines — replacerunBlockingwithrunTestfor coroutine tests.runTestreplacesdelay()with virtual time — tests stay fast even when production code has second-long delays.- The FIRST principles — Fast, Isolated, Repeatable, Self-validating, Timely. Tests violating any of these principles usually indicate a design problem.
- Descriptive test names — test names are documentation.
when_balance_is_insufficient_withdrawal_failsis better thantestWithdrawal. Use backticks for natural language names in Kotlin.