How compatible are the "builder" design pattern and Spring dependency injection? Consider this code
@Test
@Sql(executionPhase = BEFORE_TEST_METHOD, value = BASE_SCRIPT_PATH + "GetCommentTest/before.sql") // inserting sample rows
@Sql(executionPhase = AFTER_TEST_METHOD, value = BASE_SCRIPT_PATH + "GetCommentTest/after.sql") // truncating
private void getPageOneDefaultTest() throws Exception {
MockHttpServletResponse response = mockMvc.perform(get(BASE_URI + "page/" + 1)
.header(HttpHeaders.AUTHORIZATION, token))
.andExpect(status().isOk())
.andReturn()
.getResponse();
/*
The whole idea with "expectation testers" may look a bit unusual, but if you consider that
I also have methods getPageOneSizeFiveTest(), getPageTwoSizeFiveTest() (probably, I
should add more of them for a better coverage), you should realize it removes a lot of
code duplication
*/
expectationTester = new GetCommentPageExpectationTester.Builder(response)
.setExpectedPageCount(10)
.setExpectedPageDtoListSize(10)
.setExpectedOwnerUsername("mickey_m")
.build();
expectationTester.test();
}
public class GetCommentPageExpectationTester implements ExpectationTester {
private final String serializedResponseBody;
private final int expectedPageCount;
private final int expectedPageDtoListSize;
private final int expectedFirstCommentId;
private final String expectedCommentText;
private final int expectedQuestionId;
private final int expectedOwnerId;
private final String expectedOwnerUsername;
private final ObjectMapper objectMapper;
private GetCommentPageExpectationTester(Builder builder) {
this.serializedResponseBody = builder.serializedResponseBody;
this.expectedPageCount = builder.expectedPageCount;
this.expectedPageDtoListSize = builder.expectedPageDtoListSize;
this.expectedFirstCommentId = builder.expectedFirstCommentId;
this.expectedCommentText = builder.expectedCommentText;
this.expectedQuestionId = builder.expectedQuestionId;
this.expectedOwnerId = builder.expectedOwnerId;
this.expectedOwnerUsername = builder.expectedOwnerUsername;
this.objectMapper = new ObjectMapper();
}
@Override
public void test() throws JsonProcessingException {
Data<Page<QuestionCommentResponseDto>> deserializedResponseBody =
objectMapper.readValue(serializedResponseBody, Data.class); // once I post it on CodeReview, you may comment on this unchecked cast
assertNotNull(deserializedResponseBody.getData());
Page<QuestionCommentResponseDto> page = deserializedResponseBody.getData();
assertEquals(expectedPageCount, page.getCount());
assertNotNull(page.getDtos());
List<QuestionCommentResponseDto> dtoList = page.getDtos();
assertEquals(expectedPageDtoListSize, dtoList.size());
QuestionCommentResponseDto dto;
for (int i = 1; i <= dtoList.size(); i++) {
dto = dtoList.get(i);
assertEquals(expectedFirstCommentId, dto.getId());
assertEquals(expectedQuestionId, dto.getQuestionId());
assertEquals(expectedCommentText, dto.getText());
assertNotNull(dto.getCreatedDate());
assertNotNull(dto.getModifiedDate());
AccountResponseDto actualOwner = dto.getOwner();
assertEquals(expectedOwnerId, actualOwner.getId());
assertEquals(expectedOwnerUsername, actualOwner.getUsername());
}
}
public static class Builder {
private final String serializedResponseBody;
private int expectedPageCount;
private int expectedPageDtoListSize;
private int expectedFirstCommentId = 1;
private String expectedCommentText = "text";
private int expectedQuestionId = 1;
private int expectedOwnerId = 1;
private String expectedOwnerUsername;
public Builder(MockHttpServletResponse response) throws UnsupportedEncodingException {
this.serializedResponseBody = response.getContentAsString();
}
public Builder setExpectedPageCount(int expectedPageCount) {
this.expectedPageCount = expectedPageCount;
return this;
}
public Builder setExpectedPageDtoListSize(int expectedPageDtoListSize) {
this.expectedPageDtoListSize = expectedPageDtoListSize;
return this;
}
// the rest of the setters are omitted
public GetCommentPageExpectationTester build() {
return new GetCommentPageExpectationTester(this);
}
}
}
When I call build()
, it invokes a private constructor and returns the instance of the top-level class copying the field values of the Builder
. Now, suppose I want ObjectMapper
autowired. I annotate both the top-level class and the Builder
as @Component
s. Then if I call build()
, ObjectMapper
isn't going to be injected, is it?, since the top-level class instance is going to be created with a simple constructor call, it's not coming from the Spring container, is it? Can I have a builder like that while also autowiring dependencies? While in this case I can simply initialize the ObjectMapper
field with a no-args constructor (hoping it's going to be the same ObjectMapper
as the autowired one), it may make sense for "expectation testers" that use an EntityManager
// most fields are omitted for brevity
@Component
public class GetCommentExpectationTester implements ExpectationTester {
@PersistenceContext
private EntityManager entityManager;
@Override
public void test() {
assertTrue(entityManager.createQuery("""
SELECT COUNT(qc.id) = 1
FROM QuestionComment qc
JOIN qc.owner ow
JOIN qc.question q
WHERE qc.createdDate IS NOT NULL
AND qc.modifiedDate IS NOT NULL
AND qc.text = 'text'
AND ow.id = 1
AND q.id = 1
""", Boolean.class)
.getSingleResult());
}
I can autowire a field, an ObjectMapper
or an EntityManager
for example, at the Builder
level. But then I won't be able to create a Builder
instance and pass a response right in a test method like that, will I?
I can also simply pass the autowired EntityManager
as a method argument (the test class has one) when creating a Builder
instance. But since reading Robert Martin's books, I am super-wary of passing anything. The less you pass, the better, that's my takeaway
To make ObjectMapper
can be autowired into a class , that class is required to be defined as the spring bean first.
For your case , it is more natural to define the Builder
as a prototype spring bean and auto-wire the ObjectMapper
into it :
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public static class Builder {
@Autowired
private ObjectMapper objectMapper;
public GetCommentPageExpectationTester build() {
return new GetCommentPageExpectationTester(this, objectMapper);
}
}
public class GetCommentPageExpectationTester implements ExpectationTester {
private GetCommentPageExpectationTester(Builder builder, ObjectMapper obhjectMapper) {
.........
this.objectMapper = objectMapper;
}
}
Then define the Builder
as a spring bean by @Import
:
@SpringBootTest
@Import(GetCommentPageExpectationTester.Builder.class)
public class FooTest {
@Autowired
private ObjectFactory<GetCommentPageExpectationTester.Builder> builder;
}
Note that as it is in the prototype scope, you have to use ObjectFactory
to inject it to the test such that whenever you use builder.getObject()
to get the builder instance , it will always ensure you will get a new instance.
By the way , feel like the purpose of ExpectationTester
is just for reusing the code to assert MockHttpServletResponse
which seem to looks complicated to me. Instead I will try to look into defining the custom AssertJ assertion to see if it will be more helpful.