Post

The best way you should do your endpoints testing

What is worse than missing tests? Incorrectly written tests. They can give us a false impression of safety.

Once upon a time I had a situation when I’ve broke all the application endpoint by upgrading version of one of used libraries and I was not aware of that at all…

All tests passed…

What happened there?

Let’s take a look how endpoints tests were written.

Sample controller

First, let’s create a dummy controller which will return information about two users:

1
2
3
4
5
6
7
8
9
10
11
@RestController
class UserController {

    @GetMapping("/users")
    public List<User> listAllUsers() {
        return List.of(
                new User(1L, "Walter", "White"),
                new User(2L, "Jesse", "Pinkman")
        );
    }
}

An user record is pretty simple, but enough to see the possible problem:

1
2
3
4
5
6
7
public record User (
        Long id,
        String firstName,
        String lastName
) {
    // empty by design
}

Testing with mapping into POJOs

Now we can take a look how the tests were written in the mentioned project:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@SpringBootTest(webEnvironment = RANDOM_PORT)
class UserControllerPojoTest {

    @LocalServerPort
    private int port;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @Test
    void shouldListAllUsers() {
        List<User> expectedUsers = List.of(
                new User(1L, "Walter", "White"),
                new User(2L, "Jesse", "Pinkman")
        );

        List<User> users =
                given()
                    .contentType(APPLICATION_JSON_VALUE)
                .when()
                    .get("/users")
                .then()
                    .statusCode(OK.value())
                    .contentType(APPLICATION_JSON_VALUE)
                    .extract().as(new TypeRef<>() {});

        assertEquals(expectedUsers, users);
    }
}

The above test is perfectly valid and will be green after running:

UserControllerPojoTest#shouldListAllUsers test passed

Comparing entire JSONs

But it also can be written in a bit different manner:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@WebMvcTest
class UserControllerJsonTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldListAllUsers() throws Exception {

        // when
        mockMvc.perform(
                    get("/users")
                        .contentType(APPLICATION_JSON)
                )

                // then
                .andExpect(status().isOk())
                .andExpect(content().contentType(APPLICATION_JSON))
                .andExpect(content().json("""
                        [
                            {
                                "id": 1,
                                "firstName": "Walter",
                                "lastName": "White"
                            },
                            {
                                "id": 2,
                                "firstName": "Jesse",
                                "lastName": "Pinkman"
                            }
                        ]
                        """, true));
    }
}

and it will pass as well:

UserControllerJsonTest#shouldListAllUsers test passed

Now, let’s add one annotation to the User entity:

1
2
3
4
5
6
7
8
@JsonNaming(SnakeCaseStrategy.class)
public record User (
        Long id,
        String firstName,
        String lastName
) {
    // empty by design
}

and rerun both tests:

UserControllerJsonTest#shouldListAllUsers test failed

What just happened? Why is one test green and the other red?

Let’s look at the information from the failed test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = [
                      {
                      	"id": 1,
                      	"first_name": "Walter",
                      	"last_name": "White"
                      },
                      {
                      	"id":2,
                      	"first_name": "Jesse",
                      	"last_name": "Pinkman"
                      }
                    ]
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

java.lang.AssertionError: [0]
Expected: firstName
     but none found
 ; [0]
Expected: lastName
     but none found
 ; [0]
Unexpected: first_name
 ; [0]
Unexpected: last_name
 ; [1]
Expected: firstName
     but none found
 ; [1]
Expected: lastName
     but none found
 ; [1]
Unexpected: first_name
 ; [1]
Unexpected: last_name

If we take a look at the response body, we will see the effect of the added @JsonNaming annotation. The naming of the fields has actually been changed from camelCase to snake_case. So the UserControllerJsonTest#shouldListAllUsers test failed correctly. Why the UserControllerPojoTest#shouldListAllUsers passed?

What happened?

The secret lies in this piece of code (or what is actually used underneath it):

1
2
3
4
5
        List<User> users =
                    // some code omitted
                    .extract().as(new TypeRef<>() {});

        assertEquals(expectedUsers, users);

Under the hood, RestAssured uses the same object mapper that Spring uses to serialize to JSON strings in endpoints. So the @JsonNaming(SnakeCaseStrategy.class) annotation affects both equally and the test passes. The test passes, reassuring us that everything is working properly and that we have not broken anything. Sadly, this is a lie! Any service that uses our API will stop working.

Of course, the case with the @JsonNaming annotation is just an example. The configuration that caused a problem in the project I was involved in was a bit more complex. Unfortunately, I do not have the code for it and have not been able to reproduce the situation. However, as you can see, the problem exists.

And that’s why I strongly encourage the use of raw JSON in endpoint testing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                // some code omitted
                .andExpect(content().json("""
                        [
                            {
                                "id": 1,
                                "firstName": "Walter",
                                "lastName": "White"
                            },
                            {
                                "id": 2,
                                "firstName": "Jesse",
                                "lastName": "Pinkman"
                            }
                        ]
                        """, true));

I know that sometimes the JSONs can be huge. That is the only small drawback. On the other hand, with raw JSONs, we get an extra benefit - tests that are great, living documentation of our endpoints.

So… this is the Way.

You can find the entire sample code on our GitHub.

Code using @JsonNaming(SnakeCaseStrategy.class) is on the snake_case branch.

Appendix: jsonPath

During the post review, Jakub rightly pointed out to me that I did not mention about an alternative for comparing entire JSONs which is the jsonPath matcher.

With jsonPath, the above test might look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @Test
    void shouldListAllUsersWithJsonPaths() throws Exception {

        // when
        mockMvc.perform(
                    get("/users")
                        .contentType(APPLICATION_JSON)
                )

                // then
                .andExpect(status().isOk())
                .andExpect(content().contentType(APPLICATION_JSON))
                .andExpect(jsonPath("$[0].id", is(1)))
                .andExpect(jsonPath("$[0].firstName", is( "Walter")))
                .andExpect(jsonPath("$[0].lastName", is("White")))
                .andExpect(jsonPath("$[1].id", is(2)))
                .andExpect(jsonPath("$[1].firstName", is("Jesse")))
                .andExpect(jsonPath("$[1].lastName", is("Pinkman")));
    }

Overall, I’m not a big fan of this approach. It looks a bit ugly and does not protect us from omitting certain fields, which is enforced when comparing entire JSONs in strict mode.

But that doesn’t mean I don’t use the jsonPath matcher at all. It’s useful, especially when I have some untested legacy endpoints that return huge JSONs with a lot of hard to mock generators, and I’m taking the first steps to introduce tests.

In this case, comparing whole JSONs is hard to do at first. But it is nice to introduce asserts for at least the most important fields.

This post is licensed under CC BY 4.0 by the author.