Custom AssertJ and Hamcrest Matchers: Writing Domain-Specific Assertions
Built-in assertions cover primitives, collections, and strings. Once you're testing domain objects — orders, users, API responses — you write the same assertions repeatedly. Custom matchers eliminate repetition and make tests read like specifications.
Custom AssertJ Assertions
Creating an AbstractAssert Subclass
For a User domain object:
public class User {
private Long id;
private String name;
private String email;
private boolean active;
private int age;
// getters...
}Create a custom assertion class:
import org.assertj.core.api.AbstractAssert;
public class UserAssert extends AbstractAssert<UserAssert, User> {
public UserAssert(User user) {
super(user, UserAssert.class);
}
// Entry point — convention is to call this assertThat
public static UserAssert assertThat(User user) {
return new UserAssert(user);
}
public UserAssert hasName(String name) {
isNotNull();
if (!actual.getName().equals(name)) {
failWithMessage("Expected user name to be <%s> but was <%s>",
name, actual.getName());
}
return this; // return this for chaining
}
public UserAssert hasEmail(String email) {
isNotNull();
if (!actual.getEmail().equals(email)) {
failWithMessage("Expected user email to be <%s> but was <%s>",
email, actual.getEmail());
}
return this;
}
public UserAssert isActive() {
isNotNull();
if (!actual.isActive()) {
failWithMessage("Expected user <%s> to be active but was inactive",
actual.getName());
}
return this;
}
public UserAssert isAdult() {
isNotNull();
if (actual.getAge() < 18) {
failWithMessage("Expected user <%s> to be an adult (age >= 18) but age was <%d>",
actual.getName(), actual.getAge());
}
return this;
}
public UserAssert hasValidEmail() {
isNotNull();
if (actual.getEmail() == null || !actual.getEmail().matches("^[^@]+@[^@]+\\.[^@]+$")) {
failWithMessage("Expected user to have a valid email but was <%s>",
actual.getEmail());
}
return this;
}
}Usage in tests:
import static com.example.UserAssert.assertThat;
@Test
void createdUserHasCorrectProperties() {
User user = userService.create("John Doe", "john@example.com", 25);
assertThat(user)
.isNotNull()
.hasName("John Doe")
.hasEmail("john@example.com")
.isActive()
.isAdult()
.hasValidEmail();
}Assertions Entry Point Class
Group all custom assertions in one class to simplify imports:
public class Assertions extends org.assertj.core.api.Assertions {
public static UserAssert assertThat(User user) {
return new UserAssert(user);
}
public static OrderAssert assertThat(Order order) {
return new OrderAssert(order);
}
public static ApiResponseAssert assertThat(ApiResponse response) {
return new ApiResponseAssert(response);
}
}Tests import only one class:
import static com.example.Assertions.*;
assertThat(user).isActive().hasValidEmail();
assertThat(order).hasStatus("SHIPPED").hasLineItems(3);AssertJ Condition Class
For reusable conditions without a full assertion class:
import org.assertj.core.api.Condition;
// Reusable condition
Condition<String> validEmail = new Condition<>(
email -> email != null && email.matches("^[^@]+@[^@]+\\.[^@]+$"),
"a valid email address"
);
Condition<User> activeAdult = new Condition<>(
user -> user.isActive() && user.getAge() >= 18,
"an active adult user"
);
// Use in standard assertThat
assertThat("user@example.com").is(validEmail);
assertThat("not-email").isNot(validEmail);
assertThat(user).is(activeAdult);
// Use in collection assertions
assertThat(users).are(activeAdult);
assertThat(emails).have(validEmail);Custom Hamcrest Matchers
TypeSafeMatcher
import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;
public class OrderMatcher extends TypeSafeMatcher<Order> {
private final String expectedStatus;
private final int expectedItemCount;
private OrderMatcher(String status, int itemCount) {
this.expectedStatus = status;
this.expectedItemCount = itemCount;
}
@Override
protected boolean matchesSafely(Order order) {
return expectedStatus.equals(order.getStatus())
&& order.getItems().size() == expectedItemCount;
}
@Override
public void describeTo(Description description) {
description
.appendText("an order with status ")
.appendValue(expectedStatus)
.appendText(" and ")
.appendValue(expectedItemCount)
.appendText(" items");
}
@Override
protected void describeMismatchSafely(Order order, Description description) {
description
.appendText("had status ")
.appendValue(order.getStatus())
.appendText(" and ")
.appendValue(order.getItems().size())
.appendText(" items");
}
// Factory method
public static OrderMatcher isShippedOrderWith(int itemCount) {
return new OrderMatcher("SHIPPED", itemCount);
}
}Usage:
import static com.example.OrderMatcher.isShippedOrderWith;
assertThat(order, isShippedOrderWith(3));Failure: Expected: an order with status "SHIPPED" and 3 items but: had status "PENDING" and 3 items
Factory Methods for Readability
public class Matchers {
public static Matcher<String> validEmail() {
return new TypeSafeMatcher<>() {
@Override
protected boolean matchesSafely(String s) {
return s != null && s.matches("^[^@]+@[^@]+\\.[^@]+$");
}
@Override
public void describeTo(Description d) {
d.appendText("a valid email address");
}
};
}
public static Matcher<Integer> httpSuccess() {
return allOf(greaterThanOrEqualTo(200), lessThan(300));
}
public static Matcher<String> validUUID() {
return matchesPattern(
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
);
}
}Practical Example: API Response Assertions
public class ApiResponseAssert extends AbstractAssert<ApiResponseAssert, ApiResponse> {
public ApiResponseAssert(ApiResponse response) {
super(response, ApiResponseAssert.class);
}
public static ApiResponseAssert assertThat(ApiResponse response) {
return new ApiResponseAssert(response);
}
public ApiResponseAssert isSuccess() {
isNotNull();
if (actual.getStatus() < 200 || actual.getStatus() >= 300) {
failWithMessage("Expected HTTP success (2xx) but got <%d>. Body: <%s>",
actual.getStatus(), actual.getBody());
}
return this;
}
public ApiResponseAssert hasStatus(int status) {
isNotNull();
if (actual.getStatus() != status) {
failWithMessage("Expected HTTP status <%d> but got <%d>. Body: <%s>",
status, actual.getStatus(), actual.getBody());
}
return this;
}
public ApiResponseAssert hasJsonField(String field, Object value) {
isNotNull();
// parse JSON and check field
Object actual_val = JsonPath.read(actual.getBody(), "$." + field);
if (!value.equals(actual_val)) {
failWithMessage("Expected JSON field <%s> to be <%s> but was <%s>",
field, value, actual_val);
}
return this;
}
}
// Test reads naturally:
assertThat(response)
.isSuccess()
.hasStatus(201)
.hasJsonField("id", createdId)
.hasJsonField("status", "active");When to Write Custom Assertions
Write a custom assertion when you find yourself writing the same multi-step assertion for the same type in three or more tests. The one-time cost of the AbstractAssert subclass pays off quickly in readability and when the domain object changes — you update one class, not every test.