Assertions and AssertJ
Check results with JUnit's Assertions and AssertJ's fluent API, get failure messages that explain themselves, and test exceptions properly.
import 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.isEqualByComparingToforBigDecimal, andSoftAssertionsto 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.equalscompares the scale, so10.0and10.00are not equal. - Using the
assertkeyword as a test assertion. It is ignored unless the JVM runs with-ea; Surefire happens to enable it, other runners may not. - Mixing both
assertThatimports. Hamcrest and AssertJ both have one; pick AssertJ and import it statically.
Example
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();
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");
}
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.
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 skippedComparing 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.
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.
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