Skip to main content

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:

kotlin
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:

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:

kotlin
// 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:

kotlin
// 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:

kotlin
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:

kotlin
@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:

kotlin
// 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:

kotlin
// 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:

kotlin
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)

kotlin
@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:

kotlin
@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

kotlin
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:

kotlin
@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:

kotlin
// build.gradle.kts
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}

Here's an example of testing a suspend function:

kotlin
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:

kotlin
// 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:

kotlin
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

Exercises

  1. Write unit tests for a function that validates email addresses
  2. Create a shopping cart class with methods for adding items, removing items, and calculating total price, then write comprehensive tests for it
  3. Implement a mock-based test for a service that depends on a database repository
  4. Write tests for a coroutine-based function that processes a list of items asynchronously
  5. Refactor an existing untested class to make it more testable, then add tests

Happy testing!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)