1

I am noticing that out of the box JSON serialization does not work the same in production vs. unit tests. Things that work perfectly fine when I run the app with ./gradlew bootRun fail in unit tests. I'd like to understand why this is and how to make things work the same in both places without having to write custom configurations just to make tests emulate production.

To reproduce, I created a bare bones HelloWorld API server in Spring Boot 3.1.2 using Java 17 that I created with Spring Initializr. I'm using Lombok and Guava (and WebFlux which is probably not important for this question).

I have an API defined as such:

import lombok.*;
import com.google.common.collect.*;

@Builder
@Value
public class SearchResult {
  @Singular
  ImmutableList<String> results;
}

@RestController
@RequestMapping("/api/v1/search")
final class SearchController {
  @GetMapping(produces = "application/json")
  public Mono<SearchResult> search() {
    return Mono.just(SearchResult.builder().result("result1").result("result2").build());
  }
}

This works perfectly fine when I run ./gradlew bootRun. However things get wacky when I try to test this. My test looks like this:

@AutoConfigureWebTestClient
@SpringBootTest
final class SearchControllerTest {
  @Autowired private WebTestClient webTestClient;

  @Test
  public void search() {
    var response = webTestClient.get()
      .uri("/api/v1/search")
      .header("Content-Type", "application/json")
      .exchange();

    response.expectStatus().isOk();
    response.expectBody(SearchResult.class).isEqualTo(...);
  }
}

There are two things that will cause this test to fail. First an obscure error that goes away if I change ImmutableList to java.util.List. Second issue is that it says it cannot deserialize an object without a default constructor.

I can make this work by removing @Value and making this a Java record (because I want the object to be immutable) and by creating a custom object mapper only in my unit tests that register the GuavaModule explicitly like this:

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.guava.GuavaModule;

@Configuration
public class TestConfig {
  @Bean
  public ObjectMapper objectMapper() {
    var mapper = new ObjectMapper();
    mapper.registerModule(new GuavaModule());
    return mapper;
  }
}

But I don't want to do any of these things. I want to be able to have my tests use whatever ObjectMapper that's already being injected into my main production code. This will make my tests more realistic and let me use features that I'm apparently already able to use in the normal application. I'm also just curious why/how it's using different things in tests.

Update 1

I am now aware that serialization is only working as expected, but deserialization in main production code fails the same way as in my unit tests. I discovered this by adding a new endpoint to my SearchController from above.

  @GetMapping("/test")
  public Mono<SearchResult> test() {
    return webClient.get()
        .uri("http://localhost:8080/api/v1/search")
        .exchangeToMono(response ->
            response.statusCode().equals(HttpStatus.OK)
                ? response.bodyToMono(SearchResult.class)
                : response.createError())
        .flatMap(response -> Mono.just(response.toBuilder().result("result3").build()));
  }

I know how to fix the Guava issue with GuavaModule, but how to use lombok.Value annotation without an explicit default constructor?

Update 2

My last question was already answered here.

  • Why do you configure a separate objectmapper bean in your test config? Why not use the production configmapper from the production config? – knittl Aug 13 '23 at 08:45
  • *"just curious why/how it's using different things in tests"* Your test deserializes the response. The server only serializes. – Olivier Aug 13 '23 at 08:46
  • In my opinion, your test should only check the textual content of the response, nothing more. – Olivier Aug 13 '23 at 08:54
  • @knittl How do I use the production configmapper? I haven't added any special config changes here so far. – Baxter Freely Aug 13 '23 at 13:06
  • @Olivier I guess I should clarify. When I said things work correctly with `gradle bootRun` I mean that when I start my server and call it with postman or curl, everything deserializes perfectly fine without error. – Baxter Freely Aug 13 '23 at 13:07
  • 1
    @BaxterFreely When you use Postman or curl, there is no deserialization done on the server. – Olivier Aug 13 '23 at 13:24

2 Answers2

0

So the issue isn't that tests behave differently than main production code, the issue is that serialization worked but deserialization did not (both in tests and main).

The solution is to provide GuavaModule for the ImmutableList and to either use a Java record or add @NoArgsConstructor and @AllArgsConstructor to my lombok value object.

import com.fasterxml.jackson.datatype.guava.GuavaModule;

  @Bean
  public com.fasterxml.jackson.databind.Module guavaModule() {
    return new GuavaModule();
  }

  @Builder(toBuilder = true)
  @Value
  @NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
  @AllArgsConstructor
  public class SearchResult {
    @Singular
    ImmutableList<String> results;
  }

  // Alternative solution for immutability
  @Builder(toBuilder = true)
  public record SearchResult(
    @Singular
    ImmutableList<String> results,

    String name) {}
-1

When you start your application and call your endpoint within your own application, I suspect you get the same error?

You can add a default constructor, such as the lombok NoArgsConstructor, AllArgsConstructor and/or RequiredArgsConstructor to your SearchResult class.

That should resolve your deserialization issue.

Hammy1985
  • 50
  • 8
  • 1
    If you need to ask a question to provide the definitive answer it should go in a comment. – possum Aug 13 '23 at 11:52
  • @Hammy1985, you're right! I do not get any errors when I start my app and call my endpoint but I am realizing now, that only means _serialization_ is working. However, if I try to call my own endpoint from production code, it fails to _deserialize_ in the same way. So I guess my real question is, how do I deserialize with \@lombok.Value with no explicit no arg constructor? – Baxter Freely Aug 13 '23 at 13:40
  • @possum: I don‘t get your concern. The only reason for the question was to be polite and point the requester into the right direction. – Hammy1985 Aug 13 '23 at 18:09