Test Driven Development: An Introduction

What is Test Driven Development (TDD)

Test-Driven Development (TDD) is a powerful approach to software development that emphasizes the importance of testing throughout the development process. By writing tests first and then developing the code to pass those tests, TDD ensures high-quality, maintainable, and reliable code. In this post, we will elaborate on the TDD process, its key principles, and the benefits it provides.

Why TDD?

TDD is a development process that involves writing automated tests before writing the actual code. The main goal of TDD is to ensure that the code being developed meets the requirements and works as expected. This means that TDD aims to write tests for any piece of code which contains logic, not just business requirements.

One of the key benefits of TDD is that it helps catch bugs early in the development process. By writing tests first, developers can catch issues before they become larger problems down the line. Additionally, TDD can lead to better code quality and design. Since developers must consider the test cases before writing the code, they are forced to think about the problem in a more structured and organized way. This often results in cleaner, more modular code that is easier to maintain.

Another benefit of TDD is that it helps improve code documentation. Since the tests serve as documentation for the code's expected behavior, it becomes easier for other developers to understand how the code works and what its intended purpose is. This makes it easier for new developers to jump into a project and understand the codebase.

Overall, TDD can lead to faster development times, better code quality, and improved documentation. By catching bugs early and ensuring that the code meets the requirements, TDD helps reduce the risk of introducing errors and makes it easier to maintain the code over time.

TDD Process

1. Red-Green-Refactor Cycle

The TDD process revolves around a continuous cycle of three stages: Red, Green, and Refactor.

  • Red: Write a failing test. The test should cover a specific requirement or functionality and fail initially, as there is no code to satisfy the test.
  • Green: Write the minimum amount of code necessary to make the test pass. Focus on getting the test to pass without worrying about optimizations or clean code.
  • Refactor: Improve the code without changing its behavior. This step involves making the code more readable, maintainable, and efficient while preserving its functionality.

2. Write Tests First

Writing tests before the actual code shifts the focus from "how" the code works to "what" the code should do. This approach helps developers concentrate on the desired functionality and encourages them to think through the requirements before diving into the implementation.

3. Test Small Units of Code

TDD encourages developers to break down complex problems into smaller, more manageable units. By testing these small units, developers can ensure that each part of the code works correctly and identify issues early in the development process.

4. Continuous Integration

As the codebase grows, it becomes increasingly important to integrate and test changes frequently. Continuous integration is a practice that involves merging all developers' working copies to a shared mainline several times a day. This approach helps prevent integration problems and ensures that the codebase is always in a releasable state.

Benefits of the TDD Process

  1. Improved Code Quality: TDD leads to higher code quality by requiring developers to think about the expected behavior of their code before writing it. This process helps identify potential issues before they become problems, ensuring that the final code is more reliable, robust, and less prone to bugs.
  2. Better Test Coverage: By writing tests first, developers ensure comprehensive test coverage for their code. Higher test coverage means that more parts of the code are tested, reducing the likelihood of undetected bugs and ensuring the code's correctness.
  3. Easier Debugging: With TDD, debugging becomes more manageable as the code is broken into small, testable units. When a test fails, developers can easily pinpoint the issue, since the tests are focused on specific functionality. This approach saves time and effort in identifying and fixing bugs.
  4. Faster Development: TDD encourages developers to break down complex problems into smaller, manageable units. This approach makes it easier to develop, test, and debug code, leading to a more efficient development process.
  5. Simplified Code Maintenance: TDD promotes code that is easier to maintain and modify. The constant cycle of refactoring ensures that the code remains clean and organized, making it simpler to extend, update, or modify the codebase as needed.
  6. Enhanced Collaboration: With TDD, tests serve as living documentation that describes the intended functionality of the code. This documentation makes it easier for other developers to understand the code, leading to better collaboration within the team and smoother handoffs between team members.
  7. Reduced Technical Debt: Technical debt arises when developers prioritize short-term goals, like adding new features or meeting deadlines, over long-term code quality. TDD helps reduce technical debt by emphasizing the importance of writing clean, maintainable, and well-tested code from the beginning.
  8. Increased Confidence in Code: When a codebase has comprehensive tests, developers can confidently make changes, knowing that the tests will catch any unexpected issues. This confidence encourages experimentation and innovation, as developers are less afraid of breaking existing functionality.
  9. Easier Integration: TDD promotes smaller, more focused units of code, which are easier to integrate with other parts of the codebase. This approach reduces the likelihood of integration issues and ensures that the overall system remains stable and functional.
  10. Better Project Estimation: With TDD, developers gain a better understanding of the requirements and the scope of the project. This understanding allows for more accurate project estimations and helps avoid scope creep or unexpected delays.

TDD First Example

Let's walk through a simple example using Kotlin to demonstrate the TDD process. Suppose we want to create a function that calculates the factorial of a given number.

1. Write a failing test

First, we'll write a test that checks if the factorial function returns the correct result for a specific input. Since we haven't written the factorial function yet, this test will fail.

import org.junit.Test
import org.junit.Assert.assertEquals

class FactorialTest {
    @Test
    fun testFactorial() {
        assertEquals(120, factorial(5))
    }
}

2. Write the code to pass the test

Now, we'll write the minimum amount of code required to pass the test. We can use a simple loop to calculate the factorial.

fun factorial(n: Int): Int {
    var result = 1
    for (i in 1..n) {
        result *= i
    }
    return result
}

3. Refactor

In this case, our code is already simple and clean, so there's no need to refactor. However, if there were any improvements to be made, this would be the time to do so.

4. Repeat

We can now go back to step 1 and write more tests to cover additional cases, such as negative numbers or edge cases. We'll then repeat the process until all desired functionality is covered and tested.

TDD Second Example

Let's take an example of a simple Java Shoppe application with a few classes: Product, Customer, and Order. We'll use Kotlin and demonstrate the TDD process by adding a new feature.

First, let's say we have the following classes:

Product.kt

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

Customer.kt

data class Customer(val id: Int, val name: String)

Order.kt

class Order(val customer: Customer) {
    private val items = mutableListOf<Product>()

    fun addProduct(product: Product) {
        items.add(product)
    }

    fun total(): Double {
        return items.sumByDouble { it.price }
    }
}

Now, let's say we want to add a new feature: applying a discount to the total price of an order. We'll follow the TDD process:

  1. Write a failing test.
  2. Write the minimum code to pass the test.
  3. Refactor the code.

Step 1: Write a failing test

We'll start by writing a test that checks if the discount is applied correctly. We'll create a new test file called OrderTest.kt:

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class OrderTest {
    @Test
    fun `should apply discount to the total price`() {
        val customer = Customer(1, "John Doe")
        val order = Order(customer)

        val product1 = Product(1, "Coffee", 5.0)
        val product2 = Product(2, "Tea", 3.0)

        order.addProduct(product1)
        order.addProduct(product2)

        order.applyDiscount(0.1)

        val expectedTotal = 7.2
        assertEquals(expectedTotal, order.total(), 0.001)
    }
}

This test will initially fail, as we haven't implemented the applyDiscount method yet.

Step 2: Write the minimum code to pass the test

Next, we'll add the applyDiscount method and a discount property to the Order class:

class Order(val customer: Customer) {
    private val items = mutableListOf<Product>()
    private var discount: Double = 0.0

    fun addProduct(product: Product) {
        items.add(product)
    }

    fun applyDiscount(discount: Double) {
        this.discount = discount
    }

    fun total(): Double {
        val totalWithoutDiscount = items.sumByDouble { it.price }
        return totalWithoutDiscount * (1 - discount)
    }
}

Now, if we run the test again, it should pass.

Step 3: Refactor the code

The current implementation is simple, and there's no need for major refactoring. But let's make a small improvement by using a backing property for the discount property to ensure it's always within the 0.0 to 1.0 range:

class Order(val customer: Customer) {
    private val items = mutableListOf<Product>()
    private var _discount: Double = 0.0
    var discount: Double
        get() = _discount
        set(value) {
            _discount = value.coerceIn(0.0, 1.0)
        }

    fun addProduct(product: Product) {
        items.add(product)
    }

    fun applyDiscount(discount: Double) {
        this.discount = discount
    }

    fun total(): Double {
        val totalWithoutDiscount = items.sumBy

TDD Third Example

Suppose we have a simple Java Shoppe application that allows customers to order coffee and calculate the total cost of their order. Here's a basic version of the application:

data class Coffee(val name: String, val price: Double)

class JavaShoppe {
    private val menu = listOf(
        Coffee("Espresso", 2.50),
        Coffee("Cappuccino", 3.00),
        Coffee("Latte", 3.50),
        Coffee("Americano", 2.00)
    )

    fun showMenu() {
        println("Java Shoppe Menu:")
        menu.forEachIndexed { index, coffee ->
            println("${index + 1}. ${coffee.name} - $${coffee.price}")
        }
    }

    fun calculateTotal(orders: List<Int>): Double {
        return orders.sumOf { menu[it - 1].price }
    }
}

Now, let's say we want to add a feature that applies a discount when the customer orders more than a certain number of coffees. To do this using TDD, we will follow these steps:

  1. Write a failing test: Write a test that checks if the discount is applied correctly when the customer orders more than the required number of coffees.

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class JavaShoppeTest {
    @Test
    fun `discount should be applied when more than required number of coffees are ordered`() {
        val javaShoppe = JavaShoppe()
        val orders = listOf(1, 2, 3, 4, 4)
        val expectedTotal = 11.00
        val actualTotal = javaShoppe.calculateTotalWithDiscount(orders)
        
        assertEquals(expectedTotal, actualTotal)
    }
}

  1. Run the test and watch it fail: The test should fail because we have not yet implemented the calculateTotalWithDiscount() function in the JavaShoppe class.
  2. Implement the feature: Add the calculateTotalWithDiscount() function in the JavaShoppe class to make the test pass.
class JavaShoppe {
    // ...

    fun calculateTotalWithDiscount(orders: List<Int>): Double {
        val total = calculateTotal(orders)
        val discountThreshold = 5
        val discount = 0.1

        return if (orders.size >= discountThreshold) {
            total * (1 - discount)
        } else {
            total
        }
    }
}
  1. Run the test again: The test should now pass, indicating that the discount feature is working as expected.
  2. Refactor: If necessary, clean up the code and make sure the test still passes after refactoring.

By following the TDD process, we have ensured that our new feature is working as intended, and we can have confidence in the correctness of our code. The test also serves as documentation for other developers, helping them understand the intended functionality of the discount feature.

Subscribe to rohp

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe