Let’s explore one last example that revisits the Basket
class. The class offers two methods: an add()
method that receives a product
and adds it a quantity
of times to the basket, and a remove()
method that removes a product
completely from the cart. Let’s start with the add
method.
Listing 5.12 Implementation of Basket
s add
method
import static java.math.BigDecimal.valueOf;
public class Basket {
private BigDecimal totalValue = BigDecimal.ZERO;
private Map<Product, Integer> basket = new HashMap<>();
public void add(Product product, int qtyToAdd) {
assert product != null : "Product is required"; ❶
assert qtyToAdd > 0 : "Quantity has to be greater than zero"; ❶
BigDecimal oldTotalValue = totalValue; ❷
int existingQuantity = basket.getOrDefault(product, 0); ❸
int newQuantity = existingQuantity + qtyToAdd;
basket.put(product, newQuantity);
BigDecimal valueAlreadyInTheCart = product.getPrice()
.multiply(valueOf(existingQuantity)); ❹
BigDecimal newFinalValueForTheProduct = product.getPrice()
.multiply(valueOf(newQuantity)); ❹
totalValue = totalValue
.subtract(valueAlreadyInTheCart)
.add(newFinalValueForTheProduct); ❺
assert basket.containsKey(product) : "Product was not inserted in ❻
➥ the basket"; ❻
assert totalValue.compareTo(oldTotalValue) == 1 : "Total value should ❻
➥ be greater than previous total value"; ❻
assert invariant() : "Invariant does not hold"; ❻
}
}
❶ Checks all the pre-conditions
❷ Stores the old value so we can check the post-condition later
❸ If the product is already in the cart, add to it.
❹ Calculates the previous and the new value of the product for the relevant quantities
❺ Subtracts the previous value of the product from the total value of the basket and adds the new final value of the product to it
❻ Post-conditions and invariant checks
The implementation is straightforward. First it does the pre-condition checks we discussed. The product cannot be null, and the quantity of the product to be added to the cart has to be larger than zero. Then the method checks whether the basket already contains the product. If so, it adds the quantity on top of the quantity already in the cart. It then calculates the value to add to the total value of the basket. To do so, it calculates the value of that product based on the previous amount in the basket, subtracts that from the total value, and then adds the new total value for that product. Finally, it ensures that the invariant (the total value of the basket must be positive) still holds.
The remove
method is simpler than the add
method. It looks for the product in the basket, calculates the amount it needs to remove from the total value of the basket, subtracts it, and removes the product (listing 5.13). The method also ensures the same two pre-conditions we discussed before: the product cannot be null, and the product has to be in the basket.
Listing 5.13 Implementation of Basket
s remove
method
public void remove(Product product) {
assert product != null : "product can't be null"; ❶
assert basket.containsKey(product) : "Product must already be in ❶
➥ the basket"; ❶
int qty = basket.get(product);
BigDecimal productPrice = product.getPrice(); ❷
BigDecimal productTimesQuantity = productPrice.multiply( ❷
➥ valueOf(qty)); ❷
totalValue = totalValue.subtract(productTimesQuantity); ❷
basket.remove(product); ❸
assert !basket.containsKey(product) : "Product is still ❹
➥ in the basket"; ❹
assert invariant() : "Invariant does not hold"; ❹
}
❷ Calculates the amount that should be removed from the basket
❸ Removes the product from the hashmap
❹ Post-conditions and invariant check
A developer who did not read on specification-based testing and structural testing would come up with at least three tests: one to ensure that add()
adds the product to the cart, another to ensure that the method behaves correctly when the same product is added twice, and one to ensure that remove()
indeed removes the product from the basket. Then they would probably add a few tests for the exceptional cases (which in this class are clearly specified in the contracts). Here are the automated test cases.
Listing 5.14 Non-systematic tests for the Basket
class
import static java.math.BigDecimal.valueOf;
public class BasketTest {
private Basket basket = new Basket();
@Test
void addProducts() { ❶
basket.add(new Product("TV", valueOf(10)), 2);
basket.add(new Product("Playstation", valueOf(100)), 1);
assertThat(basket.getTotalValue())
.isEqualByComparingTo(valueOf(10*2 + 100*1));
}
@Test
void addSameProductTwice() { ❷
Product p = new Product("TV", valueOf(10));
basket.add(p, 2);
basket.add(p, 3);
assertThat(basket.getTotalValue())
.isEqualByComparingTo(valueOf(10*5));
}
@Test
void removeProducts() { ❸
basket.add(new Product("TV", valueOf(100)), 1);
Product p = new Product("PlayStation", valueOf(10));
basket.add(p, 2);
basket.remove(p);
assertThat(basket.getTotalValue())
.isEqualByComparingTo(valueOf(100)); ❹
}
// tests for exceptional cases...
}
❶ Ensures that products are added to the basket
❷ If the same product is added twice, the basket sums up the quantities.
❸ Ensures that products are removed from the basket
❹ Food for thought: is this assertion enough? You might also want to verify that PlayStation is not in the basket.
NOTE I used the isEqualByComparingTo
assert instruction. Remember that BigDecimal
s are instances of a strange class, and the correct way to compare one BigDecimal
to another is with the compareTo()
method. That is what the isEqualByComparingTo
assertion does. Again, the BigDecimal
class is not easy to handle.
The problem with these tests is that they do not exercise the feature extensively. If there is a bug in our implementation, it is probably hidden and will only appear after a long and unexpected sequence of adds and removes to and from the basket. Finding this specific sequence might be hard to see, even after proper domain and structural testing. However, we can express it as a property: given any arbitrary sequence of additions and removals, the basket still calculates the correct final amount. We have to customize jqwik so that it understands how to randomly call a sequence of add()
s and remove()
s, as shown in figure 5.2.
Figure 5.2 We want our test to call arbitrary sequences of add and remove actions.
Fasten your seatbelt, because this takes a lot of code. The first step is to create a bunch of jqwik Action
s to represent the different actions that can happen with the basket. Actions are a way to explain to the framework how to execute a more complex action. In our case, two things can happen: we can add a product to the basket, or we can remove a product from the basket. We define how these two actions work so that later, jqwik can generate a random sequence of actions.
Let’s start with the add action. It will receive a Product
and a quantity and insert the Product
into the Basket
. The action will then ensure that the Basket
behaved as expected by comparing its current total value against the expected value. Note that everything happens in the run()
method: this method is defined by jqwik’s Action
interface, which our action implements. In practice, jqwik will call this method whenever it generates an add action and passes the current basket to the run
method. The following listing shows the implementation of the AddAction
class.
Listing 5.15 The AddAction
action
class AddAction
implements Action<Basket> { ❶
private final Product product;
private final int qty;
public AddAction(Product product, int qty) { ❷
this.product = product;
this.qty = qty;
}
@Override
public Basket run(Basket basket) { ❸
BigDecimal currentValue = basket.getTotalValue(); ❹
basket.add(product, qty); ❺
BigDecimal newProductValue = product.getPrice()
.multiply(valueOf(qty));
BigDecimal newValue = currentValue.add(newProductValue);
assertThat(basket.getTotalValue())
.isEqualByComparingTo(newValue); ❻
return basket; ❼
}
}
❶ Actions have to implement the jqwik Action interface.
❷ The constructor receives a product and a quantity. These values will be randomly generated later by jqwik.
❸ The run method receives a Basket and, in this case, adds a new random product to it.
❹ Gets the current total value of the basket, so we can make the assertion later
❺ Adds the product to the basket
❻ Asserts that the value of the basket is correct after the addition
❼ Returns the current basket so the next action starts from it
Now let’s implement the remove action. This is tricky: we need a way to get the set of products that are already in the basket and their quantities. Note that we do not have such a method in the Basket
class. The simplest thing to do is add such a method to the class.
You might be thinking that adding more methods for the tests is a bad idea. It’s a trade-off. I often favor anything that eases testing. An extra method will not hurt and will help our testing, so I’d do it, as shown next.
Listing 5.16 Basket
class modified to support the test
class Basket {
// ... the code of the class here ...
public int quantityOf(Product product) { ❶
assert basket.containsKey(product);
return basket.get(product);
}
public Set<Product> products() { ❷
return Collections.unmodifiableSet(basket.keySet());
}
}
❶ We only return the quantity if the product is in the cart. Note that here, we could have gone for a weaker pre-condition: for example, if the product is not in the basket, return 0.
❷ Returns a copy of the set, not the original one!
The remove action picks a random product from the basket, removes it, and then ensures that the current total value is the total value minus the value of the product that was just removed. The pickRandom()
method chooses a random product from the set of products; I do not show the code here, to save space, but you can find it in the book’s code repository.
Listing 5.17 The RemoveAction
class
class RemoveAction implements Action<Basket> {
@Override
public Basket run(Basket basket) {
BigDecimal currentValue = basket.getTotalValue(); ❶
Set<Product> productsInBasket = basket.products(); ❷
if(productsInBasket.isEmpty()) {
return basket;
}
Product randomProduct = pickRandom(productsInBasket); ❸
double currentProductQty = basket.quantityOf(randomProduct);
basket.remove(randomProduct);
BigDecimal basketValueWithoutRandomProduct = currentValue
.subtract(randomProduct.getPrice()
.multiply(valueOf(currentProductQty))); ❹
assertThat(basket.getTotalValue())
.isEqualByComparingTo(basketValueWithoutRandomProduct); ❺
return basket; ❻
}
// ...
}
❶ Gets the current value of the basket for the assertion later
❷ If the basket is empty, we skip this action. This may happen, as we do not control the sequence jqwik generates.
❸ Picks a random element in the basket to be removed
❹ Calculates the new value of the basket
❺ Asserts the value of the basket without the random product we removed
❻ Returns the current basket so the next action can continue from here
Jqwik now knows how to call add()
(via AddAction
) and remove()
(via RemoveAction
). The next step is to explain how to instantiate random products and sequences of actions. Let’s start by explaining to jqwik how to instantiate an arbitrary AddAction
. First we randomly pick a product from a predefined list of products. Then we generate a random quantity value. Finally, we add the random product in the random quantity to the basket.
Listing 5.18 Instantiating add actions
class BasketTest {
// ...
private Arbitrary<AddAction> addAction() {
Arbitrary<Product> products = Arbitraries.oneOf( ❶
randomProducts
.stream()
.map(product -> Arbitraries.of(product))
.collect(Collectors.toList()));
Arbitrary<Integer> qtys =
Arbitraries.integers().between(1, 100); ❷
return Combinators
.combine(products, qtys)
.as((product, qty) -> new AddAction(product, qty)); ❸
}
static List<Product> randomProducts = new ArrayList<>() {{ ❹
add(new Product("TV", new BigDecimal("100")));
add(new Product("PlayStation", new BigDecimal("150.3")));
add(new Product("Refrigerator", new BigDecimal("180.27")));
add(new Product("Soda", new BigDecimal("2.69")));
}};
}
❶ Creates an arbitrary product out of the list of predefined products
❷ Creates arbitrary quantities
❸ Combines products and quantities, and generates AddActions
❹ A static list of hard-coded products
This is a complex piece of code, and it involves a lot of details about how jqwik works. Let’s digest it step by step:
- Our first goal is to randomly select an arbitrary
Product
from the list of products. To do so, we use jqwik’sArbitraries.oneOf()
method, which randomly picks an arbitrary element of a given set of options. Given that theoneOf
method needs aList<Arbitrary<Product>>
, we have to convert ourrandomProducts
(which is aList<Product>
). This is easily done using Java’s Stream API. - We generate a random integer that will serve as the quantity to pass to the
add()
method. We define anArbitrary<Integer>
with numbers between 1 and 100 (random choices that I made after exploring the method’s source code). - We return an
AddAction
that is instantiated using a combination of arbitrary products and quantities.
We can now create our test. The property test should receive an ActionSequence
, which we define as an arbitrary sequence of AddAction
s and RemoveAction
s. We do so with the Arbitraries.sequences()
method. Let’s define this in an addsAndRemoves
method.
We also need arbitrary remove actions, as we did for add actions, but this is much simpler since the RemoveAction
class does not receive anything in its constructor. So, we use Arbitraries.of()
.
Listing 5.19 Adding remove actions to the test
private Arbitrary<RemoveAction> removeAction() {
return Arbitraries.of(new RemoveAction()); ❶
}
@Provide
Arbitrary<ActionSequence<Basket>> addsAndRemoves() {
return Arbitraries.sequences(Arbitraries.oneOf( ❷
addAction(),
removeAction()));
}
❶ Returns an arbitrary remove action
❷ This is where the magic happens: jqwik generates random sequences of add and remove actions.
We now only need a @Property
test method that runs the different sequences of actions generated by the addsAndRemoves
method.
Listing 5.20 Property-based test that generates adds and removes
@Property
void sequenceOfAddsAndRemoves(
@ForAll("addsAndRemoves") ❶
ActionSequence<Basket> actions) {
actions.run(new Basket());
}
❶ The property receives a sequence of Basket actions defined by the addsAndRemoves method.
And we are finished. As soon as we run the test, jqwik randomly invokes sequences of adds and removes, passing random Product
s and quantities and ensuring that the value of the basket is always correct.
This was a long, complex property-based test, and you may be wondering if it is worth the effort. For this specific Basket
implementation, I would probably write thorough example-based tests. But I hope this example illustrates the power of property-based testing. Although they tend to be more complicated than traditional example-based tests, you will get used to it, and you will soon be writing them quickly.
Leave a Reply