Skip to main content

Kotlin Testing Basics

Testing is a fundamental aspect of software development that ensures your code works as expected. In this tutorial, we'll explore how to write tests for your Kotlin code using JUnit, the most popular testing framework for the JVM.

Why Testing Matters

Before diving into the technical details, let's understand why testing is crucial:

  • Bug Prevention: Catch bugs before they reach production
  • Documentation: Tests serve as living documentation of how your code should work
  • Refactoring Confidence: Change code without fear of breaking functionality
  • Design Improvement: Writing testable code often leads to better design

Setting Up Your Testing Environment

To get started with testing in Kotlin, you'll need to add the appropriate dependencies to your project.

Dependencies for Gradle

Add the following to your build.gradle.kts file:

kotlin
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.8.2")
testImplementation("org.jetbrains.kotlin:kotlin-test:1.6.21")
}

tasks.test {
useJUnitPlatform()
}

Dependencies for Maven

If you're using Maven, add this to your pom.xml:

xml
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId>
<version>1.6.21</version>
<scope>test</scope>
</dependency>
</dependencies>

Your First Test

Let's write a simple function and test it. Imagine we have a calculator class with basic operations:

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 this 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 `adding two numbers returns their sum`() {
// Given two numbers
val a = 5
val b = 3

// When adding them
val result = calculator.add(a, b)

// Then the result should be their sum
assertEquals(8, result)
}

@Test
fun `subtracting second number from first returns their difference`() {
assertEquals(2, calculator.subtract(5, 3))
}

@Test
fun `multiplying two numbers returns their product`() {
assertEquals(15, calculator.multiply(5, 3))
}

@Test
fun `dividing first number by second returns quotient`() {
assertEquals(2, calculator.divide(6, 3))
}

@Test
fun `dividing by zero throws IllegalArgumentException`() {
val exception = assertThrows<IllegalArgumentException> {
calculator.divide(5, 0)
}
assertEquals("Cannot divide by zero", exception.message)
}
}

Let's break down what's happening in our test class:

  1. We use @Test annotation to mark methods that should be executed as tests
  2. In Kotlin, we can use backticks (`) to write descriptive test names with spaces
  3. We follow the "Given-When-Then" structure in our tests to make them clear and readable
  4. The assertEquals function checks if the expected and actual values match
  5. The assertThrows function verifies that an exception is thrown when expected

Understanding Assertions

JUnit provides various assertion methods to check if your code behaves as expected:

kotlin
@Test
fun `demonstration of different assertions`() {
// Basic equality assertions
assertEquals(5, 2 + 3)
assertNotEquals(4, 2 + 3)

// Boolean assertions
assertTrue(5 > 3)
assertFalse(5 < 3)

// Null assertions
val nullValue: String? = null
assertNull(nullValue)

val nonNullValue = "Hello"
assertNotNull(nonNullValue)

// Collection assertions
val list = listOf(1, 2, 3)
assertTrue(list.contains(2))
assertEquals(3, list.size)

// Reference equality
val a = ArrayList<Int>()
val b = a
val c = ArrayList<Int>()
assertSame(a, b) // a and b reference the same object
assertNotSame(a, c) // a and c reference different objects
}

Test Fixtures: Setup and Teardown

When multiple tests share the same setup logic, you can use test fixtures to avoid code duplication:

kotlin
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class UserRepositoryTest {
private lateinit var repository: UserRepository
private lateinit var testDatabase: TestDatabase

@BeforeEach
fun setup() {
testDatabase = TestDatabase.createInMemoryDatabase()
repository = UserRepository(testDatabase)

// Initialize with some test data
repository.save(User("1", "Alice"))
repository.save(User("2", "Bob"))
}

@AfterEach
fun cleanup() {
testDatabase.close()
}

@Test
fun `findById returns user when exists`() {
val user = repository.findById("1")
assertNotNull(user)
assertEquals("Alice", user?.name)
}

@Test
fun `findById returns null when user does not exist`() {
val user = repository.findById("999")
assertNull(user)
}
}

In this example:

  • @BeforeEach methods run before each test
  • @AfterEach methods run after each test
  • You can also use @BeforeAll and @AfterAll for class-level setup/teardown (these must be on static methods in Java, but in Kotlin, you can use a companion object or top-level functions)

As your test suite grows, you'll want to organize related tests. JUnit provides nested tests for this purpose:

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

class ShoppingCartTest {
private val cart = ShoppingCart()

@Nested
inner class EmptyCart {
@Test
fun `total should be zero`() {
assertEquals(0.0, cart.getTotal())
}

@Test
fun `should be empty`() {
assertTrue(cart.isEmpty())
}
}

@Nested
inner class CartWithItems {
init {
cart.addItem(Item("Book", 15.99))
cart.addItem(Item("Pen", 1.99))
}

@Test
fun `total should be sum of items`() {
assertEquals(17.98, cart.getTotal(), 0.01) // Third parameter is delta for floating-point comparison
}

@Test
fun `should not be empty`() {
assertFalse(cart.isEmpty())
}

@Test
fun `removing an item reduces total`() {
cart.removeItem("Book")
assertEquals(1.99, cart.getTotal(), 0.01)
}
}
}

The @Nested annotation allows you to group related tests and share setup code. This makes your tests more organized and easier to understand.

Parameterized Tests

When you want to run the same test with different inputs, parameterized tests come in handy:

kotlin
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.junit.jupiter.api.Assertions.*

class StringUtilsTest {
@ParameterizedTest
@ValueSource(strings = ["", " ", " ", "\t", "\n"])
fun `isBlank returns true for blank strings`(input: String) {
assertTrue(StringUtils.isBlank(input))
}

@ParameterizedTest
@ValueSource(strings = ["a", " a ", "abc"])
fun `isBlank returns false for non-blank strings`(input: String) {
assertFalse(StringUtils.isBlank(input))
}

@ParameterizedTest
@CsvSource(
"hello, HELLO",
"World, WORLD",
"kotlin test, KOTLIN TEST"
)
fun `toUpperCase converts string to uppercase`(input: String, expected: String) {
assertEquals(expected, StringUtils.toUpperCase(input))
}
}

Here we use:

  • @ParameterizedTest to indicate a parameterized test
  • @ValueSource to provide single values
  • @CsvSource to provide multiple related values per test case

Testing Exceptions

We've already seen how to test exceptions with assertThrows, but let's explore it a bit more:

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

class ExceptionTest {
@Test
fun `divide by zero throws exception`() {
val calculator = Calculator()

val exception = assertThrows<IllegalArgumentException> {
calculator.divide(10, 0)
}

// We can verify the exception message
assertEquals("Cannot divide by zero", exception.message)
}

@Test
fun `user validation throws exception for invalid email`() {
val validator = UserValidator()

assertThrows<ValidationException> {
validator.validate(User(email = "invalid-email"))
}
}
}

Real-World Testing Example: Todo Application

Let's apply what we've learned to a more realistic example - a simple todo list application:

kotlin
// src/main/kotlin/TodoService.kt
class TodoService(private val repository: TodoRepository) {
fun addTodo(title: String, description: String = ""): Todo {
require(title.isNotBlank()) { "Title cannot be blank" }

val todo = Todo(
id = generateId(),
title = title,
description = description,
completed = false,
createdAt = System.currentTimeMillis()
)

return repository.save(todo)
}

fun markAsCompleted(id: String): Todo {
val todo = repository.findById(id) ?: throw NoSuchElementException("Todo not found with id: $id")
val updated = todo.copy(completed = true)
return repository.update(updated)
}

fun getAllTodos(): List<Todo> = repository.findAll()

fun getCompletedTodos(): List<Todo> = repository.findAll().filter { it.completed }

fun getPendingTodos(): List<Todo> = repository.findAll().filter { !it.completed }

private fun generateId(): String = java.util.UUID.randomUUID().toString()
}

// Simplified model and repository interface
data class Todo(
val id: String,
val title: String,
val description: String,
val completed: Boolean,
val createdAt: Long
)

interface TodoRepository {
fun save(todo: Todo): Todo
fun update(todo: Todo): Todo
fun findById(id: String): Todo?
fun findAll(): List<Todo>
fun deleteById(id: String)
}

Now, let's write tests for this service using a mock repository:

kotlin
// src/test/kotlin/TodoServiceTest.kt
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.Assertions.*

class TodoServiceTest {
// Simple in-memory repository implementation for testing
private class InMemoryTodoRepository : TodoRepository {
private val todos = mutableMapOf<String, Todo>()

override fun save(todo: Todo): Todo {
todos[todo.id] = todo
return todo
}

override fun update(todo: Todo): Todo {
if (!todos.containsKey(todo.id)) {
throw NoSuchElementException("Todo not found with id: ${todo.id}")
}
todos[todo.id] = todo
return todo
}

override fun findById(id: String): Todo? = todos[id]

override fun findAll(): List<Todo> = todos.values.toList()

override fun deleteById(id: String) {
todos.remove(id)
}
}

private lateinit var repository: TodoRepository
private lateinit var service: TodoService

@BeforeEach
fun setup() {
repository = InMemoryTodoRepository()
service = TodoService(repository)
}

@Test
fun `addTodo creates a new todo with correct properties`() {
// When adding a new todo
val todo = service.addTodo("Buy groceries", "Milk, eggs, bread")

// Then the todo should have the correct properties
assertEquals("Buy groceries", todo.title)
assertEquals("Milk, eggs, bread", todo.description)
assertFalse(todo.completed)
assertNotNull(todo.id)
}

@Test
fun `addTodo throws exception when title is blank`() {
assertThrows<IllegalArgumentException> {
service.addTodo("", "Empty title")
}

assertThrows<IllegalArgumentException> {
service.addTodo(" ", "Blank title")
}
}

@Test
fun `markAsCompleted changes todo state to completed`() {
// Given a new todo
val todo = service.addTodo("Write tests")
assertFalse(todo.completed)

// When marking it as completed
val completedTodo = service.markAsCompleted(todo.id)

// Then the todo should be completed
assertTrue(completedTodo.completed)
assertEquals(todo.id, completedTodo.id)
assertEquals(todo.title, completedTodo.title)
}

@Test
fun `markAsCompleted throws exception when todo doesn't exist`() {
assertThrows<NoSuchElementException> {
service.markAsCompleted("non-existent-id")
}
}

@Test
fun `getCompletedTodos returns only completed todos`() {
// Given some todos
service.addTodo("Task 1")
val todo2 = service.addTodo("Task 2")
val todo3 = service.addTodo("Task 3")

// When marking some as completed
service.markAsCompleted(todo2.id)
service.markAsCompleted(todo3.id)

// Then getCompletedTodos should return only the completed ones
val completed = service.getCompletedTodos()
assertEquals(2, completed.size)
assertTrue(completed.all { it.completed })
assertTrue(completed.map { it.title }.containsAll(listOf("Task 2", "Task 3")))
}

@Test
fun `getPendingTodos returns only pending todos`() {
// Given some todos
val todo1 = service.addTodo("Task 1")
val todo2 = service.addTodo("Task 2")
service.addTodo("Task 3")

// When marking some as completed
service.markAsCompleted(todo2.id)

// Then getPendingTodos should return only the pending ones
val pending = service.getPendingTodos()
assertEquals(2, pending.size)
assertTrue(pending.none { it.completed })
assertTrue(pending.map { it.title }.containsAll(listOf("Task 1", "Task 3")))
}
}

This example demonstrates several important testing concepts:

  1. Creating a test-specific implementation of an interface for testing
  2. Setting up test data in the @BeforeEach method
  3. Testing both happy paths and error cases
  4. Using descriptive test names to document behavior
  5. Following the Given-When-Then pattern for clear test structure
  6. Testing multiple aspects of the system's behavior

Summary

In this tutorial, we've covered the basics of testing Kotlin applications:

  • Setting up JUnit for Kotlin testing
  • Writing your first tests
  • Using assertions to verify expected behavior
  • Setting up and cleaning up test data with fixtures
  • Grouping related tests for better organization
  • Writing parameterized tests
  • Testing exception scenarios
  • Applying these concepts to a real-world application

Testing is an essential skill for any developer, and consistent testing leads to more reliable, maintainable code. By starting with these fundamentals, you're on your way to building high-quality Kotlin applications.

Additional Resources

Exercises

  1. Write tests for a StringUtils class with methods for common string operations (reverse, capitalize, etc.)
  2. Create a UserService class with methods to register, authenticate, and manage users - with comprehensive tests
  3. Add support for filtering and sorting to the TodoService and write tests for these features
  4. Practice Test-Driven Development: Write tests first, then implement a ShoppingCart class to make the tests pass


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