Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 062 / 100

Readable assertions with AssertJ: lessons from production

Assertions that read like sentences and fail clearly.

Expona Admin5 min read

Readable assertions with AssertJ: lessons from production

We've all debugged a test failure where the assertion message tells you almost nothing about what went wrong. After months of production support, we learned that readable assertions save more than time—they save your sanity when a test breaks at 2 AM. AssertJ transforms test assertions from cryptic one-liners into sentences that humans can actually parse, and its failure messages point directly to the problem.

Why assertion readability matters

Test failures happen. When they do, you're reading the assertion message under pressure, often in an unfamiliar part of the codebase. A vague failure like Expected: true but was: false sends you on a debugging rabbit hole. A clear message like Expecting: [User{id=1, email='old@example.com', status='ACTIVE'}] to contain: [User{id=1, email='new@example.com', status='ACTIVE'}] points you straight at the issue.

In production systems—especially e-commerce platforms where we've used AssertJ extensively—test reliability directly impacts deployment confidence. When tests fail with clarity, the team responds faster. When tests fail with mystery, people start skipping test runs.

Fluent chains: hasSize, contains, and beyond

The foundation of AssertJ is the fluent API. Instead of nested conditions, you chain readable methods. Let's start simple:

java
// Old style - hard to read
assertTrue(orders.size() == 3);
assertTrue(orders.contains(expectedOrder));

// AssertJ - reads like English
assertThat(orders)
  .hasSize(3)
  .contains(expectedOrder);

This fluent chain reads like a sentence: "Assert that orders has size 3 and contains expectedOrder." The failure message is equally clear. If the size check fails, you get Expected size: 3 but was: 2. If the contains check fails, you see exactly which order was missing and why.

We use this pattern constantly in payment processing tests. Orders flow through multiple states, and we need to verify both the count and specific items in a single readable assertion:

java
@Test
void processedOrdersAreMarkedCorrectly() {
  List<Order> processed = orderService.getProcessedOrders();
  
  Order expectedOrder = Order.builder()
    .id(1L)
    .status(OrderStatus.COMPLETED)
    .build();
  
  assertThat(processed)
    .hasSize(5)
    .contains(expectedOrder)
    .allMatch(o -> o.getCompletedAt() != null, "completed timestamp set");
}

The allMatch here checks that every order satisfies the condition, and the message string ("completed timestamp set") appears in the failure output. That's production-grade clarity.

Extracting properties from collections

Real-world objects rarely have a single property you care about. You usually want to verify that a collection of DTOs has the right values in a specific field. This is where extracting shines:

java
@Test
void userEmailsAreNormalized() {
  List<User> users = userRepository.findAll();
  
  assertThat(users)
    .extracting(User::getEmail)
    .containsExactly(
      "alice@example.com",
      "bob@example.com",
      "charlie@example.com"
    );
}

Instead of looping or streaming to extract emails, extracting does it declaratively and fails with a clear diff of what was expected versus what you got. In our CRM systems, we use this for verifying that contact lists are sorted, deduplicated, and formatted correctly after ETL operations.

You can extract multiple fields at once:

java
@Test
void subscriptionsHaveCorrectTiers() {
  List<Subscription> subs = subscriptionService.getActive();
  
  assertThat(subs)
    .extracting(Subscription::getUserId, Subscription::getTier)
    .containsExactly(
      tuple(101L, "PREMIUM"),
      tuple(102L, "BASIC"),
      tuple(103L, "PREMIUM")
    );
}

The tuple method pairs multiple extracted values, and the failure message shows exactly which row diverged from expectations. This catches data transformation bugs that would otherwise slip into production.

Recursive comparison for complex DTOs

When you're testing API responses or service layer DTOs, equality checks break down. Objects might have nested structures, nullable fields, or timestamps that vary by milliseconds. AssertJ's recursive comparison handles this:

java
@Test
void paymentResponseMapsCorrectly() {
  PaymentResponse response = paymentGateway.charge(request);
  
  PaymentResponse expected = PaymentResponse.builder()
    .transactionId("TXN-12345")
    .amount(BigDecimal.valueOf(99.99))
    .status("SUCCESS")
    .build();
  
  assertThat(response)
    .usingRecursiveComparison()
    .isEqualTo(expected);
}

This compares all fields recursively. If the response has nested objects, arrays, or collections, they're all checked. The failure message shows the exact field that differed.

For timestamps or floating-point values, you can ignore fields or set tolerances:

java
@Test
void invoiceCalculatesWithinTolerance() {
  Invoice invoice = invoiceService.generate(order);
  
  Invoice expected = Invoice.builder()
    .orderId(order.getId())
    .total(BigDecimal.valueOf(150.00))
    .issuedAt(Instant.now())
    .build();
  
  assertThat(invoice)
    .usingRecursiveComparison()
    .ignoringFields("issuedAt", "generatedReportId")
    .isEqualTo(expected);
}

In ride-hailing systems, we use this pattern extensively for trip pricing, where fees depend on time windows and rates change constantly. By ignoring time-sensitive fields, the test stays stable while still catching logic errors.

You can also customize comparison for specific fields:

java
assertThat(invoice)
  .usingRecursiveComparison()
  .withEqualsForFields(
    (a, b) -> Math.abs(a.doubleValue() - b.doubleValue()) < 0.01,
    "taxAmount",
    "total"
  )
  .isEqualTo(expected);

Exception assertions with assertThatThrownBy

Testing error paths is as important as testing happy paths, but exception assertions are often neglected. AssertJ makes them readable:

java
@Test
void invalidPaymentThrowsCustomException() {
  assertThatThrownBy(() -> paymentService.charge(invalidRequest))
    .isInstanceOf(PaymentException.class)
    .hasMessage("Insufficient funds");
}

This verifies that the correct exception type is thrown with the correct message, all in one fluent chain. If no exception is thrown, the test fails clearly. If the wrong exception is thrown, you see it immediately.

For more complex validations, you can inspect the exception:

java
@Test
void invalidEmailThrowsValidationException() {
  assertThatThrownBy(() -> userService.register("invalid-email"))
    .isInstanceOf(ValidationException.class)
    .hasMessageContaining("email")
    .satisfies(ex -> {
      ValidationException ve = (ValidationException) ex;
      assertThat(ve.getFieldErrors())
        .containsKey("email");
    });
}

In SaaS systems with complex validation logic, this pattern ensures that error messages are user-friendly and field mappings are correct. We've caught bugs where error codes were logging but not surfacing to the UI.

Common mistakes

Overusing soft assertions without context: AssertJ's SoftAssertions collect multiple failures, which can be useful in data validation tests. But if you use soft assertions everywhere, a single test failure becomes a wall of text. Reserve soft assertions for bulk checks like "verify all 500 users in this batch" scenarios.

Ignoring too many fields: When recursive comparison fails, the temptation is to ignore more fields. But if you ignore the field that actually matters, your test becomes useless. Ignore only fields that are genuinely non-deterministic (timestamps, generated IDs, random values).

Complex extracting chains that become unreadable: You can chain extracting multiple times, but it gets hard to follow. Instead, use map in the stream pipeline for complex transformations, or create a helper method that extracts what you need.

Not reading the failure message output: AssertJ's real power is in its failure messages. If you're not looking at the actual diff when a test fails, you're missing why you chose AssertJ in the first place.

Key takeaways

  • Fluent chains like assertThat(collection).hasSize(n).contains(item) read like English and fail with clarity.
  • Use extracting to pull properties from collections and verify them in one assertion.
  • usingRecursiveComparison() handles complex DTOs with nested structures and optional fields.
  • Exception assertions with assertThatThrownBy verify both the exception type and message in a single readable chain.
  • Ignore only fields that are genuinely non-deterministic; over-ignoring defeats the purpose of testing.
  • Invest in readable assertions now; they pay dividends every time a test fails in CI or production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles