Kotlin - Testing
Try it: Write comprehensive tests for your Kotlin applications using modern testing frameworks and patterns.
Testing Fundamentals
Testing is essential for building reliable Kotlin applications. Kotlin's expressive syntax makes writing clear, maintainable tests natural and enjoyable.
Basic JUnit 5 Testing
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int = a / b
fun factorial(n: Int): Long {
require(n >= 0) { "Factorial is not defined for negative numbers" }
return if (n <= 1) 1 else n * factorial(n - 1)
}
}
class CalculatorTest {
private lateinit var calculator: Calculator
@BeforeEach
fun setup() {
calculator = Calculator()
}
@Test
fun `should add two positive numbers`() {
// Given
val a = 5
val b = 3
// When
val result = calculator.add(a, b)
// Then
assertEquals(8, result)
}
@Test
fun `should add negative numbers`() {
assertEquals(-8, calculator.add(-5, -3))
assertEquals(2, calculator.add(-5, 7))
}
@Test
fun `should throw exception when dividing by zero`() {
assertThrows<ArithmeticException> {
calculator.divide(10, 0)
}
}
@Test
fun `should calculate factorial correctly`() {
assertEquals(1, calculator.factorial(0))
assertEquals(1, calculator.factorial(1))
assertEquals(2, calculator.factorial(2))
assertEquals(6, calculator.factorial(3))
assertEquals(24, calculator.factorial(4))
assertEquals(120, calculator.factorial(5))
}
@Test
fun `should throw exception for negative factorial`() {
val exception = assertThrows<IllegalArgumentException> {
calculator.factorial(-1)
}
assertEquals("Factorial is not defined for negative numbers", exception.message)
}
}
Parameterized Tests
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.*
class MathUtilsTest {
@ParameterizedTest
@ValueSource(ints = [2, 4, 6, 8, 10, 100])
fun `should identify even numbers`(number: Int) {
assertTrue(MathUtils.isEven(number))
}
@ParameterizedTest
@ValueSource(ints = [1, 3, 5, 7, 9, 99])
fun `should identify odd numbers`(number: Int) {
assertFalse(MathUtils.isEven(number))
}
@ParameterizedTest
@CsvSource(
"1, 1",
"2, 4",
"3, 9",
"4, 16",
"5, 25"
)
fun `should calculate square correctly`(input: Int, expected: Int) {
assertEquals(expected, MathUtils.square(input))
}
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
fun `should detect blank strings`(input: String, expected: Boolean) {
assertEquals(expected, input.isBlank())
}
companion object {
@JvmStatic
fun provideStringsForIsBlank() = listOf(
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of(" ", true),
Arguments.of("\t", true),
Arguments.of("\n", true),
Arguments.of("a", false),
Arguments.of(" a ", false)
)
}
}
object MathUtils {
fun isEven(number: Int): Boolean = number % 2 == 0
fun square(number: Int): Int = number * number
}
Testing with Mockk
import io.mockk.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
interface UserRepository {
fun findById(id: String): User?
fun save(user: User): User
}
interface EmailService {
fun sendEmail(to: String, subject: String, body: String)
}
data class User(val id: String, val email: String, val name: String)
class UserService(
private val userRepository: UserRepository,
private val emailService: EmailService
) {
fun getUser(id: String): User {
return userRepository.findById(id)
?: throw IllegalArgumentException("User not found: $id")
}
fun createUser(name: String, email: String): User {
val user = User(
id = generateId(),
name = name,
email = email
)
val savedUser = userRepository.save(user)
emailService.sendEmail(
to = email,
subject = "Welcome!",
body = "Welcome to our service, $name!"
)
return savedUser
}
private fun generateId(): String = "user_${System.currentTimeMillis()}"
}
class UserServiceTest {
private val userRepository = mockk<UserRepository>()
private val emailService = mockk<EmailService>()
private val userService = UserService(userRepository, emailService)
@Test
fun `should return user when found`() {
// Given
val userId = "123"
val expectedUser = User(userId, "[email protected]", "John Doe")
every { userRepository.findById(userId) } returns expectedUser
// When
val result = userService.getUser(userId)
// Then
assertEquals(expectedUser, result)
verify { userRepository.findById(userId) }
}
@Test
fun `should throw exception when user not found`() {
// Given
val userId = "999"
every { userRepository.findById(userId) } returns null
// When & Then
val exception = assertThrows<IllegalArgumentException> {
userService.getUser(userId)
}
assertEquals("User not found: $userId", exception.message)
verify { userRepository.findById(userId) }
}
@Test
fun `should create user and send welcome email`() {
// Given
val name = "Jane Doe"
val email = "[email protected]"
val savedUser = User("generated_id", email, name)
every { userRepository.save(any()) } returns savedUser
every { emailService.sendEmail(any(), any(), any()) } just Runs
// When
val result = userService.createUser(name, email)
// Then
assertEquals(savedUser, result)
verify {
userRepository.save(match { user ->
user.name == name && user.email == email
})
emailService.sendEmail(
to = email,
subject = "Welcome!",
body = "Welcome to our service, $name!"
)
}
}
@Test
fun `should create user with correct properties`() {
// Given
val name = "Bob Smith"
val email = "[email protected]"
val capturedUser = slot<User>()
every { userRepository.save(capture(capturedUser)) } answers { capturedUser.captured }
every { emailService.sendEmail(any(), any(), any()) } just Runs
// When
userService.createUser(name, email)
// Then
with(capturedUser.captured) {
assertEquals(name, this.name)
assertEquals(email, this.email)
assertTrue(id.startsWith("user_"))
}
}
}
Kotest Framework
import io.kotest.core.spec.style.FunSpec
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.assertions.throwables.shouldThrow
class StringProcessorTest : StringSpec({
"should reverse string correctly" {
StringProcessor.reverse("hello") shouldBe "olleh"
StringProcessor.reverse("") shouldBe ""
StringProcessor.reverse("a") shouldBe "a"
}
"should count words correctly" {
StringProcessor.countWords("hello world") shouldBe 2
StringProcessor.countWords(" hello world ") shouldBe 2
StringProcessor.countWords("") shouldBe 0
StringProcessor.countWords("single") shouldBe 1
}
"should capitalize words correctly" {
StringProcessor.capitalizeWords("hello world") shouldBe "Hello World"
StringProcessor.capitalizeWords("kotlin is awesome") shouldBe "Kotlin Is Awesome"
}
"should throw exception for null input" {
shouldThrow<IllegalArgumentException> {
StringProcessor.process(null)
}.message shouldContain "Input cannot be null"
}
})
class ListUtilsTest : FunSpec({
context("filtering operations") {
test("should filter even numbers") {
val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = ListUtils.filterEven(numbers)
result shouldContainExactly listOf(2, 4, 6)
}
test("should filter by length") {
val words = listOf("cat", "elephant", "dog", "butterfly")
val result = ListUtils.filterByLength(words, minLength = 5)
result shouldContainExactly listOf("elephant", "butterfly")
}
}
context("transformation operations") {
test("should double all numbers") {
val numbers = listOf(1, 2, 3, 4, 5)
val result = ListUtils.doubleNumbers(numbers)
result shouldContainExactly listOf(2, 4, 6, 8, 10)
}
test("should create pairs from adjacent elements") {
val items = listOf("a", "b", "c", "d")
val result = ListUtils.createPairs(items)
result shouldContainExactly listOf("a" to "b", "b" to "c", "c" to "d")
}
}
})
object StringProcessor {
fun reverse(input: String): String = input.reversed()
fun countWords(input: String): Int =
if (input.isBlank()) 0 else input.trim().split(Regex("\\s+")).size
fun capitalizeWords(input: String): String =
input.split(" ").joinToString(" ") { it.capitalize() }
fun process(input: String?): String {
require(input != null) { "Input cannot be null" }
return input.trim().lowercase()
}
}
object ListUtils {
fun filterEven(numbers: List<Int>): List<Int> = numbers.filter { it % 2 == 0 }
fun filterByLength(words: List<String>, minLength: Int): List<String> =
words.filter { it.length >= minLength }
fun doubleNumbers(numbers: List<Int>): List<Int> = numbers.map { it * 2 }
fun createPairs(items: List<String>): List<Pair<String, String>> =
items.zipWithNext()
}
Coroutines Testing
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class AsyncDataService {
private val api = ExternalApi()
suspend fun fetchUserData(userId: String): UserData {
delay(1000) // Simulate network delay
return api.getUser(userId)
}
suspend fun fetchUserWithRetry(userId: String, maxRetries: Int = 3): UserData {
repeat(maxRetries) { attempt ->
try {
return fetchUserData(userId)
} catch (e: Exception) {
if (attempt == maxRetries - 1) throw e
delay(500) // Retry delay
}
}
error("This should never be reached")
}
suspend fun processMultipleUsers(userIds: List<String>): List<UserData> {
return userIds.map { userId ->
async { fetchUserData(userId) }
}.awaitAll()
}
}
class ExternalApi {
fun getUser(userId: String): UserData = UserData(userId, "User $userId", "[email protected]")
}
data class UserData(val id: String, val name: String, val email: String)
class AsyncDataServiceTest {
private val service = AsyncDataService()
@Test
fun `should fetch user data`() = runTest {
// When
val result = service.fetchUserData("123")
// Then
assertEquals("123", result.id)
assertEquals("User 123", result.name)
}
@Test
fun `should complete fetch within expected time`() = runTest {
// Given
val testScheduler = testScheduler
// When
val deferred = async { service.fetchUserData("123") }
// Advance time by 1000ms
testScheduler.advanceTimeBy(1000)
// Then
assertTrue(deferred.isCompleted)
val result = deferred.await()
assertEquals("123", result.id)
}
@Test
fun `should process multiple users concurrently`() = runTest {
// Given
val userIds = listOf("1", "2", "3")
// When
val startTime = currentTime
val results = service.processMultipleUsers(userIds)
val endTime = currentTime
// Then
assertEquals(3, results.size)
assertTrue(results.all { it.name.startsWith("User") })
// Should complete in ~1000ms (parallel), not ~3000ms (sequential)
assertTrue(endTime - startTime < 1500)
}
@Test
fun `should handle timeout`() = runTest {
assertThrows<TimeoutCancellationException> {
withTimeout(500) {
service.fetchUserData("123") // Takes 1000ms
}
}
}
}
Property-Based Testing
import io.kotest.core.spec.style.StringSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.checkAll
import io.kotest.matchers.shouldBe
class PropertyBasedTest : StringSpec({
"reverse of reverse should be identity" {
checkAll(Arb.string()) { input ->
StringUtils.reverse(StringUtils.reverse(input)) shouldBe input
}
}
"addition should be commutative" {
checkAll(Arb.int(), Arb.int()) { a, b ->
(a + b) shouldBe (b + a)
}
}
"list size should be preserved after sorting" {
checkAll(Arb.list(Arb.int())) { list ->
list.sorted().size shouldBe list.size
}
}
"filtering then mapping should equal mapping then filtering (when filter is on mapped values)" {
checkAll(Arb.list(Arb.int(1..100))) { numbers ->
val doubledThenFiltered = numbers.map { it * 2 }.filter { it > 50 }
val filteredThenDoubled = numbers.filter { it * 2 > 50 }.map { it * 2 }
doubledThenFiltered shouldBe filteredThenDoubled
}
}
})
object StringUtils {
fun reverse(input: String): String = input.reversed()
fun isPalindrome(input: String): Boolean {
val cleaned = input.lowercase().filter { it.isLetterOrDigit() }
return cleaned == cleaned.reversed()
}
}
Integration Testing
import org.junit.jupiter.api.*
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import java.sql.DriverManager
@Testcontainers
class DatabaseIntegrationTest {
companion object {
@Container
@JvmStatic
private val postgres = PostgreSQLContainer<Nothing>("postgres:13")
.apply {
withDatabaseName("testdb")
withUsername("testuser")
withPassword("testpass")
}
}
private lateinit var userRepository: DatabaseUserRepository
@BeforeEach
fun setup() {
val connection = DriverManager.getConnection(
postgres.jdbcUrl,
postgres.username,
postgres.password
)
// Setup schema
connection.createStatement().execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
userRepository = DatabaseUserRepository(connection)
}
@Test
fun `should save and retrieve user`() {
// Given
val user = User(username = "john_doe", email = "[email protected]")
// When
val savedUser = userRepository.save(user)
val retrievedUser = userRepository.findById(savedUser.id)
// Then
assertNotNull(retrievedUser)
assertEquals(savedUser.username, retrievedUser?.username)
assertEquals(savedUser.email, retrievedUser?.email)
}
@Test
fun `should find users by email`() {
// Given
val user1 = User(username = "user1", email = "[email protected]")
val user2 = User(username = "user2", email = "[email protected]")
userRepository.save(user1)
userRepository.save(user2)
// When
val foundUser = userRepository.findByEmail("[email protected]")
// Then
assertNotNull(foundUser)
assertEquals("user1", foundUser?.username)
}
@Test
fun `should handle concurrent access`() = runBlocking {
// Given
val users = (1..10).map { User(username = "user$it", email = "[email protected]") }
// When - Save users concurrently
val savedUsers = users.map { user ->
async(Dispatchers.IO) {
userRepository.save(user)
}
}.awaitAll()
// Then
assertEquals(10, savedUsers.size)
savedUsers.forEach { user ->
assertTrue(user.id > 0)
}
}
}
data class User(
val id: Long = 0,
val username: String,
val email: String,
val createdAt: java.time.LocalDateTime = java.time.LocalDateTime.now()
)
class DatabaseUserRepository(private val connection: java.sql.Connection) {
fun save(user: User): User {
val sql = "INSERT INTO users (username, email) VALUES (?, ?) RETURNING id, created_at"
val stmt = connection.prepareStatement(sql)
stmt.setString(1, user.username)
stmt.setString(2, user.email)
val rs = stmt.executeQuery()
return if (rs.next()) {
user.copy(
id = rs.getLong("id"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime()
)
} else {
throw RuntimeException("Failed to save user")
}
}
fun findById(id: Long): User? {
val sql = "SELECT * FROM users WHERE id = ?"
val stmt = connection.prepareStatement(sql)
stmt.setLong(1, id)
val rs = stmt.executeQuery()
return if (rs.next()) {
User(
id = rs.getLong("id"),
username = rs.getString("username"),
email = rs.getString("email"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime()
)
} else null
}
fun findByEmail(email: String): User? {
val sql = "SELECT * FROM users WHERE email = ?"
val stmt = connection.prepareStatement(sql)
stmt.setString(1, email)
val rs = stmt.executeQuery()
return if (rs.next()) {
User(
id = rs.getLong("id"),
username = rs.getString("username"),
email = rs.getString("email"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime()
)
} else null
}
}
Testing Best Practices
- Test Structure: Use Given-When-Then or Arrange-Act-Assert pattern for clarity
- Naming: Use descriptive test names that explain what is being tested
- Isolation: Each test should be independent and not rely on other tests
- Fast Feedback: Keep unit tests fast; use integration tests for slower operations
- Coverage: Focus on meaningful coverage rather than just high percentages
Common Testing Patterns
- AAA Pattern: Arrange (setup), Act (execute), Assert (verify)
- Test Doubles: Use mocks, stubs, and fakes appropriately
- Data Builders: Create test data using builder patterns for flexibility
- Custom Matchers: Write domain-specific assertions for better readability
Practice Exercises
- Write comprehensive tests for a shopping cart system including edge cases
- Create integration tests for a REST API using TestContainers
- Implement property-based tests for mathematical operations
- Write performance tests for concurrent operations using coroutines
Architecture Notes
- Test Pyramid: Many unit tests, fewer integration tests, minimal E2E tests
- Dependency Injection: Design for testability with proper dependency injection
- Test Environments: Use containers and test databases for realistic testing
- Continuous Testing: Integrate tests into CI/CD pipelines for fast feedback