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":
- Red: Write a failing test for the functionality you want to implement
- Green: Write just enough code to make the test pass
- 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
:
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
:
<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:
// 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:
// 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)
// 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:
// 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:
// 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:
// 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
// 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:
// 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:
// 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:
// 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
- Higher Code Quality: TDD encourages you to think about edge cases and design
- Better Design: Writing tests first leads to more modular, loosely coupled code
- Built-in Documentation: Tests serve as examples of how code should work
- Fewer Bugs: Regression bugs are caught early by existing tests
- 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:
@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:
@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
- Write Minimal Tests First: Focus on the simplest test case that fails
- Make Small Increments: Add functionality in small steps
- Test Behavior, Not Implementation: Focus on what your code does, not how it does it
- Refactor Regularly: Don't skip the refactoring step
- Maintain Your Tests: Treat test code with the same care as production code
Common TDD Pitfalls to Avoid
- Writing Too Many Tests at Once: Focus on one feature at a time
- Testing Implementation Details: Tests should verify behavior, not specific implementations
- Skipping Refactoring: The refactor step is crucial for maintaining code quality
- Over-mocking: Excessive mocking can lead to tests that don't provide value
- 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
- JUnit 5 User Guide
- MockK Documentation
- Test-Driven Development: By Example by Kent Beck
- Growing Object-Oriented Software, Guided by Tests by Steve Freeman and Nat Pryce
Exercises
- 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)
- String Calculator: Create a calculator that handles string inputs like "1,2,3" and returns their sum
- Todo List Manager: Build a simple Todo list application with features to add, remove, and mark items as complete
- Password Validator: Create a service that validates passwords based on complexity rules
- 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! :)