DEV Community

Bruno Oliveira
Bruno Oliveira

Posted on

Java and TDD - make your software predictable

Introduction

Modern software development has changed a lot in recent years, not only intrinsically, but also due to the fact that more and more businesses are relying on software to grow their customer base and impact, making it ever more important to write safe and predictable software.

Predictable software is easier to maintain, debug and extend and software engineers employ a series of methodologies to ensure that the quality of their software is high. In this article I'll talk about my view on one of these aspects: TDD - short for test driven development.

TDD essentially means to write tests, before writing production code, and only writing the minimal code needed to make your test pass. Like this, you will cover your production code with a harness in the form of small unit tests to ensure that both the core functionality of your application is correct and that it keeps being correct when things change.

As the name states, a unit test is code that tests (asserts - in test jargon) that the behavior of a unit is as intended under a specific set of input conditions or constraints. I'll define a unit as the smallest piece of code that can't be functionally split apart. In most languages, this will be the equivalent of a function or procedure.

To write the unit tests, I’ll use JUnit 5 from within the IntelliJ IDE. JUnit is a simple framework to write repeatable tests.

Our domain - a shopping cart and products

I'll be using Java throughout my article, and to introduce a simple, closed domain to illustrate the concepts I'll be discussing, let's consider for a moment that we have Products and Shopping cart as objects in our domain. A shopping cart can hold multiple products and products have some interesting properties associated with them. Below are the basic Java classes modeling these objects:

public class Product {
    private Integer id;
    private String name;
    private double price;

    public Product(int id, String name, double displayPrice){
        this.id = id;
        this.name = name;
        this.price = displayPrice;
                validateProduct();
    }

        private void validateProduct(){
            Validate.notNull(id);
            Validate.notNull(name);
        }

    public void applyDiscount(int discount){
        this.price = price*(1.0d-(1.0d*discount/100));
    }

    public double getPrice() {
        return price;
    }
}
public class ShoppingCart {
  private maxCapacity;
  private List<Product> productsInCart;
  private totalPriceInCart;

  public ShoppingCart(maxCapacity) {
      this.maxCapacity = maxCapacity;
      validateCart();
  }

  private void validateCart() {
      if(this.maxCapacity <= 0) {
          throw new Exception("Shopping cart needs to be able to hold items");
      } 
  }

  public void addProduct(Product p){
     productsInCart.add(p);
  }

  public int getNumProductsInCart(){
     return productsInCart.size();
  }

  public double getTotalPriceInCart(){
     return productsInCart.stream()
.map(Product::getPrice).reduce(Double::sum);
  } 


}

Each of these classes has a private method to ensure that the instance of our object is a valid one at construction time: we can't do anything with a cart of zero or negative or null capacity, and, as a business requirement, we can't have products with null id and name.

This means that we don't need to worry about the initial state of our objects, and as such, we won't need to worry about testing their internal state. Unit testing should be used in the context of the functionality imposed by our domain, and not about the internal state of objects. This is a subtle but important difference that matters when writing unit tests: aim to test for functionality and interaction between moving pieces, not internal state. I've seen developers writing useless unit tests being led to believe everything was fine.
Aim to test for functionality within context.
In other words, only API methods are to be unit tested.

Testing core functionality

As an example of core functionality of our basic domain, let's look at two cases:

When we apply a discount to a product, it's discounted price needs to be smaller than the original price.
When we add a product to the shopping cart it's number of products increases by one.

class ProductTest {

    @Test
    void priceIsLowerAfterApplyingDiscount(){
        Product p = new Product(1,"testprod",10.0);
        double original = p.getPrice();
        p.applyDiscount(1); //1% discount
        assertEquals(9.9,p.getPrice());
    }
}
class ShoppingCartTest {

    @Test
    void itemsInCartIncreaseByOneAfterAddingOneItem(){
        ShoppingCart cart = new ShoppingCart(10);
                Product p = new Product(1,"testprod",10.0);
                cart.addProduct(p);
                assertEquals(1,cart.getNumProductsInCart());

    }
}

Each domain class can have a corresponding test class as per convention. The test class is typically suffixed with the word Test and each method in a test class should be annotated with @Test annotation from JUnit framework and have a descriptive name regarding what is being tested.

Each of these tests gives us confidence that the internals of the class under test are working as we expect. If something will change in an unintended way in our classes, we will know it once we run the tests.

For instance, let’s assume that the discounted price, is based on a percentual discount.

However, requirements changed, and now the discount value to apply is actually an absolute value, such that the internal implementation of the applyDiscount(discount) method will change to this:

public void applyDiscount(int discount){
        this.price = price - discount;
    }

With good unit tests in place, after this change is done, we can re-run them, which will promptly show us that our calculation for discounted price now fails, and that is a good indicator that our underlying logic has changed in ways we may or may have not predicted and that is the main advantage of unit tests: they provide immediate feedback on unexpected changes and allow you to operate large refactors on your codebase with confidence that no regressions will be introduced.

This was my first post and hopefully I'll get motivation to write frequently here!

Top comments (0)