Skip to main content

Kotlin Test Coverage

Testing your code is essential for building reliable applications, but how do you know if you've tested enough? Test coverage helps answer this question by measuring how much of your code is executed during tests. In this guide, you'll learn how to implement, measure, and improve test coverage in your Kotlin projects.

What is Test Coverage?

Test coverage is a metric that helps you understand what percentage of your code is being executed when your tests run. It's a valuable indicator of the thoroughness of your testing effort and helps identify untested parts of your codebase.

Key Coverage Metrics

  • Line Coverage: The percentage of code lines executed during tests
  • Branch Coverage: The percentage of branches (like if/else conditions) that are evaluated during tests
  • Function Coverage: The percentage of functions or methods called during tests
  • Statement Coverage: The percentage of statements executed during tests

Setting Up Test Coverage in Your Kotlin Project

JaCoCo (Java Code Coverage) is the most popular tool for measuring test coverage in Kotlin projects. Let's see how to set it up with Gradle.

Adding JaCoCo to Your Project

First, add the JaCoCo plugin to your build.gradle.kts file:

kotlin
plugins {
kotlin("jvm") version "1.9.0"
application
// Add JaCoCo plugin
jacoco
}

Then configure JaCoCo in the same file:

kotlin
jacoco {
// Configure the JaCoCo version (optional)
toolVersion = "0.8.10"
}

tasks.jacocoTestReport {
reports {
xml.required.set(true)
csv.required.set(false)
html.required.set(true)
html.outputLocation.set(layout.buildDirectory.dir("jacocoHtml"))
}
}

// Link the test task with JaCoCo
tasks.test {
finalizedBy(tasks.jacocoTestReport)
}

Running Test Coverage Analysis

Once configured, you can run your tests with coverage analysis using:

bash
./gradlew test

This will execute your tests and automatically generate coverage reports.

To view the report, open the HTML report located at build/jacocoHtml/index.html in your browser.

A Practical Example

Let's see this in action with a simple calculator example. First, let's create our calculator class:

kotlin
// src/main/kotlin/com/example/Calculator.kt
package com.example

class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}

fun subtract(a: Int, b: Int): Int {
return a - b
}

fun multiply(a: Int, b: Int): Int {
return a * b
}

fun divide(a: Int, b: Int): Int {
if (b == 0) {
throw IllegalArgumentException("Cannot divide by zero")
}
return a / b
}
}

Now, let's write some tests for it:

kotlin
// src/test/kotlin/com/example/CalculatorTest.kt
package com.example

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

class CalculatorTest {
private val calculator = Calculator()

@Test
fun `test add`() {
assertEquals(5, calculator.add(2, 3))
assertEquals(0, calculator.add(-2, 2))
}

@Test
fun `test subtract`() {
assertEquals(2, calculator.subtract(5, 3))
assertEquals(-5, calculator.subtract(0, 5))
}

// Notice: We're missing tests for multiply and divide
}

Understanding the Coverage Report

When we run our tests with JaCoCo, we'll see that our calculator class is only partially covered:

  • Our tests cover the add and subtract methods (100% coverage)
  • But multiply and divide methods have 0% coverage

This is clearly visible in the HTML report, which highlights covered code in green and missed code in red.

Improving Our Coverage

Let's add tests for the missing methods:

kotlin
// Adding to our CalculatorTest class
@Test
fun `test multiply`() {
assertEquals(6, calculator.multiply(2, 3))
assertEquals(0, calculator.multiply(0, 5))
}

@Test
fun `test divide`() {
assertEquals(2, calculator.divide(10, 5))
assertEquals(0, calculator.divide(0, 5))
assertThrows<IllegalArgumentException> {
calculator.divide(5, 0)
}
}

Now when we run ./gradlew test again, our coverage report should show 100% coverage for the Calculator class.

Setting Coverage Thresholds

It's a good practice to enforce minimum coverage thresholds to maintain code quality. Here's how to set it up:

kotlin
tasks.test {
finalizedBy(tasks.jacocoTestReport)
}

tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = "0.8".toBigDecimal() // 80% minimum coverage
}
}
}
}

// Make the check task depend on coverage verification
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}

With this configuration, your build will fail if test coverage falls below 80%.

Best Practices for Test Coverage

While aiming for high coverage is good, here are some important practices:

  1. Focus on quality over quantity: 100% coverage with poor assertions isn't valuable
  2. Prioritize critical code paths: Focus more on business logic and less on boilerplate
  3. Don't chase 100% blindly: Some code might be trivial or difficult to test
  4. Review uncovered code: Look at what's not covered and ask why
  5. Include coverage in CI/CD: Automate coverage checks in your pipelines

Real-World Example: Testing a User Service

Let's look at a more realistic example by testing a user service:

kotlin
// src/main/kotlin/com/example/UserService.kt
package com.example

class User(val id: Int, val name: String, val email: String)

interface UserRepository {
fun save(user: User): User
fun findById(id: Int): User?
fun findAll(): List<User>
}

class UserService(private val repository: UserRepository) {
fun createUser(name: String, email: String): User {
if (name.isBlank()) throw IllegalArgumentException("Name cannot be blank")
if (!email.contains("@")) throw IllegalArgumentException("Invalid email format")

val id = generateId()
val user = User(id, name, email)
return repository.save(user)
}

fun getUser(id: Int): User {
return repository.findById(id) ?: throw NoSuchElementException("User not found")
}

fun getAllUsers(): List<User> {
return repository.findAll()
}

private fun generateId(): Int {
// Simple implementation for the example
return (Math.random() * 1000).toInt()
}
}

Now let's write comprehensive tests with mocks:

kotlin
// src/test/kotlin/com/example/UserServiceTest.kt
package com.example

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class UserServiceTest {
private val repository = mockk<UserRepository>()
private val service = UserService(repository)

@Test
fun `createUser should save valid user`() {
// Given
val name = "John Doe"
val email = "[email protected]"
every { repository.save(any()) } returns User(1, name, email)

// When
val result = service.createUser(name, email)

// Then
assertEquals(name, result.name)
assertEquals(email, result.email)
verify { repository.save(any()) }
}

@Test
fun `createUser should throw exception for blank name`() {
assertThrows<IllegalArgumentException> {
service.createUser("", "[email protected]")
}
}

@Test
fun `createUser should throw exception for invalid email`() {
assertThrows<IllegalArgumentException> {
service.createUser("John Doe", "invalid-email")
}
}

@Test
fun `getUser should return user when found`() {
// Given
val user = User(1, "John", "[email protected]")
every { repository.findById(1) } returns user

// When
val result = service.getUser(1)

// Then
assertEquals(user, result)
}

@Test
fun `getUser should throw exception when user not found`() {
// Given
every { repository.findById(any()) } returns null

// Then
assertThrows<NoSuchElementException> {
service.getUser(1)
}
}

@Test
fun `getAllUsers should return list of users`() {
// Given
val users = listOf(
User(1, "John", "[email protected]"),
User(2, "Jane", "[email protected]")
)
every { repository.findAll() } returns users

// When
val result = service.getAllUsers()

// Then
assertEquals(users, result)
}
}

Running our tests with JaCoCo would show high coverage for our UserService class.

Coverage for Different Types of Code

Different types of code may require different testing approaches:

Data Classes

For Kotlin data classes, test the generated methods like equals(), hashCode(), and toString():

kotlin
data class Product(val id: Int, val name: String, val price: Double)

// Test
@Test
fun `Product equals compares correctly`() {
val product1 = Product(1, "Laptop", 999.99)
val product2 = Product(1, "Laptop", 999.99)
val product3 = Product(2, "Phone", 499.99)

assertEquals(product1, product2)
assertNotEquals(product1, product3)
}

Extension Functions

For extension functions, test them directly:

kotlin
// Extension function
fun String.isValidEmail(): Boolean {
return this.isNotEmpty() && this.contains("@") && this.contains(".")
}

// Test
@Test
fun `isValidEmail extension works correctly`() {
assertTrue("[email protected]".isValidEmail())
assertFalse("invalid-email".isValidEmail())
assertFalse("".isValidEmail())
}

Summary

Test coverage is an essential tool in your Kotlin testing arsenal. Now you know:

  • What test coverage is and why it's important
  • How to set up JaCoCo in your Kotlin project
  • How to analyze and interpret coverage reports
  • How to write tests that improve your coverage
  • Best practices for using coverage metrics effectively

Remember that while high test coverage is beneficial, it's just one aspect of a comprehensive testing strategy. Focus on writing meaningful tests that verify your code's behavior, not just increasing coverage numbers.

Additional Resources

Exercises

  1. Add JaCoCo to an existing Kotlin project and analyze its current coverage
  2. Identify and test an uncovered function in your codebase
  3. Set up coverage thresholds in your CI/CD pipeline
  4. Write tests for a complex function with multiple branches to achieve 100% branch coverage
  5. Create a custom JaCoCo report that filters out generated code or certain packages


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