What makes AssertJ's failure messages useful, and how do as()/describedAs() and custom assertions improve diagnosability?
answer
- Default messages show actual, expected, and the diff
- as("...") / describedAs() prefixes context — MUST be before the assertion
- withFailMessage/overridingErrorMessage replaces the generated text
- Custom assertion: extend AbstractAssert + failWithMessage + static assertThat
- Great messages matter most in CI where there is no debugger
basics
~20 sAssertJ failure messages show the actual value, the expected value, and often where they differ, so you understand a failure without rerunning in a debugger. You can add context with as("description") before the assertion, and for domain types you can write custom assertion classes so messages speak your domain's language.
solid answer
~50 sAssertJ's value over assertTrue/assertEquals is largely in its messages: a failed isEqualTo on strings prints both values and highlights the divergence; on collections it lists missing/unexpected elements; recursive comparison reports the differing field path. To add intent, call as("checking %s's age", name) (alias describedAs) before the terminal assertion so the description is prefixed to the failure — invaluable in loops or soft-assertion bundles where it is otherwise unclear which check failed. as() must come before the assertion that may fail. For recurring domain checks you create a custom assertion class extending AbstractAssert, exposing methods like hasStatus(...) with tailored failWithMessage text, reached via a custom assertThat entry point; this gives readable, reusable, domain-specific assertions and messages. withFailMessage/overridingErrorMessage fully replaces the generated message. Good messages cut debugging time and are a deliberate test-quality investment, especially for soft assertions and CI failures where you cannot step through.
code
java · 19 linesimport static org.assertj.core.api.Assertions.assertThat;
// as(...) adds context; MUST come before the failing assertion
assertThat(user.getAge())
.as("age of user %s", user.getName())
.isGreaterThan(18);
// custom domain assertion
public class OrderAssert extends org.assertj.core.api.AbstractAssert<OrderAssert, Order> {
public OrderAssert(Order actual) { super(actual, OrderAssert.class); }
public static OrderAssert assertThat(Order actual) { return new OrderAssert(actual); }
public OrderAssert isPaid() {
isNotNull();
if (actual.getStatus() != Status.PAID)
failWithMessage("Expected order <%s> to be PAID but was <%s>",
actual.getId(), actual.getStatus());
return this;
}
}go deeper
Knows AssertJ failure messages show actual vs expected and are clearer than assertTrue.
Uses as()/describedAs() to add context and knows it must precede the assertion.
Writes custom AbstractAssert assertions with failWithMessage for domain types and chooses prefix (as) vs replace (withFailMessage) appropriately.
Establishes team conventions for diagnosable test failures (description policy in loops/soft bundles, shared custom assertion libraries, the assertions generator) to minimize CI debugging time.
## Why messages matter When a test fails in CI you often cannot attach a debugger; the **failure message is your only evidence**. `assertTrue(a.equals(b))` prints only `expected: <true> but was: <false>` — useless. AssertJ instead reports the **values and the difference**, which is its core practical advantage. ## What AssertJ gives by default - `isEqualTo` on Strings: shows both strings and points to the first differing character/region. - Collections: `containsExactly` lists **missing** and **unexpected** elements and order issues. - `usingRecursiveComparison`: reports the **field path** that differs with both values. - Numbers, dates, optionals, etc.: type-aware phrasing. These messages are generated from the assertion's knowledge of the type, which is exactly why the type-specific assertion objects exist. ## Adding context: as() / describedAs() Sometimes the value alone is ambiguous — e.g. inside a loop, or among many soft assertions, *which* check failed? `as(...)` attaches a **description** that is prefixed to the failure message: ```java assertThat(user.getAge()) .as("age of user %s", user.getName()) .isGreaterThan(18); ``` A failure reads `[age of user Ann] Expecting ... to be greater than 18`. `describedAs(...)` is an exact alias. **Crucial rule:** `as(...)` must be called **before** the assertion that may fail; placed after, the description is set too late to appear. It supports `%s`-style format args. ## Replacing the message: withFailMessage / overridingErrorMessage When you want to **replace** (not prefix) the generated text: ```java assertThat(count) .withFailMessage("expected non-empty cart but had %d items", count) .isPositive(); ``` Use sparingly — you lose AssertJ's rich auto-message, so only override when your custom text is clearly better. ## Custom assertions for domain types For a domain type checked repeatedly (e.g. `Order`), write a **custom assertion class** so tests read in domain language and messages are tailored: ```java public class OrderAssert extends AbstractAssert<OrderAssert, Order> { public OrderAssert(Order actual) { super(actual, OrderAssert.class); } public static OrderAssert assertThat(Order actual) { return new OrderAssert(actual); } public OrderAssert isPaid() { isNotNull(); if (actual.getStatus() != PAID) failWithMessage("Expected order <%s> to be PAID but was <%s>", actual.getId(), actual.getStatus()); return this; } } ``` Usage: `OrderAssert.assertThat(order).isPaid().hasTotal(100);`. Benefits: reusable, expressive, **domain-specific messages**, and chainable like built-ins. `failWithMessage` integrates with descriptions and soft assertions. AssertJ even ships an **assertions generator** to scaffold these from your classes. ## Putting it together - Lean on the **default** rich messages first. - Add `as(...)` for context in loops / soft-assertion bundles (before the assertion). - Use `withFailMessage` only when you can do clearly better than the default. - Promote frequently repeated domain checks to a **custom assertion** for readability and message quality. This is a deliberate investment: better messages directly reduce mean-time-to-diagnose for failing tests, especially in CI where stepping through is impossible.
- Why must as() be placed before the assertion and not after?as()/describedAs() sets the description on the assertion object, and that description is only read when an assertion subsequently fails. If you call it after the terminal assertion, the failure has already been produced (or the call returns a different object), so the description never appears in the message. Always: assertThat(x).as("...").isEqualTo(...).
- When should you write a custom AbstractAssert subclass?When a domain type is asserted on repeatedly and you want expressive, reusable checks (order.isPaid()) with domain-specific failure messages. It improves readability and diagnosability across many tests; AssertJ's assertions generator can scaffold one from the class.
saying these in an interview costs you the question
- Calling as()/describedAs() after the assertion, so the description never shows
- Overusing withFailMessage and discarding AssertJ's richer default message
- Thinking as() changes assertion behavior rather than just the message
- Reinventing per-test inline checks instead of a reusable custom assertion for a hot domain type