A test assertion has two jobs. It must fail when the code is wrong, and when it fails it must tell you what was wrong without a debugger. Plain assertEquals(expected, actual) does the first job and is weak at the second: comparing two lists prints both lists and leaves you to spot the difference, and checking five properties of an object needs five separate statements that stop at the first failure. AssertJ is a Java library of fluent, type-specific assertions that does the second job much better: you write assertThat(actual), your IDE offers only the checks that make sense for that type, and failures describe exactly which element or field differed.
This article explains how AssertJ works, the assertions you will use daily for values, collections, objects and exceptions, soft assertions, descriptive failure messages and custom domain assertions, and the pitfalls that make an AssertJ test pass when it should not. It assumes you run tests with JUnit 5; AssertJ is runner-agnostic and only throws AssertionError.
How an AssertJ assertion flows
Setting it up
AssertJ Core is a single test-scoped dependency with no transitive runtime requirements. The 3.x line, at 3.27.x at the time of writing, runs on Java 8 and later; check Maven Central for the current release and pin it, or import it through a BOM such as Spring Boot's dependency management, which already manages its version.
<!-- Maven -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
// Gradle
testImplementation("org.assertj:assertj-core:$assertjVersion")Everything starts from one static import, import static org.assertj.core.api.Assertions.*;. Teams that write given-when-then tests can use BDDAssertions.then(actual) instead; it returns the same assertion objects under a different name.
Entry points and chaining
The design is simple. Assertions has dozens of assertThat overloads, one per type family: String, Integer, List, Map, Optional, Path, LocalDate and many more. The Java compiler picks the overload from the static type of the argument, so assertThat(name) returns a string assertion object and your IDE completion offers startsWith and containsIgnoringCase, while assertThat(count) offers isPositive and isBetween. Each check either throws an AssertionError or returns the same assertion object, which is what makes chaining work. Internally every assertion class extends AbstractAssert<SELF, ACTUAL>, where SELF is the concrete class, so chained calls keep their specific type.
assertThat(user.getEmail())
.isNotBlank()
.endsWith("@example.com")
.doesNotContain(" ");
assertThat(order.getTotal()).isEqualByComparingTo("19.90"); // BigDecimal: ignores scale
assertThat(ratio).isCloseTo(0.75, within(0.001));
assertThat(result).contains("ok"); // OptionalTwo distinctions catch beginners. isEqualTo uses equals while isSameAs checks reference identity. And BigDecimal equality includes scale, so new BigDecimal("19.9") is not equal to new BigDecimal("19.90"); use isEqualByComparingTo for money.
Maps, dates, files and custom predicates
Beyond strings and numbers, the type-specific entry points cover most of what business code returns. Maps get key and entry checks, dates and times get ordering checks, and files and paths get existence and content checks. A few examples worth knowing:
assertThat(headers) // Map<String, String>
.containsEntry("Content-Type", "application/json")
.containsKeys("X-Request-Id")
.doesNotContainKey("Set-Cookie");
assertThat(invoice.getDueDate()) // LocalDate
.isAfter(invoice.getIssueDate())
.isBefore(invoice.getIssueDate().plusDays(31));
assertThat(report) // java.nio.file.Path
.exists()
.isRegularFile();
assertThat(Files.readString(report)).contains("TOTAL");
assertThat(response.body())
.isEqualToIgnoringWhitespace("{\"status\": \"ok\"}");When no built-in check fits, satisfies accepts a lambda containing ordinary assertions, and matches accepts a predicate with a description. Both keep the check inside the fluent chain instead of breaking out into a separate if statement, so the failure still names the actual value.
Collections, extracting and filtering
Collection assertions are where AssertJ saves the most time, because the failure message names the missing, unexpected or out-of-order elements. Choose the check that matches the guarantee your code actually makes:
| Check | Passes when |
|---|---|
contains(a, b) | a and b are present; others allowed, any order |
containsOnly(a, b) | only a and b are present, any order, duplicates allowed |
containsExactly(a, b) | exactly a then b, nothing else |
containsExactlyInAnyOrder(a, b) | exactly these elements, any order |
hasSize(n) | n elements |
allSatisfy(consumer) | every element passes the nested assertions |
satisfiesExactly(c1, c2) | element i passes consumer i, sizes match |
For lists of objects, extracting projects one or more properties and filteredOn narrows the list first, which keeps tests readable without hand-written loops:
assertThat(orders)
.filteredOn(o -> o.getStatus() == Status.SHIPPED)
.extracting(Order::getId, Order::getCustomerId)
.containsExactlyInAnyOrder(
tuple("o-1", "c-7"),
tuple("o-4", "c-7"));
assertThat(lines).allSatisfy(line -> {
assertThat(line.getQuantity()).isPositive();
assertThat(line.getSku()).matches("[A-Z]{3}-\\d{4}");
});
Comparing whole objects
Comparing whole objects field by field is the job of usingRecursiveComparison(). It walks both object graphs and compares fields, including nested objects and collections, and reports every differing path such as address.city. Since 3.17.0 it does not use overridden equals methods by default and compares fields all the way down, which is usually what a test wants. You can tune it:
assertThat(actualCustomer)
.usingRecursiveComparison()
.ignoringFields("id", "createdAt")
.ignoringCollectionOrder()
.isEqualTo(expectedCustomer);This is ideal for DTOs and API responses where the expected value is easy to build. Be deliberate about ignored fields: every ignored field is a field the test no longer protects, and a test that ignores ten fields is probably checking the wrong thing.
Asserting on exceptions
Exceptions are first-class. Prefer these forms over JUnit 4's @Test(expected = ...) or a bare assertThrows, because they pin down which call threw and let you assert on the message and cause:
assertThatThrownBy(() -> account.withdraw(new BigDecimal("500")))
.isInstanceOf(InsufficientFundsException.class)
.hasMessageContaining("balance")
.hasNoCause();
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> Sku.parse("bad"))
.withMessage("SKU must look like ABC-1234");
Throwable thrown = catchThrowable(() -> client.fetch("missing"));
assertThat(thrown).isInstanceOf(NotFoundException.class);
assertThatCode(() -> validator.validate(goodInput)).doesNotThrowAnyException();catchThrowable fits the arrange-act-assert layout, because the act step stays a separate line.
Soft assertions
A normal assertion throws on the first failure, so a test that checks six fields reports one problem per run. Soft assertions collect every failure and throw once at the end with all of them listed. They are ideal for checking many independent properties of one result, such as a mapped response:
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(dto.getId()).isEqualTo("o-1");
softly.assertThat(dto.getStatus()).isEqualTo("SHIPPED");
softly.assertThat(dto.getLines()).hasSize(2);
softly.assertThat(dto.getTotal()).isEqualByComparingTo("42.00");
});
// JUnit 5: the extension calls assertAll() after each test
@ExtendWith(SoftAssertionsExtension.class)
class OrderMapperTest {
@InjectSoftAssertions
SoftAssertions softly;
@Test
void mapsAllFields() {
softly.assertThat(mapper.map(order).getId()).isEqualTo("o-1");
}
}If you create a SoftAssertions object by hand, you must call assertAll() yourself; forgetting it makes every failure silently disappear. The assertSoftly lambda and the JUnit 5 extension both do it for you, which is why they are the safer forms.
Descriptions and failure messages
Use as("...") to describe what is being checked; the description is prefixed to the failure message. It must come before the check, because the check runs immediately and throws. withFailMessage(...) replaces the generated message entirely, which usually loses information, so reserve it for cases where the default is unreadable.
assertThat(inventory.available("ABC-1234"))
.as("stock for ABC-1234 after reservation")
.isEqualTo(3);
// failure message, roughly:
// [stock for ABC-1234 after reservation]
// expected: 3
// but was: 4
Worked example: one test, before and after
Here is a realistic test before and after the change. The code under test reserves stock for an order and returns a reservation with lines and a status. The original version uses JUnit assertions:
@Test
void reservesStockForEveryLine() {
Reservation r = service.reserve(order("o-1", line("ABC-1234", 2), line("XYZ-0001", 1)));
assertNotNull(r);
assertEquals("RESERVED", r.getStatus());
assertEquals(2, r.getLines().size());
assertEquals("ABC-1234", r.getLines().get(0).getSku());
assertEquals(2, r.getLines().get(0).getQuantity());
assertEquals("XYZ-0001", r.getLines().get(1).getSku());
assertEquals(1, r.getLines().get(1).getQuantity());
}It has three problems. It depends on line order, which the service never promised. It stops at the first failure, so a wrong status hides wrong quantities. And a failure prints something like expected 2 but was 3 with no hint of which line. The AssertJ version states the real guarantee and reports every difference:
@Test
void reservesStockForEveryLine() {
Reservation r = service.reserve(order("o-1", line("ABC-1234", 2), line("XYZ-0001", 1)));
assertThat(r).isNotNull();
assertThat(r.getStatus()).as("reservation status").isEqualTo("RESERVED");
assertThat(r.getLines())
.extracting(ReservedLine::getSku, ReservedLine::getQuantity)
.containsExactlyInAnyOrder(
tuple("ABC-1234", 2),
tuple("XYZ-0001", 1));
}If the service reserved three units of ABC-1234, the failure lists the expected tuples, the actual tuples, the element that was not found and the element that was not expected, which is usually enough to fix the bug without rerunning anything.
Custom domain assertions
When the same group of checks appears in many tests, wrap it in a domain assertion. Extend AbstractAssert, add an entry point, and write each check with isNotNull() followed by failWithMessage. The result reads like the business language and gives one place to improve failure messages.
public class OrderAssert extends AbstractAssert<OrderAssert, Order> {
private OrderAssert(Order actual) {
super(actual, OrderAssert.class);
}
public static OrderAssert assertThat(Order actual) {
return new OrderAssert(actual);
}
public OrderAssert isShippedTo(String customerId) {
isNotNull();
if (actual.getStatus() != Status.SHIPPED) {
failWithMessage("Expected order %s to be SHIPPED but was %s",
actual.getId(), actual.getStatus());
}
if (!actual.getCustomerId().equals(customerId)) {
failWithMessage("Expected order %s to ship to %s but shipped to %s",
actual.getId(), customerId, actual.getCustomerId());
}
return this;
}
public OrderAssert hasTotal(String amount) {
isNotNull();
Assertions.assertThat(actual.getTotal())
.as("total of order %s", actual.getId())
.isEqualByComparingTo(amount);
return this;
}
}A test then reads OrderAssert.assertThat(order).isShippedTo("c-7").hasTotal("42.00");. In a worked example, a checkout test that previously had eight assertEquals lines shrinks to one chained domain assertion plus one extracting check on the order lines, and a failure now says which order, which field and what the values were. For teams with many domain types, the AssertJ assertions generator can produce such classes from your model, but hand-written ones for the few central types usually give better messages. Pair them with Mockito for interaction checks and Testcontainers when the actual value comes from a real database.
Pitfalls that make tests pass wrongly
- The assertion that checks nothing.
assertThat(result);with no check compiles and always passes. Some static analysers flag it; code review should too. - Description after the check.
assertThat(x).isEqualTo(1).as("count")never shows the description, because the check already threw. - The wrong collection check.
containspasses when extra, unexpected elements are present. If the code guarantees the full content, usecontainsExactlyorcontainsExactlyInAnyOrder. - Floating point and BigDecimal equality. Use
isCloseTowithwithinoroffsetfor doubles andisEqualByComparingTofor decimals. - Unchecked soft assertions. A hand-built
SoftAssertionswithoutassertAll()turns failures into silence. - Over-broad recursive comparison. Long ignore lists, or comparing against an expected object built by the same mapper under test, make the test tautological.
- Mixing two assertThat imports. Hamcrest's
MatcherAssert.assertThatand AssertJ's share a name; a wrong static import produces confusing compile errors. Pick one library per module.
Migrating an existing suite
Migrating an existing suite does not need a big-bang rewrite. AssertJ and JUnit assertions coexist in the same test class, so convert files as you touch them. Start with the tests that fail most often or produce the least readable failures: collection comparisons, expected exceptions and multi-field object checks. Mechanical rewrites of assertEquals(expected, actual) to assertThat(actual).isEqualTo(expected) are safe but low value; the real gain comes from replacing groups of assertions with one collection, recursive or soft check that states the actual guarantee. Agree on conventions early, such as soft assertions only through the extension and descriptions on every non-obvious check, so the suite reads consistently.
Trade-offs
Compared with JUnit's built-in assertions, AssertJ costs one dependency and a slightly larger API to learn, and gives far better failure messages and discoverability. Compared with Hamcrest, it trades composable matcher objects for IDE-friendly chaining, which most teams find easier to read and write. Google's Truth offers a similar fluent style with a smaller API. The practical choice is to standardise on one assertion library per codebase; mixed styles cost more in review than any library's individual weaknesses.
What to do next
- Add
assertj-corewith test scope and a pinned or BOM-managed version. - Replace
assertEqualson collections withcontainsExactlyorcontainsExactlyInAnyOrderand compare the failure messages. - Convert expected-exception tests to
assertThatThrownByorassertThatExceptionOfTypewith message checks. - Use
usingRecursiveComparison()for DTO tests and review every ignored field. - Adopt
SoftAssertionsExtensionfor tests that verify many independent fields. - Write one custom
AbstractAssertfor your most-tested domain type. - Add a lint or review rule against bare
assertThat(x)calls and descriptions placed after checks.