vendredi 26 mai 2023

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

Aucun commentaire:

Enregistrer un commentaire