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.