Chapter 18 of 18
18 — AssertJ
Fluent assertions that read like sentences and fail with useful diffs.
Kata: a Cart of CartItems asserted with AssertJ’s fluent style — plus one earlier chapter’s test converted.
New ideas: assertThat(x).isEqualTo(...), .hasSize(...), .extracting(...), assertThatThrownBy — assertions that read like sentences and fail with a diff that tells the story.
This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished classes also ship in
src/main/java/assertj— don’t open them yet. The RED below shows what JUnit-style numbers hide and AssertJ’s diff reveals.
Why a whole chapter for assertions?
assertEquals(expected, actual) is fine — until the failure message says expected: <3> but was: <2> for a list and leaves you guessing which element was wrong. AssertJ reads left-to-right,
names what you’re checking, and builds failure messages from your actual data.
Step 1 — RED: the fluent test against a missing Cart
@Test
void addsItemsAndTotalsTheirPrices() {
Cart cart = new Cart();
cart.add(new CartItem("apple", 200));
cart.add(new CartItem("book", 900));
assertThat(cart.size()).isEqualTo(2);
assertThat(cart.total()).isEqualTo(1100);
assertThat(cart.items())
.extracting(CartItem::name)
.containsExactly("apple", "book");
}
Run gradle test --tests "assertj.CartTest":
error: cannot find symbol
Cart cart = new Cart();
^
symbol: class Cart
RED. Note what the test just dictated: Cart needs size(), total(), items(), and an
add(...) whose parameter has a name(). Read the assertions out loud — they are the spec.
Step 2 — RED: a half-implemented Cart
Write the cart’s construction but skip the arithmetic:
public int total() {
return 0;
}
Run it. Two tests pass (staysEmpty..., the list checks), then:
CartTest > addsItemsAndTotalsTheirPrices() FAILED
org.opentest4j.AssertionFailedError:
expected: 1100
but was: 0
That’s an AssertJ failure — notice it already beats chained assertEquals: the actual value (0)
is what you’d debug, not an “expected/got” shuffling.
Step 3 — GREEN: the real cart
public int total() {
return items.stream().mapToInt(CartItem::price).sum();
}
Run it: GREEN. Now the interesting part — so far AssertJ behaved like a nicer
assertEquals. Its superpower shows when a value is wrong and composite: if the cart had kept
apple twice and dropped book, the list assertion would print the real objects:
AssertionFailedError:
expecting:
["apple", "apple"]
to contain exactly (and in same order):
["apple", "book"]
but could not find the following elements:
["book"]
That’s the diff that tells you what’s actually there — not an index.
Step 4 — converting chapter 04’s test
Same Sum kata, same purpose, AssertJ voice:
assertThat(Sum.sum(List.of(1, 2, 3))).isEqualTo(6);
assertThat(Sum.sum(List.of())).isZero();
.isZero() states intent; JUnit’s assertEquals(0, sum) merely compares numbers. This test ran
GREEN from the first run — Sum already existed — which is itself a lesson: converting a
test to a clearer assertion should not change the behaviour being locked down.
Going further — asserting contracts
Error cases read as specifications too:
assertThatThrownBy(() -> racer.race(..., Duration.ofMillis(1)))
.isInstanceOf(NoResponderException.class)
.hasMessageContaining("no responder");
The exception contract of chapter 10, with less ceremony and a message that reads as documentation in review.
Java-specific notes
- AssertJ is a test-only dependency (
assertj-core); it never touches production code. It’s the de-facto default for readable JUnit tests in the ecosystem. - It drops into any existing project untouched — no migration needed beyond the import, as the conversion in this chapter showed.
- Chain as far as reads well, then stop; a 20-assert chain is a sign the test covers too much.
- The best two habits for test quality: one clear expectation per assert (or group of same-value
asserts), and asserting on behaviour the reader cares about via
extracting, not on internal representation. (Compare theassertTrue(rendered.contains(...))in chapter 16 — which tells you nothing on failure — with the diff above.)
Run it
gradle test --tests "assertj.*"
Key takeaway: assertions are part of the API you read. Fluent assertions make the test read as documentation — and on failure, the diff is what actually moves you toward the fix.
Happy test-driving! With structured concurrency (StructuredTaskScope), JPMS modules, and JMH
benchmarks still open on the roadmap, the loop you now own — kata, seams, tests, publish —
carries every new topic.