Assertions and AssertJ

Check results with JUnit's Assertions and AssertJ's fluent API, get failure messages that explain themselves, and test exceptions properly.

Syntaximport static org.assertj.core.api.Assertions.*; assertThat(actual).isEqualTo(expected); assertThat(list).extracting(Order::id).containsExactly("a", "b"); assertThatThrownBy(() -> service.call()).isInstanceOf(X.class);

An assertion compares what happened with what should have happened and throws an AssertionError when they differ. The quality of a test depends on its assertions: a precise assertion fails for the right reason and its message tells you what broke without opening a debugger.

JUnit's Assertions

org.junit.jupiter.api.Assertions has the basics: assertEquals, assertTrue, assertNull, assertThrows and assertAll. The argument order is expected first, actual second. Swap them and the test still works, but the failure message says the opposite of the truth.

assertThrows returns the exception, so you can check the message too. It also passes for a subclass of the expected type; use assertThrowsExactly when the exact type matters. assertAll runs every check and reports all failures together.

AssertJ

AssertJ (org.assertj:assertj-core) starts every assertion with assertThat(actual) and offers methods that suit the type. It is the default in many back-end projects because its messages are much better:

// Fails with: expected true but was false. Which item? No idea.
assertTrue(skus.contains("SKU-9"));

// Fails listing the whole list and the missing element.
assertThat(skus).contains("SKU-9");

Useful parts for service code:

  • Collections: containsExactly (order matters), containsExactlyInAnyOrder, hasSize, isEmpty.
  • extracting(Order::id) to assert on one property of every element.
  • assertThatThrownBy(() -> ...).isInstanceOf(...).hasMessageContaining(...) for exceptions.
  • usingRecursiveComparison().ignoringFields("id", "createdAt") to compare DTOs or entities field by field when generated values differ.
  • isEqualByComparingTo for BigDecimal, and SoftAssertions to collect several failures in one run.

Mistakes people make

  • Writing assertThat(result); with nothing after it. That line checks nothing.
  • Comparing money with isEqualTo. BigDecimal.equals compares the scale, so 10.0 and 10.00 are not equal.
  • Using the assert keyword as a test assertion. It is ignored unless the JVM runs with -ea; Surefire happens to enable it, other runners may not.
  • Mixing both assertThat imports. Hamcrest and AssertJ both have one; pick AssertJ and import it statically.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;

class InvoiceServiceTest {

    private final InvoiceService service = new InvoiceService();

    @Test
    void buildsLinesAndTotalForAnOrder() {
        Invoice invoice = service.invoiceFor(new Order("ord-1", List.of(
                new Line("SKU-1", 2, new BigDecimal("9.99")),
                new Line("SKU-2", 1, new BigDecimal("5.00")))));

        assertThat(invoice.lines())
                .hasSize(2)
                .extracting(InvoiceLine::sku)
                .containsExactly("SKU-1", "SKU-2");
        // 24.98 and 24.980 are equal by value but not by equals()
        assertThat(invoice.total()).isEqualByComparingTo("24.98");
    }

    @Test
    void refusesAnOrderWithNoLines() {
        assertThatThrownBy(() -> service.invoiceFor(new Order("ord-2", List.of())))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("ord-2");
    }
}

// The code under test
record Line(String sku, int quantity, BigDecimal unitPrice) {}

record Order(String id, List<Line> lines) {}

record InvoiceLine(String sku, BigDecimal amount) {}

record Invoice(String orderId, List<InvoiceLine> lines, BigDecimal total) {}

class InvoiceService {
    Invoice invoiceFor(Order order) {
        if (order.lines().isEmpty()) {
            throw new IllegalArgumentException("order " + order.id() + " has no lines");
        }
        List<InvoiceLine> lines = order.lines().stream()
                .map(l -> new InvoiceLine(l.sku(), l.unitPrice().multiply(BigDecimal.valueOf(l.quantity()))))
                .toList();
        BigDecimal total = lines.stream().map(InvoiceLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
        return new Invoice(order.id(), lines, total);
    }
}

When to use it

  • Assert that a repository query returns orders in the expected sort order with containsExactly.
  • Check that an invoice total read back from a NUMERIC column equals the expected amount regardless of scale.
  • Verify that invalid input to a service raises a validation exception carrying the field name in its message.

More examples

Why BigDecimal needs a value comparison

This is exactly what assertEquals and AssertJ's isEqualTo see when a total comes back from a database column with a different scale. AssertJ's isEqualByComparingTo uses compareTo, which is the comparison you want for money.

Example · java
void main() {
    var charged = new BigDecimal("10.00");
    var expected = new BigDecimal("10.0");

    // equals() compares value AND scale: 2 decimal places vs 1
    IO.println("equals: " + charged.equals(expected));
    // compareTo() compares the value only
    IO.println("compareTo: " + charged.compareTo(expected));

    // The assert keyword does nothing unless the JVM runs with -ea
    assert charged.equals(expected) : "only checked with -ea";
    IO.println("the assert statement was skipped");
}

// Output:
// equals: false
// compareTo: 0
// the assert statement was skipped

Comparing DTOs while ignoring generated fields

A recursive comparison checks every field, including nested objects, and its failure message names the field that differs. It avoids writing one assertion per field and catches a newly added field that the mapper forgot to copy.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import java.time.Instant;
import org.junit.jupiter.api.Test;

class CustomerMapperTest {

    @Test
    void mapsARequestToANewCustomer() {
        var request = new CreateCustomerRequest("Ada", "[email protected]");

        Customer created = new CustomerMapper().toNewCustomer(request);

        // id and createdAt are generated, so leave them out of the comparison
        assertThat(created)
                .usingRecursiveComparison()
                .ignoringFields("id", "createdAt")
                .isEqualTo(new Customer(null, "Ada", "[email protected]", null));
    }
}

// The code under test
record CreateCustomerRequest(String name, String email) {}

record Customer(Long id, String name, String email, Instant createdAt) {}

class CustomerMapper {
    // id and createdAt are filled in later by the database
    Customer toNewCustomer(CreateCustomerRequest request) {
        return new Customer(null, request.name(), request.email(), null);
    }
}

Soft assertions for a whole response

With plain assertions the first failure stops the test and hides the others. Soft assertions suit checks that describe one result, such as the status, headers and body of an error response.

Example · java
import static org.assertj.core.api.SoftAssertions.assertSoftly;

import java.util.Map;
import org.junit.jupiter.api.Test;

class ProblemDetailsTest {

    @Test
    void notFoundResponseFollowsTheProblemFormat() {
        ApiResponse response = new ErrorMapper().toResponse(new OrderNotFoundException("ord-9"));

        // Every check runs; all failures are reported together at the end
        assertSoftly(softly -> {
            softly.assertThat(response.status()).isEqualTo(404);
            softly.assertThat(response.header("Content-Type")).isEqualTo("application/problem+json");
            softly.assertThat(response.body()).contains("\"status\":404");
        });
    }
}

// The code under test
record ApiResponse(int status, Map<String, String> headers, String body) {
    String header(String name) {
        return headers.get(name);
    }
}

class OrderNotFoundException extends RuntimeException {
    OrderNotFoundException(String id) {
        super("order " + id + " not found");
    }
}

class ErrorMapper {
    ApiResponse toResponse(OrderNotFoundException e) {
        return new ApiResponse(404, Map.of("Content-Type", "application/problem+json"),
                "{\"status\":404,\"title\":\"Not Found\",\"detail\":\"" + e.getMessage() + "\"}");
    }
}

Discussion

  • Be the first to comment on this lesson.