Last Updated on 18/10/2024 by Grant Little
Introduction
I’m a huge fan of Test-Driven Development (TDD). I don’t know a better way in software to have a requirement, test the logic meets the requirement and also do it a way that means you write the minimum amount of code that still allows for easy refactoring and on-going test automation.
There are plenty of discussions out there on the topic, but what I’ve found is that there are less examples on how exactly to go about it, i.e. how do I just get started with it.
I’ve put together this post to try and provide a simple example using the typical Pet Clinic app.
Now obviously (or maybe not!) I’m not going to do a full implementation. This is just a get started guide, to make you think about the logic steps you might take and just to get going. From there you should be able to continue the pattern yourself.
The examples are all in Kotlin, but the examples should really apply to any language or testing framework.
What is Test-Driven Development (TDD) and Why Use It?
Test-Driven Development (TDD) is a development approach where tests are written before the actual code. By following the Red-Green-Refactor cycle, developers ensure that the code is reliable, maintainable, and designed with future scalability in mind. Here’s what each step of the cycle involves:
- Red: Write a failing test for the feature you’re about to implement.
- Green: Write just enough code to pass the test.
- Refactor: Clean up the code to make it more efficient, while ensuring the test still passes.
TDD is a great way to avoid bugs and ensure that your code does exactly what it’s supposed to. For Kotlin developers, adopting TDD also encourages better design patterns, resulting in more modular and testable codebases. In this guide, we’ll walk through building a pet clinic application using TDD in Kotlin, starting with the domain models and gradually adding complexity.
Setting Up a Kotlin Project for TDD Using Maven or Gradle
In this section, we’ll cover how to set up a Kotlin project for TDD using Maven or Gradle as the build tool. Both are popular build tools in the Kotlin ecosystem, and they can be easily configured to support Test-Driven Development.
Option 1: Using Maven for TDD in Kotlin
If you’re using Maven, you can configure your pom.xml
file with the necessary dependencies to get started with TDD.
Add Kotlin and JUnit dependencies: In the pom.xml
, add the following dependencies:
<properties>
<kotlin.version>1.6.21</kotlin.version>
<junit.version>5.8.2</junit.version>
</properties>
<dependencies>
<!-- Kotlin standard library -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>
<!-- JUnit 5 for testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
XMLConfigure Kotlin Compiler Plugin: Add the Kotlin Maven plugin to your build section to compile Kotlin code:
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>test-compile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
XMLRun Tests: You can run the tests using the command:
mvn test
BashMaven provides a highly structured way of managing dependencies and plugins, and once your pom.xml
is configured, running tests with TDD becomes straightforward.
Option 2: Using Gradle for TDD in Kotlin
If you prefer Gradle for managing dependencies and tasks, here’s how you can set up your project for TDD:
Add dependencies in build.gradle.kts
: Use the following configuration to add Kotlin and JUnit dependencies in your Gradle Kotlin DSL
val kotlinVersion = "1.6.21"
val junitVersion = "5.8.2"
plugins {
kotlin("jvm") version kotlinVersion
}
repositories {
mavenCentral()
}
dependencies {
implementation(kotlin("stdlib", kotlinVersion))
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
}
tasks.test {
useJUnitPlatform()
}
KotlinCompile and Test:
Compile your code using:
./gradlew build
BashRun the tests with:
./gradlew test
BashGradle offers a more flexible and dynamic approach to build management, and the configuration allows you to quickly add, manage, and modify dependencies for both production and testing.
Both Maven and Gradle offer robust support for Kotlin and testing with JUnit 5, making it easy to get started with TDD in a Kotlin project. By configuring either build tool to support unit testing, you’ll be able to apply the TDD principles of writing tests before implementation, ensuring that your code is reliable from the start.
Writing the First Test: A Simple Domain Model
Now that we have the project set up using either Maven or Gradle, let’s move forward with writing the first test for our pet clinic application. We’ll begin by creating a simple Pet
domain model, following TDD principles.
Step 1: Write a Failing Test
In TDD, we start by writing a test that defines the behaviour we want, even though the implementation doesn’t exist yet. Let’s write a test that ensures we can create a Pet
with a name and type.
Create a test file (PetTest.kt
) under the src/test/kotlin
directory:
// src/test/kotlin/com/petclinic/PetTest.kt
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class PetTest {
@Test
fun `Should create a pet with name and type`() {
val pet = Pet("Buddy", "Dog")
assertEquals("Buddy", pet.name)
assertEquals("Dog", pet.type)
}
}
KotlinAt this point, the test will fail because the Pet
class doesn’t exist yet. This is the Red phase of TDD, where we expect the test to fail initially.
Step 2: Implement the Minimum Code to Pass the Test
Now, let’s write just enough code to make this test pass. Create the Pet
class in src/main/kotlin
:
// src/main/kotlin/com/petclinic/Pet.kt
data class Pet(val name: String, val type: String)
KotlinWith this simple implementation, the test will pass, transitioning us from the Red phase to the Green phase. This is the core of TDD—write the test, make it pass, and then move on.
Step 3: Run the Tests
Depending on your build tool, use the appropriate command to run your test:
Maven:
mvn test
BashGradle:
./gradlew test
BashIf everything is set up correctly, you should see the tests pass successfully.
Step 4: Refactor the Code (Optional)
At this point, the test passes, but you may want to refactor the code if necessary while keeping the test green. In this case, our implementation is straightforward, so we can move on to adding more complex functionality.
Iterating: Adding Business Logic for Scheduling Appointments
Now that we have a basic Pet
model in place, let’s move on to more complex features by adding business logic for scheduling appointments. We’ll follow the TDD approach by writing a test for the appointment scheduling functionality first.
Step 1: Write the Failing Test for Scheduling an Appointment
We’ll create a PetClinic
class, which will handle scheduling appointments. Let’s start by writing a test for this functionality. The test should ensure that an appointment can be scheduled for a pet and that the appointment details are correctly stored.
Add this test to your test suite (PetClinicTest.kt
):
// src/test/kotlin/com/petclinic/PetClinicTest.kt
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class PetClinicTest {
@Test
fun `Should schedule an appointment for a pet`() {
val pet = Pet("Buddy", "Dog")
val clinic = PetClinic()
val appointment = clinic.scheduleAppointment(pet, "2024-10-20")
assertTrue(appointment.isScheduled)
assertEquals("Buddy", appointment.pet.name)
assertEquals("2024-10-20", appointment.date)
}
}
KotlinAt this stage, you haven’t written the PetClinic
or Appointment
classes, so this test will fail, which is expected in the Red phase of TDD.
Step 2: Write the Minimum Code to Pass the Test
Next, let’s implement the simplest code to make this test pass. First, create the PetClinic
and Appointment
classes:
// src/main/kotlin/com/petclinic/PetClinic.kt
class PetClinic {
fun scheduleAppointment(pet: Pet, date: String): Appointment {
return Appointment(pet, date, true)
}
}
Kotlin// src/main/kotlin/com/petclinic/Appointment.kt
data class Appointment(val pet: Pet, val date: String, val isScheduled: Boolean)
KotlinWith this code, the test should now pass. You can run the tests again using your preferred build tool:
Maven:
mvn test
BashGradle:
./gradlew test
BashThis will take you from the Red phase to the Green phase in the TDD cycle.
Step 3: Refactor and Add Complexity
Now that we have the basic appointment scheduling feature in place, let’s refactor and add more complexity. For example, you might want to handle edge cases, such as preventing the scheduling of duplicate appointments for the same pet at the same time.
Here’s how you can write a test to handle this scenario:
@Test
fun `Should not allow duplicate appointments for the same pet at the same time`() {
val pet = Pet("Buddy", "Dog")
val clinic = PetClinic()
clinic.scheduleAppointment(pet, "2024-10-20")
val exception = assertThrows<IllegalArgumentException> {
clinic.scheduleAppointment(pet, "2024-10-20")
}
assertEquals("Appointment already scheduled for this time.", exception.message)
}
KotlinTo make this test pass, update the PetClinic
class to track appointments and throw an exception when a duplicate is detected:
// src/main/kotlin/com/petclinic/PetClinic.kt
class PetClinic {
private val appointments = mutableListOf<Appointment>()
fun scheduleAppointment(pet: Pet, date: String): Appointment {
if (appointments.any { it.pet == pet && it.date == date }) {
throw IllegalArgumentException("Appointment already scheduled for this time.")
}
val appointment = Appointment(pet, date, true)
appointments.add(appointment)
return appointment
}
}
KotlinNow, the test ensures that the system prevents scheduling the same pet twice for the same time slot. Running the tests again will validate this behaviour.
Step 4: Run and Refactor
After implementing the logic, run all your tests to ensure everything works:
Maven:
mvn test
BashGradle:
./gradlew test
BashOnce the tests pass, you can clean up your code if needed. This is the Refactor phase in the TDD cycle, where you improve the code structure while keeping functionality intact.
Adding More Complex Business Logic: Managing Clinic Hours and Pet Owner Details
As we expand the pet clinic application, the next step is to manage clinic hours and store pet owner details. Again, we’ll follow the TDD approach, writing tests first and iterating on the implementation.
Step 1: Managing Clinic Hours
We need to ensure that appointments are only scheduled during clinic hours. Let’s start by defining the clinic hours and then prevent appointments from being scheduled outside of these hours.
Here’s a test that checks whether the clinic prevents appointments outside working hours:
@Test
fun `Should not allow scheduling appointments outside clinic hours`() {
val pet = Pet("Buddy", "Dog")
val clinic = PetClinic(openHour = 9, closeHour = 17)
val exception = assertThrows<IllegalArgumentException> {
clinic.scheduleAppointment(pet, "2024-10-20 08:00")
}
assertEquals("Appointment time is outside clinic hours.", exception.message)
}
KotlinThis test ensures that appointments before 9 AM or after 5 PM will throw an error. Now, let’s implement the logic in the PetClinic
class:
// src/main/kotlin/com/petclinic/PetClinic.kt
class PetClinic(private val openHour: Int, private val closeHour: Int) {
private val appointments = mutableListOf<Appointment>()
fun scheduleAppointment(pet: Pet, dateTime: String): Appointment {
val appointmentHour = dateTime.split(" ")[1].split(":")[0].toInt()
if (appointmentHour < openHour || appointmentHour >= closeHour) {
throw IllegalArgumentException("Appointment time is outside clinic hours.")
}
val appointment = Appointment(pet, dateTime, true)
appointments.add(appointment)
return appointment
}
}
KotlinThis implementation checks whether the appointment falls within the clinic’s operating hours before allowing it to be scheduled.
Step 2: Storing Pet Owner Details
Next, we can enhance our Pet
model to include owner details, such as the owner’s name and contact information. This will allow us to store important information about the pet’s owner along with their pet.
Here’s the test for creating a pet with owner details:
@Test
fun `Should create a pet with owner details`() {
val owner = Owner("John Doe", "john.doe@example.com")
val pet = Pet("Buddy", "Dog", owner)
assertEquals("John Doe", pet.owner.name)
assertEquals("john.doe@example.com", pet.owner.email)
}
KotlinTo pass this test, update the Pet
and Owner
classes as follows:
// src/main/kotlin/com/petclinic/Owner.kt
data class Owner(val name: String, val email: String)
// src/main/kotlin/com/petclinic/Pet.kt
data class Pet(val name: String, val type: String, val owner: Owner)
KotlinNow the Pet
model includes an Owner
, and you can access the owner’s details when needed.
Step 3: Extending Business Logic for Pet Ownership
You might want to add further complexity, such as restricting certain actions based on pet ownership. For example, let’s write a test that prevents scheduling an appointment for a pet if the owner’s email is invalid:
@Test
fun `Should not allow scheduling an appointment for a pet with invalid owner email`() {
val owner = Owner("John Doe", "invalid-email")
val pet = Pet("Buddy", "Dog", owner)
val clinic = PetClinic()
val exception = assertThrows<IllegalArgumentException> {
clinic.scheduleAppointment(pet, "2024-10-20")
}
assertEquals("Invalid owner email.", exception.message)
}
KotlinTo handle this, you can add email validation to the PetClinic
class:
// src/main/kotlin/com/petclinic/PetClinic.kt
import java.util.regex.Pattern
class PetClinic {
private val appointments = mutableListOf<Appointment>()
fun scheduleAppointment(pet: Pet, date: String): Appointment {
if (!isValidEmail(pet.owner.email)) {
throw IllegalArgumentException("Invalid owner email.")
}
val appointment = Appointment(pet, date, true)
appointments.add(appointment)
return appointment
}
private fun isValidEmail(email: String): Boolean {
val emailRegex = "^[A-Za-z0-9+_.-]+@(.+)$"
val pattern = Pattern.compile(emailRegex)
return pattern.matcher(email).matches()
}
}
KotlinWith this functionality, the system will validate the owner’s email before allowing an appointment to be scheduled, ensuring data consistency.
Step 4: Running and Refactoring
As always, after implementing the logic, run the tests:
Maven:
mvn test
BashGradle:
./gradlew test
BashIf all tests pass, you’re in the Green phase, and you can refactor the code if necessary to keep it clean and maintainable.
Conclusion
By the end of this guide, you should have a solid understanding of how to implement Test-Driven Development (TDD) in Kotlin. Starting from setting up a project with Maven or Gradle, we’ve explored the core principles of TDD using the Red-Green-Refactor cycle. Through the example of building a pet clinic application, you’ve learned how to:
- Write tests before implementing code to define and confirm functionality.
- Gradually develop a simple domain model, like the
Pet
class, by iterating on features using TDD. - Introduce and manage more complex business logic, such as scheduling appointments, enforcing clinic hours, and handling edge cases with TDD.
- Refactor your code safely, knowing that tests provide a safety net for maintaining functionality.
By following TDD, you can ensure that your application is reliable, maintainable, and easy to refactor as new features are added. As you apply these practices to your own projects, you’ll be able to build more confident and robust software.
Resources
Dave Farley from Continuous Delivery provides a good video tutorial on this topic on his YouTube channel