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:
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
:
<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:
// 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:
// 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:
- We use
@Test
annotation to mark methods that should be executed as tests - In Kotlin, we can use backticks (`) to write descriptive test names with spaces
- We follow the "Given-When-Then" structure in our tests to make them clear and readable
- The
assertEquals
function checks if the expected and actual values match - 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:
@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:
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)
Grouping Related Tests
As your test suite grows, you'll want to organize related tests. JUnit provides nested tests for this purpose:
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:
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:
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:
// 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:
// 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:
- Creating a test-specific implementation of an interface for testing
- Setting up test data in the
@BeforeEach
method - Testing both happy paths and error cases
- Using descriptive test names to document behavior
- Following the Given-When-Then pattern for clear test structure
- 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
- JUnit 5 User Guide
- Kotlin Documentation on Testing
- Test-Driven Development By Example by Kent Beck
- Mockk Library for mocking in Kotlin
Exercises
- Write tests for a
StringUtils
class with methods for common string operations (reverse, capitalize, etc.) - Create a
UserService
class with methods to register, authenticate, and manage users - with comprehensive tests - Add support for filtering and sorting to the
TodoService
and write tests for these features - 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! :)