
Pragmatic Unit Testing in Java has been published! In crafting the 3rd edition of this venerable classic, originally created by The pragmatic programmers Andy Hunt and Dave Thomas, I made significant cuts in order to keep the page count under 275 or so.
This article, and at least one more to come, represents one of those excised book segments, and you don’t need to read any of the book to follow along (as long as you’re familiar with JUnit basics).
I firmly believe good examples represent a core part of a solid book, and that these resurrected examples will be useful to at least someone.
Invariants
Excessive use of primitive datatypes is a code smell known as primitive obsession. A benefit of an object-oriented language like Java is that it lets you define your own custom abstractions in the form of classes.
A circle has only 360 degrees. Rather than store the direction of travel as a native type like an int
, you can create a class named Bearing
that encapsulates the direction along with logic to constrain its range. Tests show how it works.
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class ABearing {
@Test
void throwsWhenValueTooLarge() {
assertThrows(BearingOutOfRangeException.class,
() -> new Bearing(Bearing.MAX + 1));
}
@Test
void answersValidBearing() {
assertEquals(Bearing.MAX, new Bearing(Bearing.MAX).value());
}
@Nested
class AngleTo {
@Test
void isDifferenceFromOtherBearingValue() {
assertEquals(3, new Bearing(15).angleTo(new Bearing(12)));
}
@Test
void isNegativeWhenThisBearingSmaller() {
assertEquals(-3, new Bearing(12).angleTo(new Bearing(15)));
}
}
}
The constraint is implemented in the constructor of the Bearing
class:
public record Bearing(int value) {
public static final int MAX = 359;
public Bearing {
if (value < 0 || value > MAX)
throw new BearingOutOfRangeException();
}
public int angleTo(Bearing bearing) {
return value - bearing.value;
}
}
Note that angleTo()
returns an int
. You’re not placing any range restrictions (such as that it must not be negative) on the result.
The Bearing
abstraction makes it impossible for client code to create out-of-range bearings. As long as the rest of the system accepts and works with Bearing
objects, the gate on range-related defects is shut.
Other constraints might not be as straightforward. Suppose you have a class that maintains two points, each an x, y
integer tuple. The constraint on the range is that the two points must describe a rectangle with no side greater than 100 units. That is, the allowed range of values for both x, y
pairs is interdependent.
You want a range assertion for any behavior that can affect a coordinate, to ensure that the resulting range of the x, y
pairs remains legitimate—that the invariant on the Rectangle
holds true.
More formally: An invariant is a condition that holds true throughout the execution of some chunk of code. In this case, you want the invariant to hold true any time its state changes during the lifetime of the Rectangle
object.
You can add invariants in the form of assertions to the @AfterEach
method so that they are verified upon completion of each test. Here’s what an implementation for the invariant for the constrained Rectangle
class looks like:
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import util.ExpectToFail;
import static java.lang.String.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ARectangle {
private Rectangle rectangle;
@AfterEach
void ensureInvariant() {
assertTrue(constrainsSidesTo(rectangle, 100),
format("rectangle invariant: neither sides can exceed 100"));
}
boolean constrainsSidesTo(Rectangle rect, int length) {
return
Math.abs(rect.origin().x() - rect.opposite().x()) <= length &&
Math.abs(rect.origin().y() - rect.opposite().y()) <= length;
}
@Test
void answersArea() {
rectangle = new Rectangle(new Point(5, 5), new Point(15, 10));
assertEquals(50, rectangle.area());
}
@Test
void allowsDynamicallyChangingSize() {
rectangle = new Rectangle(new Point(5, 5));
rectangle.setOppositeCorner(new Point(125, 55));
assertEquals(6000, rectangle.area());
}
}
Assuming the answersArea
test runs first (which may or may not be true):
-
After JUnit runs the
answersArea
test, it runs the@AfterEach
method, which passes because therectangle
created has the size 10x5. -
After JUnit runs the
allowsDynamicallyChangingSize
test, it runs the@AfterEach
method. Its assert fails becauserectangle
has a side with length 120, which violates the invariant.
Knowing that JUnit will always check the invariant (for all your tests that manipulate a rectangle instance) should help you sleep safely.