Unveiling Test-Driven Development: A Critical Examination of Its Efficacy (1/2).

Unveiling Test-Driven Development: A Critical Examination of Its Efficacy (1/2).


We've now arrived at the ninth stage of our exploration into the world of software testing. Over the past two stages, we've immersed ourselves in the mechanics of unit tests, mastering the technical intricacies of their creation (3.),(4.),(5.). Equipped with this knowledge, we are ready to delve into the realm of Test-Driven Development (TDD), which represents the most radical approach among agile development methodologies.

Test-Driven Development is a software development technique where tests are written before the implementation code. TDD follows a simple cycle known as "Red-Green-Refactor": write a failing test (Red), write the minimum code to pass the test (Green), and then refactor the code while keeping the tests passing. TDD promotes writing small, focused tests that drive the design and implementation of the software, leading to more modular, testable, and maintainable code.

Here is TDD state machine.

And here is the TDD workflow.


As we can see the cycle typically follows these steps:

  1. Write a Test: Before writing any code, developers first write a test that specifies a small unit of functionality.
  2. Run the Test: Execute all tests, including the new one. The new test should fail, as there's no implementation code yet to make it pass.
  3. Write Code: Develop the minimum amount of code necessary to make the new test pass.
  4. Run Tests Again: Execute all tests. They should all pass now, including the new test.
  5. Refactor: Once the test passes, refactor the code to improve its structure, readability, or performance, without changing its behavior.
  6. Repeat: Go back to step 1 and repeat the process for the next piece of functionality.

Let's break the ice by exploring a simple example of applying TDD.

Suppose we want to develop a class that manages a shopping cart for an e-commerce website. We'll start with basic functionality like adding items to the cart, removing items, and calculating the total price.

We'll follow the TDD cycle of writing a failing test, implementing the code to pass the test, and then refactoring.

?Let's start:

Step 1 - Create a failing test: We'll start by writing a test case that verifies the basic functionality of the shopping cart, such as adding items and calculating the total price.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class ShoppingCartTest {

    @Test
    public void testAddItem() {
        ShoppingCart cart = new ShoppingCart();
        cart.addItem("Laptop", 1000);
        assertEquals(1, cart.getItemCount());
    }

    @Test
    public void testCalculateTotalPrice() {
        ShoppingCart cart = new ShoppingCart();
        cart.addItem("Laptop", 1000);
        cart.addItem("Mouse", 20);
        assertEquals(1020, cart.calculateTotalPrice());
    }
}        

Step 2 - Run the Test: This will fail because the ShoppingCart class and Item class don't exist yet.

Step 3 - Implement the code to pass the test: Now, we'll create the ShoppingCart class and implement the necessary methods based on the failing tests.

import java.util.HashMap;
import java.util.Map;

public class ShoppingCart {
    private Map<String, Integer> items;

    public ShoppingCart() {
        this.items = new HashMap<>();
    }

    public void addItem(String itemName, int price) {
        items.put(itemName, price);
    }

    public int getItemCount() {
        return items.size();
    }

    public int calculateTotalPrice() {
        int total = 0;
        for (int price : items.values()) {
            total += price;
        }
        return total;
    }
}        

Step 4 - Run Tests Again: Now the tests should pass.

Step 5 - Refactor the code: After implementing the basic functionality, we may refactor the code to improve its design or readability. In this case, let's refactor the calculateTotalPrice method to use streams for better readability.

import java.util.Map;
import java.util.stream.Collectors;

public class ShoppingCart {
    private Map<String, Integer> items;

    public ShoppingCart() {
        this.items = new HashMap<>();
    }

    public void addItem(String itemName, int price) {
        items.put(itemName, price);
    }

    public int getItemCount() {
        return items.size();
    }

    public int calculateTotalPrice() {
        return items.values().stream().mapToInt(Integer::intValue).sum();
    }
}
        

Step 6 - Repeat: If you have more functionality to add, go back to step 1 and continue the cycle.

By following the TDD approach, we've developed a simple shopping cart system that allows adding items and calculating the total price. The process ensures that our code is thoroughly tested, and any changes made during refactoring won't break existing functionality.


Having reached this point, it's essential to address a fundamental question: Why should we write tests before code? Beginning the implementation of a feature by crafting the test first may seem counterintuitive to many people, prompting the question: Why would we adopt this approach?

TDD fans argue that there are several good reasons that make it convenient. I'll list the ones that I think are the most reasonable.

The first reason is that writing tests before code promotes better software design by encouraging developers to write code that is modular, loosely coupled, and highly cohesive. Since tests drive the implementation, developers often refactor the code to make it more testable, cleaner and more maintainable.

The second reason is that thinking about tests first drives us to consider and design the API of the "system under test" (SUT) in way that is easy to use. Often, when people write code first, the resulting API is bound to the implementation rather than being a nice and clean interface that hides all of the technical details.

The third reason is that? TDD provides a safety net for refactoring existing code. Developers can confidently make changes to the codebase, knowing that if any regressions occur, the tests will catch them. This facilitates continuous improvement and evolution of the software architecture over time.

The fourth reason is the simple design ensured by TDD. Indeed, with TDD, the appropriate design for the software at any given time is one that adheres to the following criteria:

  1. Passes all tests.
  2. Contains no duplicated logic.
  3. Utilizes the fewest possible classes and methods.

Every aspect of the system's design should be able to justify its presence based on these criteria. Simple design adheres to the principle of "removing any design element that can be present without violating rules 1, 2, and 3."

The last reason is that TDD addresses the need for solid quality assurance throughout the development lifecycle. By writing tests before implementing the code, developers ensure that each component of the software behaves as expected under various conditions. This proactive testing approach helps catch defects early, leading to higher overall software quality. Remember when we discussed in a previous article that the cost of fixing a bug in software increases exponentially over time. A problem that might cost one dollar to fix if discovered during requirements analysis could cost thousands of dollars to fix once the software is in production. The graph below illustrates the effort curve for bug resolution over time from the beginning of the project.

The cost of change rising exponentially over time


The TDD aims to flatten this curve and make it so.

The cost of change may not rise dramatically over time


Now that we've explored the positive aspects of TDD, let's proceed with another example of its application. I will tell you my doubts about the efficacy of TDD in the last part of this article.

Although I strongly believe in the usefulness of good unit tests, I am very skeptical about their excessive use (abuse, I think), as imposed by TDD.


As a second example of application of TDD, now we see the "Money Example".

"Multi-Currency Money" is a classic demonstration used by Kent Beck in his book "Test Driven Development: By Example" (1.) to illustrate the principles and practices of Test-Driven Development (TDD). In this example, Beck walks through the process of developing a simple program for handling money using TDD.

The goal of this example is to create a class representing money, which can perform basic arithmetic operations like addition, subtraction, multiplication, and division in different currencies and perform conversions between currencies. Kent Beck guides readers through the process of writing tests first, before writing the actual implementation code. This is the core principle of TDD: write a failing test, write the simplest code to pass the test, then refactor as needed.

Kent Beck starts by writing a failing test for creating money objects and performing basic operations. He then gradually refines the implementation code to make the tests pass, iterating through the TDD cycle multiple times. Along the way, he will use techniques like incremental development, keeping tests simple and focused, and refactoring to improve the design (2.).

The Money Example serves as a practical, hands-on introduction to TDD, showing how it can lead to well-designed, maintainable code through a series of small, iterative steps. It emphasizes the importance of writing tests as a means of driving the design and ensuring the correctness of the code.

I highly recommend you to read his book and follow Kent Beck in the more than one hundred pages that he dedicates to this example.


In Chapter 1 Kent Beck starts to write the first initial failing test.

public void testMultiplication() {
    Dollar five = new Dollar(5);
    five.times(2);
    assertEquals(10, five.amount);
}        

In this test, Kent Beck?is attempting to multiply a Dollar object by 2 and expects the result to be 10. However, the Dollar class and its times() method do not exist yet, so this test will fail.

Then he writes the minimal amount of code required to make the failing test pass.

public class Dollar {
    int amount;
    
    public Dollar(int amount) {
        this.amount = amount;
    }
    
    public void times(int multiplier) {
        this.amount *= multiplier;
    }
}        

With this simple implementation of the times() method in the Dollar class, the test now passes.

Furthermore, no explicit refactoring is required at this stage.

In Chapter 2 Kent Beck begins by noting that there is a side effect to the implementation of the Dollar class created in Chapter 1. He begins by writing the following test:

public void testMultiplication() {
    Dollar five= new Dollar(5);
    five.times(2);
    assertEquals(10, five.amount);
    five.times(3);
    assertEquals(15, five.amount);
}        

After the first call to times(), five isn't five any more, it's actually ten. If, however, we return a new object from times(), then we can multiply our original five dollars and never change it. We have to modify the interface of Dollar when we make this change, so we need to change the test.

public void testMultiplication() {
    Dollar five= new Dollar(5);
    Dollar product= five.times(2);
    assertEquals(10, product.amount);
    product= five.times(3);
    assertEquals(15, product.amount);
}        

The new test won't compile until we change the declaration of Dollar.times():

To pass this test, we would define a times() method in the Dollar class that has to return a new Dollar object with the result.

public class Dollar {
    public int amount;

    public Dollar(int amount) {
        this.amount = amount;
    }

    public Dollar times(int multiplier) {
        return new Dollar(this.amount * multiplier);
    }
}        

Now the test passes, no other explicit refactoring is required at this stage.

??

In chapter 3 Kent Beck explores the concept of object equality and introduces the importance of implementing equals() method consistently.

This is the test we want to pass:

public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
    assertFalse(new Dollar(5).equals(new Dollar(6)));
}        

To pass this test, we have to override the equals() method in the Dollar class to compare the amounts of two Dollar objects for equality.

public class Dollar {
    public int amount;


    public Dollar(int amount) {
        this.amount = amount;
    }

    public Dollar times(int multiplier) {
        return new Dollar(amount * multiplier);
    }

    @Override
    public boolean equals(Object obj) {
        Dollar dollar = (Dollar) obj;
        return amount == dollar.amount;
    }
}        

So, equality is done for the moment and the test passes. But what about comparing with null and comparing with other objects? These are commonly used operations but are not handled well by our code.?

So we need to use refactoring to improve our implementation. Here's a better implementation.

public class Dollar {
    public int amount;


    public Dollar(int amount) {
        this.amount = amount;
    }

    public Dollar times(int multiplier) {
        return new Dollar(amount * multiplier);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Dollar dollar = (Dollar) obj;
        return amount == dollar.amount;
    }
}        

Now that we have defined equality, we can use it to make our tests more "speaking."

Conceptually, the operation Dollar.times() should return a Dollar whose value is the value of the receiver times the multiplier. Our test doesn't exactly say that:

public void testMultiplication() {
    Dollar five= new Dollar(5);
    Dollar product= five.times(2);
    assertEquals(10, product.amount);
    product= five.times(3);
    assertEquals(15, product.amount);
}        

We can rewrite the assertions to compare Dollars to Dollars:

public void testMultiplication() {
    Dollar five= new Dollar(5);
    assertEquals(new Dollar(10), five.times(2));
    assertEquals(new Dollar(15), five.times(3));
}        

This test speaks to us more clearly, as if it were an assertion of truth, not a sequence of operations.

With these changes to the test, Dollar is now the only class using its amount instance variable, so we can make it private.?

Now the test fails because the "equals" function must be able to access the "amount" property of a Dollar object passed as a parameter.

To pass this test, we have to provide appropriate accessors, ensuring that the internal state is not directly accessible from outside the class.

public class Dollar {
    private int amount;


    public  Dollar(int amount) {
        this.amount = amount;
    }

    public Dollar times(int multiplier) {
        return new Dollar(amount * multiplier);
    }
    public int getAmount () {
        return amount;
    }


    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Dollar dollar = (Dollar) obj;
        return amount == dollar.getAmount();
    }
}        

In chapter 5 Kent Beck introduces another currency, the Franc, and ensures that it behaves similarly to the Dollar. We must ensure that objects of different currencies, such as francs and dollars, are not the same as each other.

This is the test we want to pass.

public void testEquality() {
    assertTrue(new Dollar(5).equals(new Dollar(5)));
    assertFalse(new Dollar(5).equals(new Dollar(6)));
    assertTrue(new Franc(5).equals(new Franc(5)));
    assertFalse(new Franc(5).equals(new Franc(6)));
    assertFalse(new Franc(5).equals(new Dollar(5)));
}        

Initially we create the Franc class by cloning the Dollar class, then we will do refactoring. During refactoring we have to eliminate duplicate code and improve the clarity of the implementation.

public class Franc {
    private int amount;
    public Franc(int amount) {
        this.amount= amount;
    }
    public Franc times(int multiplier) {
        return new Franc(amount * multiplier);
    }
    public int getAmount () {
        return amount;
    }

    public boolean equals(Object object) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;

        Franc franc= (Franc) object;
        return amount == franc.amount;
    }
}        


In chapters 6,7,8,9 Kent Beck uses refactoring in order to eliminate the large amount of duplicate code in the Dollar and Franc classes.

We are going to find a common superclass for the two classes, as shown in the class diagram and in the snippet below.


public class Money {
    protected int amount;
    public int getAmount () {
        return amount;
    }
    public boolean equals(Object object) {
        Money money = (Money) object;
        return amount == money.amount
            && getClass().equals(money.getClass());
    }
}        

Then we can make Franc and Dollar extend Money.

public class Franc extends Money {
    public Franc(int amount) {
        this.amount= amount;
    }
    public Franc times(int multiplier) {
        return new Franc(amount * multiplier);
    }
}

public class Dollar extends Money {
    public Dollar(int amount) {
        this.amount= amount;
    }
    public Dollar times(int multiplier) {
        return new Dollar(amount * multiplier);
    }
}        

We can observe that the two implementations of times() methods are very similar, so we can make them both return a Money.

public class Franc extends Money {
    public Franc(int amount) {
        this.amount= amount;
    }
    public Money times(int multiplier) {
        return new Franc(amount * multiplier);
    }
}
public class Dollar extends Money {
    public Dollar(int amount) {
        this.amount= amount;
    }
    public Money times(int multiplier) {
        return new Dollar(amount * multiplier);
    }
}        

The two subclasses of Money don't do enough work to justify their existence, so we'd like to eliminate them. But we can't do it with one big step, because that wouldn't be a very effective demonstration of TDD which instead recommends proceeding in small steps.

We would be one step closer to eliminating subclasses if there were fewer direct references to subclasses. We can introduce a factory method in Money that returns a dollar or a franc. We would use it like this:

abstract class Money {
    protected int amount;
    public int getAmount () {
        return amount;
    }
    public boolean equals(Object object) {
        Money money = (Money) object;
        return amount == money.amount
            && getClass().equals(money.getClass());
    }
    abstract Money times(int multiplier);

    public static Money dollar(int amount) {
        return new Dollar(amount);
    }
    public static Money franc(int amount) {
        return new Franc(amount);
    }
}        

We can now use our factory method everywhere in the tests:

public void testEquality() {
    assertTrue(Money.dollar(5).equals(Money.dollar(5)));
    assertFalse(Money.dollar(5).equals(Money.dollar(6)));
    assertTrue(Money.franc(5).equals(Money.franc(5)));
    assertFalse(Money.franc(5).equals(Money.franc(6)));
    assertFalse(Money.franc(5).equals(Money.dollar(5)));
}
public void testFrancMultiplication() {
    Money five = Money.franc(5);
    assertEquals(Money.franc(10), five.times(2));
    assertEquals(Money.franc(15), five.times(3));
}        

We are now in a better position than before. No client code knows that there is a subclass called Dollar or Franc. By decoupling the tests from the existence of the subclasses, we gave ourselves the freedom to change inheritance without affecting any model code.

We need objects representing currencies to implement the factory, string will be fine. Here is the test:

public void testCurrency() {
    assertEquals("USD", Money.dollar(1).currency());
    assertEquals("CHF", Money.franc(1).currency());
}        

In order to pass the test first we declare currency() in Money and then we implement it in both subclasses:

abstract class Money {
    protected int amount;
    public int getAmount () {
        return amount;
    }
    public boolean equals(Object object) {
        Money money = (Money) object;
        return amount == money.amount
            && getClass().equals(money.getClass());
    }
    abstract Money times(int multiplier);
    abstract String currency();

    public static Money dollar(int amount) {
        return new Dollar(amount);
    }
    public static Money franc(int amount) {
        return new Franc(amount);
    }
}

public class Franc extends Money {
    public Franc(int amount) {
        this.amount= amount;
    }
    public Money times(int multiplier) {
        return new Franc(amount * multiplier);
    }
    public String currency() {
        return "CHF";
    }
}

class Dollar extends Money {
    public Dollar(int amount) {
        this.amount= amount;
    }
    public Money times(int multiplier) {
        return new Dollar(amount * multiplier);
    }
    public String currency() {
        return "USD";
    }
}        

If we move the constant strings "USD" and "CHF" to the static factory methods, then the two constructors will be identical and we can create a common implementation, so we can push up the implementation on the Money class.

public class Money {
    protected int amount;
    private String currency;
    Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public int getAmount () {
        return amount;
    }
    public boolean equals(Object object) {
        Money money = (Money) object;
        return amount == money.amount
            && getClass().equals(money.getClass());
    }
    abstract Money times(int multiplier);
    public String currency(){
        return currency ;
    }

    public static Money dollar(int amount) {
        return new Dollar(amount,"USD");
    }
    public static Money franc(int amount) {
        return new Franc(amount,"CHF");
    }
}

public class Franc extends Money {
    public Franc(int amount, String currency) {
        super(amount, currency);
    }
    public Money times(int multiplier) {
        return new Franc(amount * multiplier);
    }
}

public class Dollar extends Money {
    public Dollar(int amount, String currency) {
        super(amount, currency);
    }

    public Money times(int multiplier) {
        return new Dollar(amount * multiplier);
    }
}        

Now we want to eliminate the two implementations of times() in the Dollar and Franc classes and move them to the Money class, but the two implementations of times() are close, but not identical, so in order to pass the test we have to make the Money class concrete and we have to push up the implementation of times() on the Money class.

However now the test below don't pass.

public void testDifferentClassEquality() {
    assertTrue(new Money(10, "CHF").equals(new Franc(10, "CHF")));
}        

The test doesn't pass because the equals() code should compare currencies, not class.

We have to modify the equals method so that it no longer makes a comparison between classes but between currencies. Here is the code:

public class Money {
    protected int amount;
    private String currency;
    Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public int getAmount () {
        return amount;
    }
    public boolean equals(Object object) {
        Money money = (Money) object;
        return amount == money.amount
            && currency().equals(money.currency());
    }
    public Money times(int multiplier){
        return new Money(amount * multiplier, currency);
    }
    public String currency(){
        return currency ;
    }

    public static Money dollar(int amount) {
        return new Dollar(amount,"USD");
    }
    public static Money franc(int amount) {
        return new Franc(amount,"CHF");
    }
}        

After moving the multiplication ("times" method) into the Money class we are ready to eliminate the stupid Dollar and Franc subclasses.

The two subclasses, Dollar and Franc, have only their own constructors. But because a constructor is not reason enough to have a subclass, we want to delete the subclasses. We can replace references to the subclasses with references to the superclass without changing the meaning of the code like this:

public class Money {
    protected int amount;
    private String currency;
    Money(int amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public int getAmount () {
        return amount;
    }
    public boolean equals(Object object) {
        Money money = (Money) object;
        return amount == money.amount
            && currency().equals(money.currency());
    }
    public Money times(int multiplier){
        return new Money(amount * multiplier, currency);
    }
    public String currency(){
        return currency ;
    }

    public static Money dollar(int amount) {
        return new Money(amount,"USD");
    }
    public static Money franc(int amount) {
        return new Money(amount,"CHF");
    }
}        

Now there are no references to Dollar and Franc, so we can delete them.


In the following chapters, Kent Beck addresses the issue of summing different currencies and currency conversion. He introduces a Bank class responsible for currency conversion, which includes methods for adding exchange rates and reducing expressions to a specific currency. Finally, Kent Beck concludes "The Money Example" with a very interesting retrospective on this project."

I strongly encourage you to read Kent Beck's book (1.) ?because, in my opinion, it's the best guide to learn the TDD method.


With this example, I conclude this first article about TDD.

Before finishing, I'd like to share some thoughts:

  • The Money Example is a simple project suitable for development using the TDD process. However, in more complicated situations, such as those involving concurrent and time-critical systems, using the TDD process is neither simple nor appropriate.
  • I've had various experiences with applying TDD, and I believe that test-driven development may not always lead to the emergence of the best architectural solutions. Although TDD can certainly guide good micro-level design decisions, it may not be able to guide broader architectural decisions. In my opinion, relying solely on TDD without considering the broader architectural context could lead to a fragmented or poorly integrated system.
  • The TDD fans say : “Putting in functionality on speculation is crazy. Put in what you need when you need it”. They say that by designing in this style, we will implement something in a very simple way and we never pay for flexibility we don't use. I, however, believe that we must try to anticipate problems, we must "predict the future" based on the information available and our experience. My advice is: "Implement for today, design for tomorrow".


With these considerations, I would conclude this initial article in the topic of TDD. In the next article we will explore some interesting evolutions of TDD.

See you on the next episode.

?

I remind you my newsletter "Sw Design & Clean Architecture"? : https://lnkd.in/eUzYBuEX where you can find my previous articles and where you can register, if you have not already done, so you will be notified when I publish new articles.

Thanks for reading my article, and I hope you have found the topic useful,

Feel free to leave any feedback.

Your feedback is very appreciated.

Thanks again.

Stefano

?

References:

1. Kent Beck, "Test-Driven Development?", Addison Wesley (2003).

2. Martin Fowler, “Refactoring: Improving the Design of Existing Code”, Pearson (2019)

3.S.Santilli: “https://www.dhirubhai.net/pulse/unveiling-effectiveness-black-box-testing-software-quality-santilli-nrv8f/”

4. S.Santilli: " https://www.dhirubhai.net/pulse/uniting-pieces-exploring-synergy-between-unit-testing-santilli-s9dmf/ "

5. S.Santilli: "https://www.dhirubhai.net/pulse/exploring-anatomy-unit-tests-unveiling-structure-stefano-santilli-ffvlf/ "


要查看或添加评论,请登录

Stefano Santilli的更多文章

社区洞察

其他会员也浏览了