Kotlin Testing Basics

Testing verifies that your code works correctly. In Kotlin, you write unit tests with JUnit 5 and use kotlinx-coroutines-test for testing suspend functions and coroutines. Good tests catch bugs early and make it safe to change code without breaking things.

Why Write Tests


Without tests:
  You change a function → manually check a few cases → ship
  → Bug found in production → fix → other thing breaks → repeat

With tests:
  You change a function → run 200 tests in 2 seconds
  → Tests catch regressions immediately → fix → all tests pass → ship safely

Step 1: Add Dependencies

// In build.gradle.kts:
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
    testImplementation("io.mockk:mockk:1.13.8")   // mocking library
}

tasks.test {
    useJUnitPlatform()
}

Your First Test

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

// The function we want to test:
fun add(a: Int, b: Int) = a + b
fun divide(a: Double, b: Double): Double {
    require(b != 0.0) { "Cannot divide by zero" }
    return a / b
}

class CalculatorTest {

    @Test
    fun `add should return sum of two numbers`() {
        assertEquals(5, add(2, 3))
        assertEquals(0, add(-1, 1))
        assertEquals(-5, add(-3, -2))
    }

    @Test
    fun `divide should return correct result`() {
        assertEquals(2.5, divide(5.0, 2.0), 0.001)
    }

    @Test
    fun `divide by zero should throw exception`() {
        assertThrows {
            divide(10.0, 0.0)
        }
    }
}

Common JUnit 5 Assertions


assertEquals(expected, actual)          → checks equality
assertNotEquals(unexpected, actual)     → checks inequality
assertTrue(condition)                   → checks is true
assertFalse(condition)                  → checks is false
assertNull(value)                       → checks is null
assertNotNull(value)                    → checks is not null
assertThrows { ... }        → checks exception is thrown
assertAll { ... }                       → runs all assertions even if one fails

Test Structure: Arrange-Act-Assert

data class ShoppingCart(val items: MutableList = mutableListOf()) {
    fun addItem(item: String) { items.add(item) }
    fun removeItem(item: String) { items.remove(item) }
    val total: Int get() = items.size
}

class ShoppingCartTest {

    @Test
    fun `adding item increases cart size`() {
        // ARRANGE — set up the test data
        val cart = ShoppingCart()

        // ACT — call the function being tested
        cart.addItem("Laptop")

        // ASSERT — verify the expected outcome
        assertEquals(1, cart.total)
        assertTrue("Laptop" in cart.items)
    }

    @Test
    fun `removing existing item decreases cart size`() {
        val cart = ShoppingCart(mutableListOf("Mouse", "Keyboard"))
        cart.removeItem("Mouse")
        assertEquals(1, cart.total)
        assertFalse("Mouse" in cart.items)
    }

    @Test
    fun `removing non-existent item does not crash`() {
        val cart = ShoppingCart()
        assertDoesNotThrow { cart.removeItem("Monitor") }
        assertEquals(0, cart.total)
    }
}

Testing Suspend Functions

import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

// Suspend function to test:
suspend fun fetchUserName(id: Int): String {
    kotlinx.coroutines.delay(100)   // simulated delay
    return if (id > 0) "User_$id" else throw IllegalArgumentException("Invalid ID")
}

class UserServiceTest {

    @Test
    fun `fetchUserName returns correct name`() = runTest {
        val name = fetchUserName(42)
        assertEquals("User_42", name)
    }

    @Test
    fun `fetchUserName throws for invalid id`() = runTest {
        assertThrows {
            fetchUserName(-1)
        }
    }

    @Test
    fun `runTest advances virtual time instantly`() = runTest {
        val start = currentTime
        fetchUserName(1)         // delay(100) doesn't actually wait in runTest
        val elapsed = currentTime - start
        assertEquals(100L, elapsed)   // virtual time advances, real time does not
    }
}

Test Lifecycle Hooks

import org.junit.jupiter.api.*

class DatabaseTest {
    private lateinit var db: FakeDatabase

    @BeforeEach
    fun setUp() {
        db = FakeDatabase()
        db.connect()
        println("DB ready for test")
    }

    @AfterEach
    fun tearDown() {
        db.disconnect()
        println("DB cleaned up")
    }

    @BeforeAll companion object {
        @JvmStatic @BeforeAll
        fun initSuite() = println("Test suite starting")

        @JvmStatic @AfterAll
        fun cleanSuite() = println("Test suite finished")
    }

    @Test fun `insert saves record`() { db.insert("row1"); assertEquals(1, db.count()) }
    @Test fun `delete removes record`() { db.insert("row1"); db.delete("row1"); assertEquals(0, db.count()) }
}

class FakeDatabase {
    private val records = mutableListOf()
    fun connect()    { }
    fun disconnect() { records.clear() }
    fun insert(r: String) { records.add(r) }
    fun delete(r: String) { records.remove(r) }
    fun count() = records.size
}

Practical Example: Testing a Service Class

class EmailValidator {
    fun isValid(email: String): Boolean =
        email.matches("""^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$""".toRegex())

    fun extractDomain(email: String): String? =
        if (isValid(email)) email.substringAfter("@") else null
}

class EmailValidatorTest {
    private val validator = EmailValidator()

    @Test fun `valid email passes`()          { assertTrue(validator.isValid("a@b.com")) }
    @Test fun `missing @ fails`()             { assertFalse(validator.isValid("notanemail")) }
    @Test fun `domain extracted correctly`()  { assertEquals("mail.com", validator.extractDomain("x@mail.com")) }
    @Test fun `invalid email returns null domain`() { assertNull(validator.extractDomain("bad")) }
}

Leave a Comment

Your email address will not be published. Required fields are marked *