Learn Java with Tests

A TDD course for Java, in the spirit of Learn Go with Tests.

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 runSum 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 the assertTrue(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.