Custom AssertJ and Hamcrest Matchers: Writing Domain-Specific Assertions

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.

Read more

Start now free