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, @ParameterizedTest as 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, @MethodSource eliminate test duplication with different inputs. One test, many data sets.
  • @Nested for organization — group related tests in inner classes with @Nested. Makes test output more structured and readable.
  • runTest for coroutines — replace runBlocking with runTest for coroutine tests. runTest replaces delay() 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_fails is better than testWithdrawal. Use backticks for natural language names in Kotlin.

← Previous: Web Server   Next: Mocking →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact