Testing larger components

As always, let’s use a concrete example. Suppose we have the following requirement:

Given a shopping cart with items, quantities, and respective unit prices, the final price of the cart is calculated as follows:

  • The final price of each item is calculated by multiplying its unit price by the quantity.
  • The delivery costs are the following. For shopping carts with
    • 1 to 3 elements (inclusive), we charge 5 dollars extra.
    • 4 to 10 elements (inclusive), we charge 12.5 dollars extra.
    • More than 10 elements, we charge 20 dollars extra.
  • If there is an electronic item in the cart, we charge 7.5 dollars extra.

NOTE The business rule related to delivery costs is not realistic. As a developer, when you notice such inconsistencies, you should talk to the stakeholder, product owner, or whomever is sponsoring that feature. I am keeping this business rule simple for the sake of the example.

Before I begin coding, I think about how to approach the problem. I see how the final price is calculated and that a list of rules is applied to the shopping cart. My experience with software design and design for testability tells me that each rule should be in its own class—putting everything in a single class would result in a large class, which would require lots of tests. We prefer small classes that require only a handful of tests.

Suppose the ShoppingCart and Item classes already exist in our code base. They are simple entities. ShoppingCart holds a list of Items. An Item is composed of a name, a quantity, a price per unit, and a type indicating whether this item is a piece of electronics.

Let’s define the contract that all the prices have in common. Listing 9.1 shows the PriceRule interface that all the price rules will follow. It receives a ShoppingCart and returns the value that should be aggregated to the final price of the shopping cart. Aggregating all the price rules will be the responsibility of another class, which we will code later.

Listing 9.1 PriceRule interface

public interface PriceRule {
    double priceToAggregate(ShoppingCart cart);
}

We begin with the DeliveryPrice price rule. It is straightforward, as its value depends solely on the number of items in the cart.

Listing 9.2 Implementation of DeliveryPrice

public class DeliveryPrice implements PriceRule {
  @Override
  public double priceToAggregate(ShoppingCart cart) {
 
    int totalItems = cart.numberOfItems();      ❶
 
    if(totalItems == 0)                         ❷
      return 0;
    if(totalItems >= 1 && totalItems <= 3)
      return 5;
    if(totalItems >= 4 && totalItems <= 10)
      return 12.5;
 
    return 20.0;
  }
}

❶ Gets the number of items in the cart. The delivery price is based on this.

❷ These if statements based on the requirements are enough to return the price.

NOTE I am using double to represent prices for illustration purposes, but as discussed before, that would be a poor choice in real life. You may prefer to use BigDecimal or represent prices using integers or longs.

With the implementation ready, let’s test it as we have learned: with unit testing. The class is so small and localized that it makes sense to exercise it via unit testing. We will apply specification-based and, more importantly, boundary testing. The requirements contain clear boundaries, and these boundaries are continuous (1 to 3 items, 4 to 10 items, more than 10 items). This means we can test each rule’s on and off points:

  • 0 items
  • 1 item
  • 3 items
  • 4 items
  • 10 items
  • More than 10 items (with 11 being the off point)

NOTE Notice the “0 items” handler: the requirements do not mention that case. But I was thinking of the class’s pre-conditions and decided that if the cart has no items, the price should return 0. This corner case deserves a test.

We use a parameterized test and comma-separated values (CSV) source to implement the JUnit test.

Listing 9.3 Tests for DeliveryPrice

public class DeliveryPriceTest {
 
  @ParameterizedTest
  @CsvSource({                                                   ❶
    "0,0",
    "1,5",
    "3,5",
    "4,12.5",
    "10,12.5",
    "11,20"})
  void deliveryIsAccordingToTheNumberOfItems(int noOfItems,
    ➥ double expectedDeliveryPrice) {
 
    ShoppingCart cart = new ShoppingCart();                      ❷
    for(int i = 0; i < noOfItems; i++) {
      cart.add(new Item(ItemType.OTHER, "ANY", 1, 1));
    }
 
    double price = new DeliveryPrice().priceToAggregate(cart);   ❸
 
    assertThat(price).isEqualTo(expectedDeliveryPrice);          ❹
  }
}

❶ Exercises the six boundaries. The first value is the number of items in the cart; the second is the expected delivery price.

❷ Creates a shopping cart and adds the specified number of items to it. The type, name, quantity, and unit price do not matter.

❸ Calls the DeliveryPrice rule …

❹ … and asserts its output.

Refactoring to achieve 100% code coverage

This example illustrates why you cannot blindly use code coverage. If you generate the report, you will see that the tool does not report 100% branch coverage! In fact, only three of the five conditions are fully exercised: totalItems >= 1 and totalItems >= 4 are not.

Why? Let’s take the first case as an example. We have lots of tests where the number of items is greater than 1, so the true branch of this condition is exercised. But how can we exercise the false branch? We would need a number of items less than 1. We have a test where the number of items is zero, but the test never reaches that condition because an early return happens in totalItems == 0. Pragmatically speaking, we have covered all the branches, but the tool cannot see it.

One idea is to rewrite the code so this is not a problem. In the following code, the implementation is basically the same, but the sequence of if statements is written such that the tool can report 100% branch coverage:

public double priceToAggregate(ShoppingCart cart) {
 
  int totalItems = cart.numberOfItems();
 
  if(totalItems == 0)
    return 0;
  if(totalItems <= 3)    ❶
    return 5;
  if(totalItems <= 10)   ❷
    return 12.5;
 
  return 20.0;
}

❶ We do not need to check totalItems >= 1, as that is the only thing that can happen if we reach this if statement.

❷ Same here: no need to check totalItems >= 4

Next, we implement ExtraChargeForElectronics. The implementation is also straightforward, as all we need to do is check whether the cart contains any electronics. If so, we add the extra charge.

Listing 9.4 ExtraChargeForElectronics implementation

public class ExtraChargeForElectronics implements PriceRule {
  @Override
  public double priceToAggregate(ShoppingCart cart) {
 
    List<Item> items = cart.getItems();
 
    boolean hasAnElectronicDevice = items
      .stream()
      .anyMatch(it -> it.getType() == ItemType.ELECTRONIC);   ❶
 
    if(hasAnElectronicDevice)                                 ❷
      return 7.50;
 
    return 0;                                                 ❸
  }
}

❶ Looks for any item whose type is equal to ELECTRONIC

❷ If there is at least one such item, we return the extra charge.

❸ Otherwise, we do not add an extra charge.

We have three cases to exercise: no electronics in the cart, one or more electronics in the cart, and an empty cart. Let’s implement them in three test methods. First, the following test exercises the “one or more electronics” case. We can use parameterized tests to try this.

Listing 9.5 Testing the extra charge for electronics

public class ExtraChargeForElectronicsTest {
 
  @ParameterizedTest
  @CsvSource({"1", "2"})                                              ❶
  void chargeTheExtraPriceIfThereIsAnyElectronicInTheCart(
    ➥ int numberOfElectronics) {
    ShoppingCart cart = new ShoppingCart();
 
    for(int i = 0; i < numberOfElectronics; i++) {                    ❷
      cart.add(new Item(ItemType.ELECTRONIC, "ANY ELECTRONIC", 1, 1));
    }
 
    double price = new ExtraChargeForElectronics().priceToAggregate(cart);
 
    assertThat(price).isEqualTo(7.50);                                ❸
  }
}

❶ The parameterized test will run a test with one electronic item in the cart and another test with two electronic items in the cart. We want to ensure that having multiple electronics in the cart does not incur incorrect extra charges.

❷ A simple loop that adds the specified number of electronics. We could also have added a non-electronic item. Would that make the test stronger?

❸ Asserts that the extra electronics price is charged

We then test that no extra charges are added when there are no electronics in the cart (see listing 9.6).

NOTE You may wonder if we should write a property-based test in this case. The implementation is straightforward, and the number of electronic items does not significantly affect how the algorithm works, so I am fine with example-based testing here.

Listing 9.6 Testing for no extra charge for electronics

@Test
void noExtraChargesIfNoElectronics() {
  ShoppingCart cart = new ShoppingCart();                  ❶
  cart.add(new Item(ItemType.OTHER, "BOOK", 1, 1));
  cart.add(new Item(ItemType.OTHER, "CD", 1, 1));
  cart.add(new Item(ItemType.OTHER, "BABY TOY", 1, 1));
 
  double price = new ExtraChargeForElectronics().priceToAggregate(cart);
  assertThat(price).isEqualTo(0);                          ❷
}

❶ Creates a cart with random items, all non-electronic

❷ Asserts that nothing is charged

Finally, we test the case where there are no items in the shopping cart.

Listing 9.7 No items in the shopping cart, so no electronics charge

@Test
void noItems() {
  ShoppingCart cart = new ShoppingCart();
  double price = new ExtraChargeForElectronics().priceToAggregate(cart);
  assertThat(price).isEqualTo(0);         ❶
}

❶ The shopping cart is empty, so nothing is charged.

The final rule to implement is PriceOfItems, which navigates the list of items and calculates the unit price times the quantity of each item. I do not show the code and the test, to save space; they are available in the book’s code repository.

Let’s go to the class that aggregates all the price rules and calculates the final price. The FinalPriceCalculator class receives a list of PriceRules in its constructor. Its calculate method receives a ShoppingCart, passes it to all the price rules, and returns the aggregated price.

Listing 9.8 FinalPriceCalculator that runs all the PriceRules

public class FinalPriceCalculator {
 
  private final List<PriceRule> rules;
 
  public FinalPriceCalculator(List<PriceRule> rules) {    ❶
    this.rules = rules;
  }
 
  public double calculate(ShoppingCart cart) {
    double finalPrice = 0;
 
    for (PriceRule rule : rules) {                        ❷
      finalPrice += rule.priceToAggregate(cart);
    }
 
    return finalPrice;                                    ❸
  }
}

❶ Receives a list of price rules in the constructor. This class is flexible and can receive any combination of price rules.

❷ For each price rule, gets the price to add to the final price

❸ Returns the final aggregated price

We can easily unit-test this class: all we need to do is stub a set of PriceRules. Listing 9.9 creates three price rule stubs. Each returns a different value, including 0, as 0 may happen. We then create a very simple shopping cart—its items do not matter, because we are mocking the price rules.

Listing 9.9 Testing FinalPriceCalculator

public class FinalPriceCalculatorTest {
 
  @Test
  void callAllPriceRules() {
    PriceRule rule1 = mock(PriceRule.class);                       ❶
    PriceRule rule2 = mock(PriceRule.class);
    PriceRule rule3 = mock(PriceRule.class);
 
    ShoppingCart cart = new ShoppingCart();                        ❷
    cart.add(new Item(ItemType.OTHER, "ITEM", 1, 1));
 
    when(rule1.priceToAggregate(cart)).thenReturn(1.0);            ❸
    when(rule2.priceToAggregate(cart)).thenReturn(0.0);
    when(rule3.priceToAggregate(cart)).thenReturn(2.0);
 
    List<PriceRule> rules = Arrays.asList(rule1, rule2, rule3);    ❹
    FinalPriceCalculator calculator = new FinalPriceCalculator(rules);
    double price = calculator.calculate(cart);
 
    assertThat(price).isEqualTo(3);                                ❺
  }
}

❶ Creates three different stubs of price rules

❷ Creates a simple cart

❸ Makes the stubs return different values, given the cart

❹ Passes the stubs to the calculator and runs it

❺ Given the values we set for the stubs, we expect a final value of 3.

If this is what you envisioned when I posed the requirements, you understand my way of thinking about design and testing. But you may be thinking that even though we tested each of the price rules individually, and we tested the price calculator with stubbed rules, we don’t know if these pieces will work when we plug them together.

This is a valid skeptical thought. Why not write more tests? Because our tests already cover all the requirements. Structurally, we have covered everything. In these cases, I suggest writing a larger test that exercises all the classes together. In this case, the larger test will exercise FinalPriceCalculator together with all the PriceRules. First, let’s create a factory class in the production code that is responsible for instantiating the calculator with all its dependencies.

Listing 9.10 FinalPriceCalculatorFactory

public class FinalPriceCalculatorFactory {
 
  public FinalPriceCalculator build() {
 
    List<PriceRule> priceRules = Arrays.asList(     ❶
        new PriceOfItems(),
        new ExtraChargeForElectronics(),
        new DeliveryPrice());
 
    return new FinalPriceCalculator(priceRules);
  }
}

❶ Passes the list of PriceRules manually. You can use dependency injection frameworks to do this.

Now all we need to do is to use the factory to build up a real FinalPriceCalculator and then give it some inputs. To get started, let’s write a test with a shopping cart that has four items (the delivery price is 12.5) and an electronic item (the final price will include the extra charge).

Listing 9.11 A larger test for FinalPriceCalculator

public class FinalPriceCalculatorLargerTest {
 
  private final FinalPriceCalculator calculator =
  ➥  new FinalPriceCalculatorFactory().build();            ❶
 
  @Test
  void appliesAllRules() {
    ShoppingCart cart = new ShoppingCart();                 ❷
    cart.add(new Item(ItemType.ELECTRONIC, "PS5", 1, 299));
    cart.add(new Item(ItemType.OTHER, "BOOK", 1, 29));
    cart.add(new Item(ItemType.OTHER, "CD", 2, 12));
    cart.add(new Item(ItemType.OTHER, "CHOCOLATE", 3, 1.50));
 
    double price = calculator.calculate(cart);
 
    double expectedPrice =
        299 + 29 + 12 * 2 + 1.50 * 3 +                      ❸
        7.50 +                                              ❹
        12.5;                                               ❺
 
    assertThat(price)
      .isEqualTo(expectedPrice);                            ❻
  }
}

❶ Uses a real FinalPriceCalculator with all the real PriceRules

❷ Builds up a shopping cart

❸ The prices of the items

❹ Includes an electronic

❺ Delivery price

❻ Asserts that the final value matches the shopping cart

In terms of test code, this is no different from writing a unit test. In fact, based on the definition. I do not consider this an integration test, as it does not go beyond the system’s boundaries. This is a larger test that exercises many units.

From a testing perspective, we can apply specification-based, boundary, and structural testing the same way. The difference is that the granularity may be coarser. When testing the DeliveryPrice unit, we only had to think about the rules related to delivery. Now that we are testing all the behavior together (the calculator plus the price rules), the number of combinations is larger.

Specification-based testing in larger tests

Let’s look at how I would apply specification-based testing here. I would consider each price rule a category to exercise individually, analogous to the input values of the methods we test in isolation. Therefore, my categories would be price per itemdelivery, and electronics extra charge, each with its own partitions. The item itself can also vary. The categories and partitions are as follows:

  • Shopping cart:a) Empty cartb) 1 elementc) Many elements
  • Each individual item:a) Single quantityb) More than onec) Unit price times quantity, roundedd) Unit price times quantity, not rounded
  • Delivery price:a) 1 to 3 itemsb) 4 to 10 itemsc) More than 10 items
  • Electronics:a) Has an electronic itemb) No electronic items

I would then combine the partitions that make sense, engineer the different test cases, and write them as automated JUnit tests. I will leave that as an exercise for you.

This example shows how much more work it is to test sets of classes together. I use this approach when I see value in it, such as for debugging a problem that happens in production. However, I use these tests in addition to unit tests. I also do not re-test everything. I prefer to use these large component tests as an excuse to try the component with real-world inputs.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *