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")) }
}