JUnit - the Difference between Practice and @Theory
Lately a colleague showed me how to improve JUnit tests written for a distance calculator. Speaking with other developers I found out that the majority wasn’t aware of the undocumented @Theories Runner which can be found in an experimental package in JUnit, so I decided to share this valuable “experiment”.
In contrast to the parameterized JUnit test, the Theories-runner will try out all possible combinations of data points against your test methods marked with the @Theory annotation.
But let’s get your hands dirty. Obviously the first thing to do is run the test with the Theories-runner and declare some data points:
@RunWith(Theories.class)
public class DistanceCalculatorTest {
@DataPoint
static public LatLng es_1 = new LatLng( 49.0060285, 8.4006111 );
@DataPoint
static public LatLng es_2 = new LatLng( 49.0060285, 8.4005789 );
@DataPoint
static public LatLng es_3 = new LatLng( 49.0060056, 8.4005611 );
...
The implementation of the distance calculator should be:
- Commutative,
- Positive semidefinite
- And fullfill the triangle equality
Which can be written down using the following three @Theory annotated test methods (which do not accept null values):
@Theory(nullsAccepted = false)
public void distanceIsCommutative(LatLng p1, LatLng p2) {
assertThat(distanceCalculator.calculate(p1, p2), is(distanceCalculator.calculate(p2, p1)));
}
@Theory(nullsAccepted = false)
public void distanceIsPositiveSemidefinite(LatLng p1, LatLng p2) {
assertThat(distanceCalculator.calculate(p1, p2), is(greaterThanOrEqualTo(0.)));
}
@Theory(nullsAccepted = false)
public void distanceFulfillsTriangleEquality(LatLng p1, LatLng p2, LatLng p3) {
assertThat(distanceCalculator.calculate(p1, p2) + distanceCalculator.calculate(p2, p3),
is(greaterThanOrEqualTo(distanceCalculator.calculate(p1, p3) - delta)));
}
Sometimes you have to assume preconditions to test specific parts of your implementation. In the following @Theory the assumption is either p1 or p2 is null, which should lead to an IllegalArgumentException when using the calculator…
@DataPoint
public static LatLng nullPoint = null;
@Rule
public ExpectedException distanceCalculatorException = ExpectedException.none();
@Theory
public void distanceToNullNotDefined(LatLng p1, LatLng p2) throws Exception {
assumeTrue(p1 == null || p2 == null);
distanceCalculatorException.expect(IllegalArgumentException.class);
distanceCalculator.calculate(p1, p2);
}
It is very simple to add more corner cases. The following snippet adds two representations of the north pole:
@DataPoint
static public LatLng north_pole_1 = new LatLng( 90, 10 );
@DataPoint
static public LatLng north_pole_2 = new LatLng( 90, 20 );
If you add a test which won’t work with the whole data points available (e.g. near the poles in this example) they can be easily filtered out by adding a more complex assumption at the beginning of the test method.
@Theory(nullsAccepted = false)
public void runningTowardsThePole(LatLng pt) {
// this test does not work at the poles
assumeThat(pt.lat, is(allOf(greaterThan(-89.), lessThan(89.))));
...
}
Just a side note. Rewriting the tests for the distance calculator in fact discovered a bug in the original implementation of the distance calculator using LatLng(-1, -1) as NIRVANA. Thanks again Hauke for teaching me this lesson.
Have fun verifying your code with theories.