Kotlin Unit Testing
Introduction
Unit testing is an essential practice in modern software development that involves testing individual components or "units" of code in isolation. In Kotlin, unit testing helps ensure your functions, classes, and methods work as expected and continue to work as your codebase evolves.
This guide will introduce you to unit testing in Kotlin, covering the fundamentals, popular frameworks, and best practices to help you write effective tests for your Kotlin applications.
Why Unit Testing Matters
Before diving into the technical aspects, let's understand why unit testing is critical:
- Early Bug Detection: Catch issues before they reach production
- Refactoring Confidence: Change code without fear of breaking existing functionality
- Documentation: Tests serve as living documentation of how your code should behave
- Better Design: Writing testable code often leads to better software architecture
Getting Started with JUnit in Kotlin
JUnit is the most widely used testing framework for JVM languages, including Kotlin. Let's set up a basic project with JUnit 5 (also known as Jupiter).
Setting Up Dependencies
For a Gradle project, add the following to your build.gradle.kts:
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2")
}
tasks.test {
    useJUnitPlatform()
}
For Maven, add this to your pom.xml:
<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.9.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>
Your First Kotlin Unit Test
Let's create a simple calculator class and test it:
// src/main/kotlin/Calculator.kt
class Calculator {
    fun add(a: Int, b: Int): Int = a + b
    fun subtract(a: Int, b: Int): Int = a - b
    fun multiply(a: Int, b: Int): Int = a * b
    fun divide(a: Int, b: Int): Int {
        require(b != 0) { "Cannot divide by zero" }
        return a / b
    }
}
Now, let's write tests for our Calculator class:
// src/test/kotlin/CalculatorTest.kt
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.assertThrows
class CalculatorTest {
    
    private val calculator = Calculator()
    
    @Test
    fun `should add two numbers correctly`() {
        // Given
        val a = 5
        val b = 3
        
        // When
        val result = calculator.add(a, b)
        
        // Then
        assertEquals(8, result)
    }
    
    @Test
    fun `should subtract two numbers correctly`() {
        assertEquals(2, calculator.subtract(5, 3))
    }
    
    @Test
    fun `should multiply two numbers correctly`() {
        assertEquals(15, calculator.multiply(5, 3))
    }
    
    @Test
    fun `should divide two numbers correctly`() {
        assertEquals(2, calculator.divide(6, 3))
    }
    
    @Test
    fun `should throw exception when dividing by zero`() {
        val exception = assertThrows<IllegalArgumentException> {
            calculator.divide(5, 0)
        }
        assertEquals("Cannot divide by zero", exception.message)
    }
}
Running Your Tests
You can run tests from your IDE (most have built-in support for JUnit) or via command line:
- Gradle: ./gradlew test
- Maven: mvn test
Understanding JUnit Annotations
JUnit 5 provides several annotations to control test execution:
import org.junit.jupiter.api.*
class AnnotationsExampleTest {
    @BeforeAll // Executed once before all test methods
    companion object {
        @JvmStatic
        fun setupAll() {
            println("Setting up the test class")
        }
    }
    @BeforeEach // Executed before each test method
    fun setup() {
        println("Setting up a test")
    }
    @Test // Marks a method as a test
    fun `should pass`() {
        assertTrue(true)
    }
    
    @Test
    @Disabled("Test is currently disabled") // Disables a test
    fun `skipped test`() {
        fail("This test should be skipped")
    }
    
    @RepeatedTest(3) // Repeats a test multiple times
    fun `repeated test`(repetitionInfo: RepetitionInfo) {
        println("Running repetition ${repetitionInfo.currentRepetition}")
        assertTrue(true)
    }
    @AfterEach // Executed after each test method
    fun tearDown() {
        println("Tearing down a test")
    }
    @AfterAll // Executed once after all test methods
    companion object {
        @JvmStatic
        fun tearDownAll() {
            println("Tearing down the test class")
        }
    }
}
Assertions in Kotlin Unit Tests
JUnit provides various assertion methods to verify your code behavior:
@Test
fun `demonstration of various assertions`() {
    // Basic assertions
    assertEquals(4, 2 + 2, "2 + 2 should equal 4")
    assertNotEquals(5, 2 + 2)
    
    // Boolean assertions
    assertTrue(4 > 3)
    assertFalse(3 > 4)
    
    // Null assertions
    val nullValue: String? = null
    val nonNullValue = "Hello"
    assertNull(nullValue)
    assertNotNull(nonNullValue)
    
    // Collection assertions
    val list = listOf(1, 2, 3)
    assertTrue(list.contains(2))
    assertEquals(3, list.size)
    
    // Exception assertions
    val exception = assertThrows<ArithmeticException> {
        1 / 0
    }
    assertTrue(exception.message?.contains("zero") ?: false)
    
    // Group of assertions
    assertAll(
        { assertEquals(4, 2 * 2) },
        { assertEquals(6, 3 * 2) }
    )
}
Using Mockito for Mocks in Kotlin
When testing components that depend on other components, we often use mocks. Mockito is a popular mocking framework, and it works well with Kotlin when using the mockito-kotlin library.
Let's set up the dependencies:
// build.gradle.kts
dependencies {
    testImplementation("org.mockito:mockito-core:5.3.1")
    testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0")
}
Now, let's create a more complex example with dependencies:
// User repository interface
interface UserRepository {
    fun getUserById(id: String): User?
    fun saveUser(user: User): Boolean
}
// User model
data class User(val id: String, val name: String, val email: String)
// User service that depends on the repository
class UserService(private val userRepository: UserRepository) {
    fun getUserName(userId: String): String {
        val user = userRepository.getUserById(userId) 
            ?: throw IllegalArgumentException("User not found")
        return user.name
    }
    
    fun registerUser(name: String, email: String): User {
        val id = generateUserId()
        val user = User(id, name, email)
        val success = userRepository.saveUser(user)
        
        if (!success) {
            throw RuntimeException("Failed to register user")
        }
        
        return user
    }
    
    private fun generateUserId(): String {
        // In a real system, this would generate a unique ID
        return "user-${System.currentTimeMillis()}"
    }
}
Here's how to test the UserService using Mockito:
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.assertThrows
import org.mockito.kotlin.*
class UserServiceTest {
    
    private lateinit var userRepository: UserRepository
    private lateinit var userService: UserService
    
    @BeforeEach
    fun setup() {
        // Create a mock of the UserRepository
        userRepository = mock()
        
        // Create the service with the mock repository
        userService = UserService(userRepository)
    }
    
    @Test
    fun `should return user name when user exists`() {
        // Given
        val userId = "user-123"
        val user = User(userId, "John Doe", "[email protected]")
        
        // Configure the mock to return our test user when getUserById is called
        whenever(userRepository.getUserById(userId)).thenReturn(user)
        
        // When
        val userName = userService.getUserName(userId)
        
        // Then
        assertEquals("John Doe", userName)
        
        // Verify the repository method was called exactly once with the right argument
        verify(userRepository).getUserById(userId)
    }
    
    @Test
    fun `should throw exception when user does not exist`() {
        // Given
        val userId = "non-existent"
        
        // Configure the mock to return null (user not found)
        whenever(userRepository.getUserById(userId)).thenReturn(null)
        
        // When & Then
        val exception = assertThrows<IllegalArgumentException> {
            userService.getUserName(userId)
        }
        
        assertEquals("User not found", exception.message)
        verify(userRepository).getUserById(userId)
    }
    
    @Test
    fun `should register user successfully`() {
        // Given
        val name = "Jane Doe"
        val email = "[email protected]"
        
        // Configure mock to return true for any User object
        whenever(userRepository.saveUser(any())).thenReturn(true)
        
        // When
        val user = userService.registerUser(name, email)
        
        // Then
        assertEquals(name, user.name)
        assertEquals(email, user.email)
        assertTrue(user.id.startsWith("user-"))
        
        // Verify that saveUser was called with a User that has the right name and email
        argumentCaptor<User>().apply {
            verify(userRepository).saveUser(capture())
            assertEquals(name, firstValue.name)
            assertEquals(email, firstValue.email)
        }
    }
    
    @Test
    fun `should throw exception when user registration fails`() {
        // Given
        whenever(userRepository.saveUser(any())).thenReturn(false)
        
        // When & Then
        val exception = assertThrows<RuntimeException> {
            userService.registerUser("Name", "[email protected]")
        }
        
        assertEquals("Failed to register user", exception.message)
    }
}
Best Practices for Kotlin Unit Testing
Here are some best practices to follow when writing unit tests in Kotlin:
1. Follow the AAA Pattern (Arrange-Act-Assert)
@Test
fun `should follow AAA pattern`() {
    // Arrange - Set up the test conditions
    val calculator = Calculator()
    val a = 5
    val b = 3
    
    // Act - Perform the action being tested
    val result = calculator.add(a, b)
    
    // Assert - Verify the results
    assertEquals(8, result)
}
2. Use Descriptive Test Names
Kotlin allows backtick-enclosed method names with spaces, which makes for very readable test names:
@Test
fun `should return correct full name when both first and last names are provided`() {
    // Test implementation
}
@Test
fun `should handle null last name by using only first name`() {
    // Test implementation
}
3. Test One Thing Per Test
Each test should verify a single behavior to make it clear what functionality is broken when a test fails.
4. Use Test Fixtures for Common Setup
class UserServiceFixture {
    val repository = mock<UserRepository>()
    val service = UserService(repository)
    
    fun givenUserExists(id: String, name: String): User {
        val user = User(id, name, "$name@example.com")
        whenever(repository.getUserById(id)).thenReturn(user)
        return user
    }
}
class UserServiceTest {
    private lateinit var fixture: UserServiceFixture
    
    @BeforeEach
    fun setup() {
        fixture = UserServiceFixture()
    }
    
    @Test
    fun `test using fixture`() {
        val user = fixture.givenUserExists("123", "John")
        val result = fixture.service.getUserName("123")
        assertEquals(user.name, result)
    }
}
5. Use Parameterized Tests
JUnit 5 allows you to run the same test with different parameters:
@ParameterizedTest
@CsvSource(
    "5,3,8",    // a, b, expected
    "10,-5,5",
    "0,0,0",
    "-3,-7,-10"
)
fun `should add numbers correctly for various inputs`(a: Int, b: Int, expected: Int) {
    val calculator = Calculator()
    assertEquals(expected, calculator.add(a, b))
}
Testing Kotlin Coroutines
For testing coroutines, Kotlin provides the kotlinx-coroutines-test library:
// build.gradle.kts
dependencies {
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}
Here's an example of testing a suspend function:
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*
class CoroutineServiceTest {
    
    @Test
    fun `test suspend function`() = runTest {
        // Given
        val service = CoroutineService()
        
        // When
        val result = service.fetchData("test")
        
        // Then
        assertEquals("Data for test", result)
    }
}
class CoroutineService {
    suspend fun fetchData(id: String): String {
        delay(1000) // This delay is skipped in runTest
        return "Data for $id"
    }
}
Real-World Example: Testing a Weather App
Let's put everything together in a more practical example - a simple weather app service:
// Weather data models
data class WeatherInfo(val temperature: Double, val conditions: String)
// API service interface
interface WeatherApiService {
    suspend fun getWeatherForCity(city: String): WeatherInfo?
}
// Repository that uses the API
class WeatherRepository(private val apiService: WeatherApiService) {
    private val cache = mutableMapOf<String, CachedWeather>()
    
    data class CachedWeather(val weatherInfo: WeatherInfo, val timestamp: Long)
    
    suspend fun getWeatherForCity(city: String): WeatherInfo? {
        // Check cache first (valid for 30 minutes)
        val cachedData = cache[city]
        val currentTime = System.currentTimeMillis()
        
        if (cachedData != null && currentTime - cachedData.timestamp < 30 * 60 * 1000) {
            return cachedData.weatherInfo
        }
        
        // Fetch fresh data
        val freshData = apiService.getWeatherForCity(city)
        
        // Update cache
        if (freshData != null) {
            cache[city] = CachedWeather(freshData, currentTime)
        }
        
        return freshData
    }
}
// Application service using the repository
class WeatherService(private val weatherRepository: WeatherRepository) {
    suspend fun getFormattedWeather(city: String): String {
        val weather = weatherRepository.getWeatherForCity(city)
            ?: throw IllegalArgumentException("Weather data not available for $city")
        
        return "${city}: ${weather.temperature}°C, ${weather.conditions}"
    }
    
    fun isCold(temperature: Double): Boolean = temperature < 10.0
}
Now, let's test our WeatherService:
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.assertThrows
import org.mockito.kotlin.*
class WeatherServiceTest {
    
    private lateinit var apiService: WeatherApiService
    private lateinit var repository: WeatherRepository
    private lateinit var weatherService: WeatherService
    
    @BeforeEach
    fun setup() {
        apiService = mock()
        repository = WeatherRepository(apiService)
        weatherService = WeatherService(repository)
    }
    
    @Test
    fun `should format weather data correctly`() = runTest {
        // Given
        val city = "London"
        val weatherInfo = WeatherInfo(15.5, "Cloudy")
        
        whenever(apiService.getWeatherForCity(city)).thenReturn(weatherInfo)
        
        // When
        val result = weatherService.getFormattedWeather(city)
        
        // Then
        assertEquals("London: 15.5°C, Cloudy", result)
    }
    
    @Test
    fun `should throw exception when weather data is not available`() = runTest {
        // Given
        val city = "NonExistentCity"
        whenever(apiService.getWeatherForCity(city)).thenReturn(null)
        
        // When & Then
        val exception = assertThrows<IllegalArgumentException> {
            weatherService.getFormattedWeather(city)
        }
        
        assertEquals("Weather data not available for NonExistentCity", exception.message)
    }
    
    @Test
    fun `should use cached data when available and fresh`() = runTest {
        // Given
        val city = "Berlin"
        val weatherInfo = WeatherInfo(20.0, "Sunny")
        
        whenever(apiService.getWeatherForCity(city)).thenReturn(weatherInfo)
        
        // First call to populate cache
        repository.getWeatherForCity(city)
        
        // Reset mock to verify it's not called again
        reset(apiService)
        
        // When
        val result = repository.getWeatherForCity(city)
        
        // Then
        assertEquals(weatherInfo, result)
        verify(apiService, never()).getWeatherForCity(any())
    }
    
    @Test
    fun `should correctly identify cold weather`() {
        assertTrue(weatherService.isCold(5.0))
        assertFalse(weatherService.isCold(15.0))
    }
}
Summary
In this guide, we've explored:
- Setting up JUnit 5 for Kotlin unit testing
- Writing basic unit tests with assertions
- Using Mockito for mocking dependencies
- Testing coroutines with runTest
- Best practices for effective unit testing
- Real-world examples that demonstrate testing principles
Unit testing is an investment in the quality and maintainability of your codebase. While it requires some initial effort, the payoff in terms of bug prevention, code confidence, and documentation is substantial.
Additional Resources
- JUnit 5 Official Documentation
- Mockito-Kotlin GitHub Repository
- Kotlin Coroutines Testing Guide
- Test-Driven Development by Example by Kent Beck
Exercises
- Write unit tests for a function that validates email addresses
- Create a shopping cart class with methods for adding items, removing items, and calculating total price, then write comprehensive tests for it
- Implement a mock-based test for a service that depends on a database repository
- Write tests for a coroutine-based function that processes a list of items asynchronously
- Refactor an existing untested class to make it more testable, then add tests
Happy testing!
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!