Testing Java-Annotations of DTOs

How to test the effects of annotations in simple and fast unit tests

Michael Kutz
4 min readMar 7, 2024
Some code with the test pyramid in foreground and text stating that annotations should not be tested in the middle, but the base of the pyramid.

Annotations are not strictly code. However, usually they configure the framework to change its behaviour quite a lot. Hence, omitting or misusing annotations can cause sever bugs and hence their effects should be covered by unit tests.

One obvious way to test this is to start application and check its behaviour directly. This is valid and necessary as the framework’s behaviour is changed by a lot of things beside the annotations, e.g. the classpath, the application.yml/application.properties or other configuration files, configuration classes that may implicitly depend upon each other, …. FriendApiTest below is such an integration test using @SpringBootTest to start the application as a whole.

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, classes = LocalTestApplication.class)
class FriendApiTest {

@Value("http://localhost:${local.server.port}")
String baseUrl;

@Autowired WebTestClient webClient = WebTestClient.bindToServer().baseUrl(baseUrl).build();
@Autowired FriendRepository repository;

@BeforeEach
void clearDatabase() {
repository.deleteAll();
}

@Test
void post() {
var friendJson = aFriend().buildJson();

var actualResponse =
webClient
.post()
.uri("/friends/")
.header("Content-Type", "application/json")
.bodyValue(friendJson)
.exchange();

actualResponse.expectAll(
response -> response.expectStatus().isEqualTo(HttpStatus.CREATED),
response -> response.expectHeader().valueMatches("Location", baseUrl + "/friends/.+"),
response -> response.expectBody().json(friendJson));
}

@Test
void post_invalid_data() {
var invalidFriendJson = aFriend().invalid().buildJson();

var actualResponse =
webClient
.post()
.uri("/friends/")
.header("Content-Type", "application/json")
.bodyValue(invalidFriendJson)
.exchange();

actualResponse.expectAll(
response -> response.expectStatus().isEqualTo(HttpStatus.BAD_REQUEST),
response -> response.expectHeader().doesNotExist("Location"),
response -> response.expectBody().jsonPath("title", "Bad Request"),
response -> response.expectBody().jsonPath("detail", "Invalid request content."),
response -> response.expectBody().jsonPath("status", 400));
}
}

Unfortunately, starting he application usually takes quite some time and testing a lot of different cases on this level is probably not a good idea. Therefore, such tests should only cover very basic cases: usually one valid and one invalid.

As things like validation –e.g. via Jakarta bean validation annotations–, and JSON parsing –e.g. via Jackson– both should be tested thoroughly and with a lot of example cases, this project demonstrates some ways to work around the need to start the application.

In this article we’ll consider the following data transfer object (DTO) class FriendDto.

public record FriendDto(
@NotBlank @Pattern(regexp = "^\\p{L}[\\p{L}\\p{Zs}\\p{N}]+") String firstName,
@NotBlank @Pattern(regexp = "^\\p{L}[\\p{L}\\p{Zs}\\p{N}]+") String lastName,
@Email String email,
@Pattern(regexp = "^[0-9.+()/ ]+") String phoneNumber,
@NotNull @PastOrPresent @JsonFormat(pattern = "yyyy-MM-dd") LocalDate birthday) {

public FriendDto(Friend friend) {
this(
friend.firstName(),
friend.lastName(),
friend.email(),
friend.phoneNumber(),
friend.birthday());
}
}

It features two kinds of important annotations:

  • Jakarta bean validation annotations like @NotBlank, @Pattern, @Email, or @PastOrPresent that are used to validate the object upon retrieval
  • Jackson annotations like @JsonFormat to influence the parsing of JSON strings into a FriendDto or the marshalling of it in a JSON string.

Testing Bean Validation Annotations

The Jakarta bean validation annotations of FriendDto are evaluated as the FriendController annotates the parameter with @Valid @RequestBody. This causes Spring to validate the given object with a Validator before the controller method is even called. To verify these annotations, we rely on the basic cases in FriendApiTest above.

The Validator that is used by Spring is configured via ValidationAutoConfiguration. That class mostly takes care of how the validation messages are resolved, while the general validation logic is mostly left to defaults. So, as long as we don't make detailed tests on the generated validation message, we can simplify use Validation.buildDefaultValidatorFactory().getValidator() to create a Validator for our test.

In FriendDtoValidatorTest below there are two parametrized tests that easily could be extended with even more cases as the only run for a few millis.

class FriendDtoValidatorTest {

static Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

static Stream<FriendDto> validFriendDtos() {
return Stream.of(
aFriend()
.id(null)
.name("Somé", "Bødy Ⅲ")
.email("somebody@mkutz.github.io")
.phoneNumber("+49 (123) 555456")
.birthday(LocalDate.of(1982, 2, 19))
.buildDto(),
aFriend().birthday(LocalDate.now()).buildDto());
}

@ParameterizedTest(name = "{0}")
@MethodSource("validFriendDtos")
void validate_valid(FriendDto valid) {
assertThat(validator.validate(valid)).isEmpty();
}

static Stream<Arguments> invalidFriendDtos() {
return Stream.of(
arguments(aFriend().lastName("456").buildDto(), List.of("lastName")),
arguments(aFriend().firstName("123").buildDto(), List.of("firstName")),
arguments(aFriend().lastName("Bødy\nⅢ").buildDto(), List.of("lastName")),
arguments(aFriend().email("invalid").buildDto(), List.of("email")),
arguments(aFriend().phoneNumber("abc").buildDto(), List.of("phoneNumber")),
arguments(aFriend().birthday(LocalDate.now().plusDays(1)).buildDto(), List.of("birthday")));
}

@ParameterizedTest(name = "{0} invalid {1}")
@MethodSource("invalidFriendDtos")
void validate_invalid(FriendDto invalid, List<String> invalidPaths) {
assertThat(validator.validate(invalid))
.extracting(violation -> violation.getPropertyPath().toString())
.containsExactlyInAnyOrderElementsOf(invalidPaths);
}
}

Testing JSON/Jackson Annotations

To check if the annotations and configurations for JSON parsing work, we can simply create am ObjectMapper (or whatever the framework is using under the hood to parse JSON).

Spring does a lot more than this on initializing its own ObjectMapper (see JacksonAutoConfiguration). For example, it does the “auto-registration for all Module beans with all ObjectMapper beans (including the defaulted ones)”. This means that new ObjectMapper() produces a differently configured version then the one Spring provides and hence our tests won't produce exactly the same results as we will see in the framework.

However, we only want to demonstrate, that the annotations we use do what the should. So we can configure our own ObjectMapper the way our classes need it and trust in our few integration tests to detect any deviations due to configuration differences.

In the blow FriendDtoObjectMapperTest we simply can test two cases for the annotations of FriendDto

  1. writeValueAsString: writing the JSON representation of an object, and
  2. readValue: reading JSON into an object.
class FriendDtoObjectMapperTest {

static ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());

@Test
void writeAsString() throws JsonProcessingException {
var builder = aFriend();

var json = objectMapper.writeValueAsString(builder.buildDto());

assertThat(json).isEqualTo(builder.buildJson());
}

@Test
void readValue() throws JsonProcessingException {
var builder = aFriend();

var dto = objectMapper.readValue(builder.buildJson(), FriendDto.class);

assertThat(dto).isEqualTo(builder.buildDto());
}
}

Both tests only run for a couple of millis and hence a lot of variants could be tested.

Conclusion

The two simple unit tests cover all the functionality of the used annotations in the FriendDto class in a very effective way. Each case only runs for a few milliseconds.

The @SpringBootTest annotated FriendApiTest in the beginning of the article takes more than a second to start up and each case takes 5 to 10 times more time than the simple unit tests. It also has a lot more potential failure causes and hence its failure will be harder to interpret.

You can find all the code from this article in this GitHub repository.

--

--

Michael Kutz

I've been a software engineer since 2009, worked in various agile projects & got a taste for quality assurance. Today I'm a quality engineer at REWE digital.