Search code examples
javaspringtestingdesign-patternsdependency-injection

How do I make the "builder" design pattern and Spring dependency injection compatible with each other?


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 @Components. 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


Solution

  • 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.