Introduction and Overview

In this lesson, we'll deepen our understanding of the Test-Driven Development (TDD) mindset by focusing on the Red-Green-Refactor cycle with a practical example centered on a calculateTotal function. This example will guide you through the process of thinking in tests, prioritizing test writing, and leveraging TDD to enhance code clarity, reliability, and maintainability.

Using Kotlin and JUnit, we'll follow these steps:

  • Begin with the Red phase by identifying and writing failing tests for the calculateTotal function, which will compute the total price of items in a shopping cart.
  • Move to the Green phase to implement the minimal code required to pass each test, ensuring that the calculateTotal function behaves as expected.
  • Enter the Refactor phase to improve the code structure and readability of calculateTotal, employing techniques like Kotlin's functional programming constructs for aggregation while keeping tests green.
  • Utilize JUnit as a testing framework to efficiently integrate the TDD approach within our Kotlin project.

By working through this example, you'll gain practical experience with TDD principles and develop the calculateTotal function in a way that showcases how TDD fosters code quality and robustness.

Example: calculateTotal Function (Red Phase)

Let's begin by writing tests for a function named calculateTotal (from the Cart class, which we'll take a look at soon), designed to compute the total price of items in a shopping cart. This is where you engage with the Red phase: Write a failing test.

Let's think about how to build a method that calculates lists of items. What should the interface be? How does the consumer of the code use it? These are the questions we think about first when we "think in tests." Here's one way we might think about it in Kotlin.

Kotlin
1import org.junit.jupiter.api.Assertions.assertEquals 2import org.junit.jupiter.api.Test 3 4class CartTest { 5 6 @Test 7 fun calculateTotalShouldReturnZeroForEmptyCart() { 8 val items = listOf<CartItem>() 9 10 val cart = Cart() 11 val total = cart.calculateTotal(items) 12 13 assertEquals(0.0, total, 0.001) // Use a small delta for double comparison 14 } 15}

Explanation:

  • We know we want a method called calculateTotal(), so we'll write a test that uses it.
  • For now, we know that we want an empty list as input to return 0, so we can write that test.
  • We use a small delta of 0.001 for the assertEquals method to handle precision issues with double values, ensuring that minor floating-point errors do not cause the test to fail.
  • The expectation is that these tests will initially fail, which is an integral part of the Red phase.

Running these tests confirms they fail, creating a clear path for subsequent development.

Example: Passing the Tests (Green Phase)

Now, let's move to the Green phase, where we implement the minimal code to pass these tests.

Implement the calculateTotal method in a new Cart class using a list of CartItem objects:

Kotlin
1class Cart { 2 fun calculateTotal(items: List<CartItem>): Double { 3 return 0.0 // Minimal implementation to pass the first test 4 } 5}

Explanation:

  • The calculateTotal method takes a List of CartItem objects. At this stage, we are not concerned with the details of how the data will be processed.
  • Returning 0.0 is enough to get the test to pass, allowing us to focus on iterating through the TDD process.

By running the test suite again, we should see all tests passing, demonstrating that our function meets the required condition.

Example: Refactor!

Even though, in this case, there is nothing to be changed, always be mindful about potential improvements during the refactor phase.

Example: Write Another Test (Red)

Now is the time to think about what kind of data we want to pass to our calculateTotal method. We consider that we'd like to pass the name, price, and quantity as properties in the CartItem class. The total will be the product of price * quantity. Let's do that and see how it feels:

Kotlin
1@Test 2fun calculateTotalShouldReturnCorrectTotalForSingleItem() { 3 val items = listOf( 4 CartItem("Apple", 0.5, 3) 5 ) 6 7 val cart = Cart() 8 val total = cart.calculateTotal(items) 9 10 assertEquals(1.5, total, 0.001) 11}

That feels pretty good; the interface seems clear. Let's see if we can get those tests passing.

Example: Make It Pass Again (Green)

Now we need to think about how to make these tests pass. What is the minimum necessary to get this to pass?

Kotlin
1class Cart { 2 fun calculateTotal(items: List<CartItem>): Double { 3 return if (items.isNotEmpty()) { 4 items[0].price * items[0].quantity 5 } else { 6 0.0 7 } 8 } 9}

When we run the tests, we should see that both tests pass! We're Green!

Example: Refactor

Now we ask ourselves: is there anything we can do to make this code better? Let's define our CartItem class with its required properties.

Kotlin
1data class CartItem( 2 val name: String, 3 val price: Double, 4 val quantity: Int 5)

Let's simplify and improve our implementation of calculateTotal using Kotlin's straightforward iteration:

Kotlin
1class Cart { 2 fun calculateTotal(items: List<CartItem>): Double { 3 return items.fold(0.0) { acc, item -> acc + item.price * item.quantity } 4 } 5}

In the above snippet, we used fold:

  • fold: Accumulates the total price by iterating over the list and summing up the product of price and quantity for each CartItem.

Our tests help us ensure the code is still Green!

Example: Write a Failing Test (Red)

The existing code works! But it won't be very useful to only use the fold operation naively without checking if other operations might fit better. Let's explore this by writing an additional test.

Kotlin
1@Test 2fun calculateTotalShouldReturnCorrectTotalForMultipleItems() { 3 val items = listOf( 4 CartItem("Apple", 0.5, 3), 5 CartItem("Banana", 0.3, 2) 6 ) 7 8 val cart = Cart() 9 val total = cart.calculateTotal(items) 10 11 assertEquals(2.1, total, 0.001) 12}

As expected, this new test will fail, and we can move to the Green step.

Example: Make the Test Pass (Green)

Let's take a stab at getting the test to pass:

Kotlin
1class Cart { 2 fun calculateTotal(items: List<CartItem>): Double { 3 return items.sumByDouble { it.price * it.quantity } 4 } 5}

This does the job, and we're Green again. The code now efficiently calculates the total using sumByDouble, which is idiomatic in Kotlin for such tasks.

Example: Refactor!

When we look at the calculateTotal function, it looks nicely generalized, but Kotlin allows for more idiomatic and clean approaches:

Kotlin
1class Cart { 2 fun calculateTotal(items: List<CartItem>): Double { 3 return items.sumOf { it.price * it.quantity } 4 } 5}

Let's recap the functions used in this approach:

  • sumOf: Directly sums up the result of applying the given selector function to each element of the collection, resulting in clearer and more concise code.

I like this code a lot better! Best of all, our tests tell us that we're still Green! We've successfully refactored the code using Kotlin's expressive functions.

Running the Tests:

To run the JUnit tests, configure your Kotlin development environment. You can use an IDE like IntelliJ IDEA with Kotlin and JUnit support. Run the tests directly within the IDE using the Run feature, or employ Gradle for command-line execution:

Here's how you might run the tests using Gradle:

Bash
1./gradlew test
Summary and Preparation for Practice

Throughout this lesson, we focused on refining the TDD mindset by emphasizing writing tests prior to coding and following the Red-Green-Refactor cycle. Here's what we covered:

  • Red Phase: We started by writing failing tests, such as determining that calculateTotal should return 0 for an empty cart and a specific total for a single item. This helped us clearly define our interface and objectives.
  • Green Phase: We implemented minimal solutions to pass each test condition, like returning a simple calculation for a single CartItem or a total for an entire list of items.
  • Refactor Phase: We improved the code structure and readability, utilizing Kotlin's built-in functional constructs for aggregation, ensuring that the function remains expressive and maintainable while tests continue passing. Even if no changes are needed, always consider potential improvements.

These steps in the TDD workflow have shown how test-first development can clarify requirements, ensure accuracy, and guide continuous improvement. This foundation prepares you for practice exercises aimed at reinforcing these techniques, highlighting how TDD fosters clarity, reliability, and robustness in software development using Kotlin and JUnit.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal