Skip to main content

Kotlin TDD (Test-Driven Development)

Introduction

Test-Driven Development (TDD) is a software development approach where tests are written before the actual code. In this guide, we'll explore how to apply TDD principles in Kotlin projects. TDD helps create robust, bug-resistant code and encourages better software design.

The TDD workflow follows a simple cycle often called "Red-Green-Refactor":

  1. Red: Write a failing test for the functionality you want to implement
  2. Green: Write just enough code to make the test pass
  3. Refactor: Improve the code while keeping the tests passing

Let's dive into Kotlin TDD and see how it can transform your development process.

Setting Up Your Kotlin Project for TDD

Before we start, let's make sure we have the necessary dependencies in our Kotlin project.

For a Gradle project, add these to your build.gradle.kts:

kotlin
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.9.2")
testImplementation("io.mockk:mockk:1.13.4")
}

tasks.test {
useJUnitPlatform()
}

For Maven users, add these 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>
<dependency>
<groupId>io.mockk</groupId>
<artifactId>mockk</artifactId>
<version>1.13.4</version>
<scope>test</scope>
</dependency>
</dependencies>

TDD in Practice: A Simple Example

Let's start with a simple calculator example to demonstrate TDD in Kotlin.

Step 1: Write a Failing Test (Red)

First, create a test file for our calculator class:

kotlin
// src/test/kotlin/CalculatorTest.kt
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class CalculatorTest {
@Test
fun `should add two numbers correctly`() {
// Given
val calculator = Calculator()

// When
val result = calculator.add(3, 5)

// Then
assertEquals(8, result)
}
}

If you try to run this test, it will fail because we haven't created our Calculator class yet. That's expected in TDD!

Step 2: Write Minimal Code to Pass the Test (Green)

Now let's implement just enough code to make the test pass:

kotlin
// src/main/kotlin/Calculator.kt
class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}
}

Run the test again, and it should pass. Congratulations! You've just completed your first TDD cycle.

Step 3: Refactor (if needed)

Our implementation is already quite simple, but in more complex scenarios, this is where you'd improve your code without changing its behavior.

TDD with More Complex Features

Let's extend our calculator with a more complex feature: a function to calculate the average of a list of numbers.

Step 1: Write a Failing Test (Red)

kotlin
// Adding to CalculatorTest.kt
@Test
fun `should calculate average of numbers correctly`() {
// Given
val calculator = Calculator()
val numbers = listOf(2, 4, 6, 8, 10)

// When
val result = calculator.average(numbers)

// Then
assertEquals(6.0, result)
}

@Test
fun `should return zero for empty list when calculating average`() {
// Given
val calculator = Calculator()

// When
val result = calculator.average(emptyList())

// Then
assertEquals(0.0, result)
}

Step 2: Write Code to Pass the Tests (Green)

Now let's extend our Calculator class:

kotlin
// Extending Calculator.kt
class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}

fun average(numbers: List<Int>): Double {
if (numbers.isEmpty()) return 0.0
return numbers.sum().toDouble() / numbers.size
}
}

Step 3: Refactor

We could refactor our code to make it more concise using Kotlin's features:

kotlin
// Refactored Calculator.kt
class Calculator {
fun add(a: Int, b: Int): Int = a + b

fun average(numbers: List<Int>): Double =
numbers.takeIf { it.isNotEmpty() }
?.average()
?: 0.0
}

Real-world Example: Building a User Service with TDD

Let's apply TDD to a more realistic example: a user service for a web application.

Step 1: Define the Interface

First, let's define what our UserService interface should look like:

kotlin
// src/main/kotlin/user/UserService.kt
package user

interface UserService {
fun getUserById(id: String): User?
fun createUser(userData: UserData): User
}

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

data class UserData(
val name: String,
val email: String
)

Step 2: Write Tests for Implementation

kotlin
// src/test/kotlin/user/UserServiceImplTest.kt
package user

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

class UserServiceImplTest {
private lateinit var userRepository: UserRepository
private lateinit var userService: UserServiceImpl

@BeforeEach
fun setup() {
userRepository = mockk()
userService = UserServiceImpl(userRepository)
}

@Test
fun `should return user when getUserById finds a user`() {
// Given
val userId = "user123"
val expectedUser = User(userId, "John Doe", "[email protected]")
every { userRepository.findById(userId) } returns expectedUser

// When
val result = userService.getUserById(userId)

// Then
assertEquals(expectedUser, result)
verify { userRepository.findById(userId) }
}

@Test
fun `should return null when getUserById doesn't find a user`() {
// Given
val userId = "nonexistent"
every { userRepository.findById(userId) } returns null

// When
val result = userService.getUserById(userId)

// Then
assertNull(result)
verify { userRepository.findById(userId) }
}

@Test
fun `should create and return a new user`() {
// Given
val userData = UserData("Jane Smith", "[email protected]")
val generatedId = "user456"
val expectedUser = User(generatedId, userData.name, userData.email)

every { userRepository.nextId() } returns generatedId
every { userRepository.save(any()) } returns expectedUser

// When
val result = userService.createUser(userData)

// Then
assertEquals(expectedUser, result)
verify {
userRepository.nextId()
userRepository.save(User(generatedId, userData.name, userData.email))
}
}
}

Step 3: Define the Repository Interface

Based on our tests, we need a UserRepository interface:

kotlin
// src/main/kotlin/user/UserRepository.kt
package user

interface UserRepository {
fun findById(id: String): User?
fun save(user: User): User
fun nextId(): String
}

Step 4: Implement the Service

Now we implement our service to make the tests pass:

kotlin
// src/main/kotlin/user/UserServiceImpl.kt
package user

class UserServiceImpl(private val userRepository: UserRepository) : UserService {
override fun getUserById(id: String): User? {
return userRepository.findById(id)
}

override fun createUser(userData: UserData): User {
val newUser = User(
id = userRepository.nextId(),
name = userData.name,
email = userData.email
)

return userRepository.save(newUser)
}
}

Step 5: Implement the Repository (if needed)

For completeness, we can implement an in-memory repository:

kotlin
// src/main/kotlin/user/InMemoryUserRepository.kt
package user

import java.util.UUID

class InMemoryUserRepository : UserRepository {
private val users = mutableMapOf<String, User>()

override fun findById(id: String): User? {
return users[id]
}

override fun save(user: User): User {
users[user.id] = user
return user
}

override fun nextId(): String {
return UUID.randomUUID().toString()
}
}

Benefits of TDD in Kotlin

  1. Higher Code Quality: TDD encourages you to think about edge cases and design
  2. Better Design: Writing tests first leads to more modular, loosely coupled code
  3. Built-in Documentation: Tests serve as examples of how code should work
  4. Fewer Bugs: Regression bugs are caught early by existing tests
  5. Refactoring Confidence: You can refactor knowing tests will catch regressions

Common TDD Patterns in Kotlin

Arrange-Act-Assert (AAA)

This pattern structures your tests into three clear sections:

kotlin
@Test
fun `example of AAA pattern`() {
// Arrange - set up the test context
val calculator = Calculator()

// Act - perform the action being tested
val result = calculator.add(3, 5)

// Assert - verify the results
assertEquals(8, result)
}

Given-When-Then

This is similar to AAA but with more behavior-driven terminology:

kotlin
@Test
fun `example of Given-When-Then pattern`() {
// Given a calculator
val calculator = Calculator()

// When adding two numbers
val result = calculator.add(3, 5)

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

TDD Best Practices

  1. Write Minimal Tests First: Focus on the simplest test case that fails
  2. Make Small Increments: Add functionality in small steps
  3. Test Behavior, Not Implementation: Focus on what your code does, not how it does it
  4. Refactor Regularly: Don't skip the refactoring step
  5. Maintain Your Tests: Treat test code with the same care as production code

Common TDD Pitfalls to Avoid

  1. Writing Too Many Tests at Once: Focus on one feature at a time
  2. Testing Implementation Details: Tests should verify behavior, not specific implementations
  3. Skipping Refactoring: The refactor step is crucial for maintaining code quality
  4. Over-mocking: Excessive mocking can lead to tests that don't provide value
  5. Test-After Development: Writing tests after code defeats the purpose of TDD

Summary

Test-Driven Development in Kotlin provides a powerful way to build robust applications with fewer bugs. By following the Red-Green-Refactor cycle, you'll create more maintainable code with built-in verification of your requirements.

TDD may feel slower at first, but it typically leads to faster development in the long run by reducing debugging time and rework. With Kotlin's concise syntax and strong type system, TDD becomes even more effective.

Additional Resources

Exercises

  1. FizzBuzz TDD: Implement the classic FizzBuzz problem using TDD (print numbers 1 to 100, but "Fizz" for multiples of 3, "Buzz" for multiples of 5, and "FizzBuzz" for multiples of both)
  2. String Calculator: Create a calculator that handles string inputs like "1,2,3" and returns their sum
  3. Todo List Manager: Build a simple Todo list application with features to add, remove, and mark items as complete
  4. Password Validator: Create a service that validates passwords based on complexity rules
  5. Shopping Cart: Implement a shopping cart with features like adding/removing items and calculating totals with discounts

By practicing these exercises using TDD, you'll gain confidence in the approach and see how it improves your code design and reliability.



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