All articles

// Knowledge.log — 技術記事

AssertJ in Spring Boot 4.1: Failures on the Right Field

Use AssertJ with ProblemDetail and MockMvcTester to pinpoint mismatched fields in Spring Boot 4.1 tests without adding redundant dependencies.

A test compares two DTOs and gets something close to expected: <X> but was: <Y>. Now the investigation begins: open the objects, line up the fields visually, and figure out whether the difference is in title, a nested address, or that generated id that was not even part of the behavior under test. The message is technically correct. Whether it is useful is another matter.

With AssertJ, we can compare the object recursively and have the failure report the field path, the actual value, and the expected value. The same feature works well for RFC 9457 responses represented by ProblemDetail: instead of learning only that the objects differ, the test shows that detail or instance diverged.

The practical result will be replacing an opaque comparison with one that describes the defect. We will also apply the approach to an MVC test and keep jsonPath where it remains the most direct tool.

Versions and prerequisites

The stack used here is Spring Boot 4.1.1, Spring Framework 7.0.9, and Java 25 LTS. Boot 4.1.1 supports Java 17 through 26, so Java 25 is the newest LTS within the official requirements matrix.

In tests, spring-boot-starter-test 4.1.1 already provides AssertJ 3.27.7 and Mockito 5.23.0. Do not add another dependency for assertj-core: besides being redundant, it allows a manually pinned version to drift away from the set managed by Boot. The starter also includes JSONPath, which is used by classic MVC tests.

The web slice uses the Boot 4 packages:

import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

When the test depends on automatic error handling in RFC 9457 format, enable:

spring.mvc.problemdetails.enabled=true

Without this property—or an equivalent @ControllerAdvice—there is no reason to expect every application error to become the ProblemDetail defined by the contract. Tests do not fix configuration by telepathy, although some pipelines seem to bet on it.

Start with the ProblemDetail contract

A ProblemDetail has five members standardized by RFC 9457: status, type, title, detail, and instance. In the Java object, type and instance are URI; status is an integer; the others are strings. Extension properties live in the map returned by getProperties() and are serialized as top-level JSON members.

For an object created directly, explicit assertions are a good choice when each field calls for a different rule:

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
    HttpStatus.NOT_FOUND,
    "No project found for id 'spring-unknown'"
);
problem.setType(URI.create("https://example.org/problems/unknown-project"));
problem.setTitle("Unknown project");
problem.setInstance(URI.create("/projects/spring-unknown"));

assertThat(problem.getStatus()).isEqualTo(404);
assertThat(problem.getType())
    .isEqualTo(URI.create("https://example.org/problems/unknown-project"));
assertThat(problem.getTitle()).isEqualTo("Unknown project");
assertThat(problem.getDetail()).contains("spring-unknown");
assertThat(problem.getInstance())
    .isEqualTo(URI.create("/projects/spring-unknown"));

This already produces better messages than a generic comparison. But when you have a complete expected object, usingRecursiveComparison() avoids five repeated comparisons and groups the differences by field:

assertThat(actualProblem)
    .usingRecursiveComparison()
    .comparingOnlyFields("status", "type", "title", "detail", "instance")
    .isEqualTo(expectedProblem);

If title is wrong, the message identifies something like field/property 'title' differ, followed by actual value and expected value. For nested objects, the path is nested too, such as customer.address.number. This is more than prettier output: it is the difference between fixing the contract and staring at two long toString() results until one character confesses.

comparingOnlyFields works as a list of fields relevant to that test. When you specify "customer", its subfields enter the comparison; for a narrower slice, use paths such as "customer.address.number".

Meanwhile, ignoringFields removes unstable or irrelevant fields. A persisted DTO may have a generated id and timestamps, for example:

assertThat(savedProject)
    .usingRecursiveComparison()
    .ignoringFields("id", "createdAt", "updatedAt")
    .isEqualTo(requestedProject);

The decision needs to follow the test contract. Ignoring a field because it failed turns diagnostics into decoration; ignoring it because it is generated outside the behavior being observed keeps the test focused.

There is a similarly named trap: withEqualsForFields does not select fields. It accepts a BiPredicate with a custom equality rule for specific locations. To allow a numeric tolerance, the correct use is:

BiPredicate<Double, Double> closeEnough =
    (actual, expected) -> Math.abs(actual - expected) <= 0.5;

assertThat(actualCharacter)
    .usingRecursiveComparison()
    .withEqualsForFields(closeEnough, "height")
    .isEqualTo(expectedCharacter);

So use comparingOnlyFields to choose what to compare, ignoringFields to exclude fields, and withEqualsForFields only when that field has its own notion of equality.

This comparison does not require changing equals() and hashCode() just to accommodate a test scenario. That matters for input and output DTOs with similar but not identical shapes: by default, recursive comparison can traverse the fields even when the object types are not the same. If equality must also require the same type throughout the tree, add withStrictTypeChecking() and make that decision explicit in the test.

Another benefit appears when more than one field differs. A sequence of assertEquals calls usually stops the method at the first failure; the next defect appears only after the first one is fixed. By placing the relevant fields in the same recursive comparison, the message can list every difference found within that slice. This does not mean comparing everything indiscriminately. It means declaring a boundary—the five ProblemDetail members, for example—and receiving a complete diagnosis within it.

Descriptions also help locate the intent when the suite is large. An .as("RFC 9457 problem body") before usingRecursiveComparison() adds context to the failure without replacing the field-by-field report. Avoid generic custom messages that hide the values produced by AssertJ; writing “invalid response” merely replaces one poor message with another that sounds more confident.

Bring the comparison to MockMvcTester

In Spring Framework 7, MockMvcTester provides AssertJ assertions for MVC calls. With AssertJ on the classpath, Boot configures it in tests using @WebMvcTest. Mockito remains responsible for the mocked collaborator; AssertJ verifies the result. One library does not replace the other.

The test below assumes that ProjectController returns the described problem when the service cannot find the project:

@WebMvcTest(ProjectController.class)
class ProjectErrorTests {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    ProjectService projects;

    @Test
    void returnsRfc9457BodyForUnknownProject() {
        given(projects.find("spring-unknown"))
            .willReturn(Optional.empty());

        ProblemDetail expected = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            "No project found for id 'spring-unknown'"
        );
        expected.setType(URI.create(
            "https://example.org/problems/unknown-project"
        ));
        expected.setTitle("Unknown project");
        expected.setInstance(URI.create("/projects/spring-unknown"));

        assertThat(mvc.get().uri(
                "/projects/{id}", "spring-unknown"))
            .hasStatus(HttpStatus.NOT_FOUND)
            .hasContentTypeCompatibleWith(
                MediaType.APPLICATION_PROBLEM_JSON)
            .bodyJson()
            .extractingPath("$")
            .convertTo(ProblemDetail.class)
            .usingRecursiveComparison()
            .comparingOnlyFields(
                "status", "type", "title", "detail", "instance")
            .isEqualTo(expected);
    }
}

convertTo(ProblemDetail.class) uses the Jackson HTTP converters available in the MVC context. If you create a MockMvcTester in standalone mode, you will need to register those converters. In a @WebMvcTest slice, that infrastructure is already part of the configured context.

You can also test parts of the JSON without converting it:

assertThat(mvc.get().uri("/projects/{id}", "spring-unknown"))
    .bodyJson()
    .extractingPath("$.status")
    .asNumber()
    .isEqualTo(404);

Conversion is especially useful when the intent is to validate the set of fields as an object. JSONPath is better when we want to locate a member, filter a collection, or preserve an existing test that already expresses the contract well.

AssertJ and jsonPath can coexist

The classic style with MockMvc and Hamcrest remains supported:

mockMvc.perform(get("/projects/{id}", "spring-unknown"))
    .andExpect(status().isNotFound())
    .andExpect(jsonPath("$.status").value(404))
    .andExpect(jsonPath("$.title").value("Unknown project"));

For new object-oriented tests, MockMvcTester combined with AssertJ usually offers a more consistent flow. There is no need to rewrite the entire suite at once. The official integration lets you reuse existing matchers through matches():

assertThat(mvc.get().uri("/projects/{id}", "spring-unknown"))
    .matches(status().isNotFound());

There is also a bridge for a ResultActions produced by the classic style:

assertThat(mockMvc.perform(get("/projects/{id}", "spring-unknown")))
    .hasStatus(HttpStatus.NOT_FOUND);

The firm recommendation is conditional: for new tests that convert the response into a DTO or ProblemDetail, DevDojo would adopt MockMvcTester with recursive comparison; it would keep Hamcrest jsonPath when the test depends on expressive JSON queries or when migrating a stable case would only trade one syntax for another.

Make the test fail on purpose

Before trusting the improvement, temporarily change the expected title to "Project missing" and run:

./mvnw -q test

The failure should mention the title path and show the actual and expected values. Then change detail as well to confirm that one recursive comparison lists both divergent paths. Revert the changes afterward; leaving a broken test as interactive documentation tends to produce a less interactive meeting the next day.

Also check the dependency tree:

./mvnw dependency:tree \
  -Dincludes=org.assertj:assertj-core

The result should show a single version, 3.27.7, brought in by spring-boot-starter-test. If another one appears, remove the manual declaration before investigating strange API issues.

Review three more points when the test does not produce the expected body:

  • confirm spring.mvc.problemdetails.enabled=true or the presence of your @ControllerAdvice;
  • use the Boot 4 stack packages for @WebMvcTest and @MockitoBean;
  • decide whether instance is part of the stable contract. Spring may populate it from the request path when it has not been set.

The next step is to choose a real error test, replace only the object comparison, and introduce a mismatch in a nested field. If the output points to the right path without requiring manual inspection of the objects, the change has already delivered value. Contract tests between services, such as those shown in the article about Pact with Spring Boot, solve a different problem; here, the benefit is precise diagnostics within the suite itself.

javaspring-boottesting

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles