Pure tests in programming are not a single way to check if the code is correct. Theories are another method, introduced already some time ago.
Data Engineering Design Patterns
Looking for a book that defines and solves most common data engineering problems? I'm currently writing
one on that topic and the first chapters are already available in π
Early Release on the O'Reilly platform
I also help solve your data engineering problems π contact@waitingforcode.com π©
This post is divided in 2 parts. In the first it describes what theories are. The comparison with test cases will be used there to understand the nuance better. The second part shows, through Junit 4 experimental feature @Theories, how to implement them in the code.
What are theories in testing ?
Theories used in tests are similar to theories from scientific world. Thus, a theory is a general statement, always true for some set of parameters. As an example we could take a theory that all sum of positive numbers is always greater than the individual numbers, ie. (a + b) > a and (a + b) > b.
After the example we can deduce that a theory is a method taking some parameters (a and b in the case) and behaving always positively (exception not thrown, assertions passed).
The difference between a test and a theory is that test focuses more on a single scenario. In the other side, the theory enhances this behavior by applying the behavior's check on some number of potential scenarios. If we reuse the example of sum greater than its members, a test could simply look like:
should_check_that_2_plus_2_is_always_greater_than_2() { assertThat(sum(2, 2)).isGreaterThan(2); }
But a theory could do something like:
@TheoryParameters({1, 2, 3}) should_sum_be_always_greater_than_members(int a, int b) { int sum = sum(a, b); assertThat(sum).isGreaterThan(a); assertThat(sum).isGreaterThan(b); }
As we can see, test checks a specific behavior on a single scenario. Theory checks some different scenarios, such as: 1 + 1, 1 + 2, 1 + 3, 2 + 1, 2 + 2, 2 + 3, 3 + 1, 3 + 2 and 3 + 3. Now, it's simpler to think that theory checks a general concept (sum of 2 values is always greater than these 2 values) and about tests as example-based tests.
Often theories go with assumptions. For the example of our sum test, we could assume that it applies only on positive values. Beside that, we could define another theory checking the behavior for negative numbers.
Theories example in Junit
As the first theory test we will check the behavior of 2 collections: HashSet and ArrayList, especially to show how to deal with assumptions. You can see that theories are ran with special JUnit runner, Theories. However, it's still in org.junit.experimental Junit's 4 package:
@RunWith(Theories.class) public class TheoriesTest { @DataPoints public static int[] duplicatedInts() { return new int[]{1, 1, 2, 2, 3, 3}; } @Theory public void should_not_allow_duplicated_elements_in_a_set(Integer value1, Integer value2) { Assume.assumeTrue(value1.equals(value2)); Set<Integer> testedSet = new HashSet<>(); testedSet.add(value1); testedSet.add(value2); assertThat(testedSet).hasSize(1); } @Theory public void should_allow_duplicated_elements_in_a_list(Integer value1, Integer value2) { Assume.assumeTrue(value1.equals(value2)); List<Integer> testedList = new ArrayList<>(); testedList.add(value1); testedList.add(value2); assertThat(testedList).hasSize(2); } }
To use a more real example, let's imagine an object representing Order. We want grant to a user free delivery when all of its order are greater than 100. We can represent that through theories as below:
private OrderService orderService = new OrderService(); @DataPoints public static BigOrder[] bigOrders() { BigOrder order1 = new BigOrder(), order2 = new BigOrder(), order3 = new BigOrder(), order4 = new BigOrder(); order1.total = 50; order2.total = 55; order3.total = 60; order4.total = 65; return new BigOrder[] {order1, order2, order3, order4}; } @DataPoints public static SmallOrder[] smallOrders() { SmallOrder order1 = new SmallOrder(), order2 = new SmallOrder(), order3 = new SmallOrder(), order4 = new SmallOrder(); order1.total = 10; order2.total = 15; order3.total = 20; order4.total = 25; return new SmallOrder[] {order1, order2, order3, order4}; } @Theory public void should_always_detect_free_delivery_for_big_orders(BigOrder order1, BigOrder order2) { assertThat(orderService.isFreeDelivery(order1, order2)).isTrue(); } @Theory public void should_always_detect_not_free_delivery_for_small_orders(SmallOrder smallOrder1, SmallOrder smallOrder2) { assertThat(orderService.isFreeDelivery(smallOrder1, smallOrder2)).isFalse(); } @Theory public void should_detect_not_free_delivery_for_small_and_big_order_mix(BigOrder order1, SmallOrder order2) { assertThat(orderService.isFreeDelivery(order1, order2)).isFalse(); } private static class Order { protected double total; } private static class BigOrder extends Order { } private static class SmallOrder extends Order { } private static class OrderService { private static final double FREE_DELIVERY_MIN_AMOUNT = 100d; public boolean isFreeDelivery(Order...orders) { double allOrdersAmount = 0d; for (Order order : orders) { allOrdersAmount += order.total; } return allOrdersAmount > FREE_DELIVERY_MIN_AMOUNT; } }
Theories can be mistakenly considered as parametrized tests. However, unlike normal parametrized tests, theories produce Cartesian product by themselves. Doing the same with simple parametrized test would be difficult. In additional, they examplify a general idea instead of specific use case and can catch more bugs. So that, they are a good complement for example-based tests.