본문 바로가기
Programming/TDD Project

Pull Request 016. Base Entity를 통해 데이터 생성일 / 수정일 설정하기 - 제어하기 어려운 코드 개선, static 메서드 mocking 테스트

by JKROH 2023. 11. 28.
반응형

 해당 기능을 넣을까말까 고민을 많이 했다. 그런데 아무리 생각해도 결국에는 필요한 기능이 아닐까 싶어서 넣기로 했다. 문제는 향로님의 글을 보며 시간에 대한 테스트를 어떻게 해야할까에 대한 고민이 남아있었다는 것이다. 그런데 향로님이 아예 예시 코드를 남겨주시기까지 했는데 못 할 것 있나? 싶은 생각에 빠르게 돌입했다.

 

 이번 포스팅에 앞서 이번 구현만큼은 TDD 방식으로 구현하지 못했음을 미리 알린다. 아예 새롭게 시도해보는 방법이었고, static메서드의 테스트도 처음 도전해보는 부분이라 거의 다른 분들의 코드를 따라하는 수준이었다. 참고한 글들은 글 말미에 남긴다.

 

 데이터 생성일과 수정일은 모든 엔티티에 적용되어야 한다. 따라서 모든 엔티티의 상위 엔티티가 될 Base Entity를 먼저 만들었다.

@MappedSuperclass
@Getter
@Setter
public class BaseEntity {

    @Column(name = "CREATED_AT")
    private LocalDateTime createdAt = LocalDateTime.now();

    @Column(name = "LAST_MODIFIED_AT")
    private LocalDateTime modifiedAt = LocalDateTime.now();
}

 

 Base Entity는 별도의 테이블을 필요로 하지 않는다. 따라서 @MappedSuperClass를 사용해 필요한 정보인 생성일과 수정일 칼럼만 넘겨준다.

 

 Response Dto도 수정해야겠다. 시간이 넘어가야하니깐 말이다.

public class FreeBoardDto {

    ...

    @Getter
    @Builder
    public static class Response{
        private long boardId;
        private String title;
        private String content;
        private String createdAt;
        private String modifiedAt;
    }

    ...
}

 

 이게 LocalDateTime을 그대로 넘기니 나오는 형태가 너무 보기 싫었다. 그래서 String 타입을 적용했고, LocalDateTime을 적절히 변형하기로 했다.

public FreeBoardDto.Response convertEntityToResponseDto(FreeBoard entity) {
    return FreeBoardDto.Response.builder()
            .boardId(entity.getId())
            .title(entity.getTitle())
            .content(entity.getContent())
            .createdAt(entity.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
            .modifiedAt(entity.getModifiedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
            .build();
}

 

 해당 DateTimeFormatter도 다른 분이 정리해놓으신 자료를 참고했다. 마찬가지로 글 말미에 링크를 남긴다.

 

 이제부터는 앞서 링크에 걸어둔 향로님의 글에 적용된 방법을 그대로 적용한다. 먼저 Time 인터페이스와 이들을 구현하는 클래스인 JodaTime 클래스, StubTime 클래스를 만들었다.

public interface Time {
    LocalDateTime now();
}

/////////////////////////

@Component
public class JodaTime implements Time{

    @Override
    public LocalDateTime now() {
        return LocalDateTime.now();
    }
}

/////////////////////////

@MockBean
public class StubTime implements Time {

    private final LocalDateTime currentTime;

    public StubTime(LocalDateTime currentTime){
        this.currentTime = currentTime;
    }

    public static LocalDateTime of(int year, int month, int day, int hour, int minute, int second){
        return LocalDateTime.of(year, month, day, hour, minute, second);
    }

    @Override
    public LocalDateTime now() {
        return this.currentTime;
    }
}

 

 StubTime의 경우, test 디렉토리에 넣었더니 @Component를 사용할 수 없었다. @MockBean애너테이션으로 사용하니 잘 되더라. 해당 애너테이션 간의 차이는 추후 공부해봐야겠다. 어떻게 완성은 했지만 아직 이해가 안되는 부분이다. 지금 생각으로는 Time을 구현한 @Component가 2개가 되면 Spring Context에서 충돌이 일어나는건가? 라고 생각된다.

 

 아무튼 Controller에 해당 Time을 적용하고, putFreeBoard(); 메서드를 수정하자.

public class FreeBoardController {

    private final FreeBoardService freeBoardService;
    private final Time time;

    public FreeBoardController(FreeBoardService freeBoardService, Time time) {
        this.freeBoardService = freeBoardService;
        this.time = time;
    }
    
    ...

    @PutMapping("/{free-board-id}")
    public ResponseEntity<FreeBoardDto.Response> putFreeBoard(@PathVariable("free-board-id") long freeBoardId,
                                                              @RequestBody FreeBoardDto.Put dto){
        FreeBoardDto.Response responseDto = freeBoardService.putFreeBoard(freeBoardId, dto, time.now());
        return new ResponseEntity<>(responseDto, HttpStatus.OK);
    }
    
    ...
    
}

 

 Time을 생성자 주입 받기 때문에, Application이 실행될 때 Spring Context에서 적절한 구현체인 JodaTime을 찾아 주입해준다. FreeBoardService#putFreeBoard()메서드에도 마찬가지로 상위 메서드가 호출된 시점의 시간이 들어갈 수 있게 수정했다. 

public interface FreeBoardService {

    ...

    FreeBoardDto.Response putFreeBoard(long id, FreeBoardDto.Put putDto, LocalDateTime modifiedAt);

    ...
}

/////////////////////////

@Service
public class FreeBoardServiceImpl implements FreeBoardService {

    private final FreeBoardConverter freeBoardConverter;
    private final FreeBoardCommandService freeBoardCommandService;
    private final FreeBoardQueryService freeBoardQueryService;

    public FreeBoardServiceImpl(FreeBoardConverter freeBoardConverter, FreeBoardCommandService freeBoardCommandService, FreeBoardQueryService freeBoardQueryService) {
        this.freeBoardConverter = freeBoardConverter;
        this.freeBoardCommandService = freeBoardCommandService;
        this.freeBoardQueryService = freeBoardQueryService;
    }

    ...

    @Override
    @Transactional
    public FreeBoardDto.Response putFreeBoard(long id, FreeBoardDto.Put putDto, LocalDateTime modifiedAt) {
        FreeBoard entity = freeBoardQueryService.readEntityById(id);
        freeBoardCommandService.update(entity, putDto, modifiedAt);
        return freeBoardConverter.convertEntityToResponseDto(entity);
    }

    ...
}

 

 

 전달된 시간은 죽죽 내려가서 최종적으로는 엔티티에서 해당 시간으로 modifiedAt을 수정한다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue(value = "FREE")
public class FreeBoard extends Board {

    @Builder
    public FreeBoard(String title, String content){
        this.setTitle(title);
        this.setContent(content);
    }

    public void modify(FreeBoardDto.Put putDto, LocalDateTime modifiedAt) {
        this.setTitle(putDto.getTitle());
        this.setContent(putDto.getContent());
        this.setModifiedAt(modifiedAt);
    }
}

 

 이제 이걸 테스트해야한다. 먼저, modify();부터 테스트하기로했다.

    @Test
    void testModify() {
        FreeBoard testEntity = FreeBoard.builder()
                .title("test")
                .content("content")
                .build();
        testEntity.setId(1L);

        FreeBoardDto.Put putDto = new FreeBoardDto.Put();
        putDto.setTitle("modifiedTitle");
        putDto.setContent("modifiedContent");

        try(MockedStatic<StubTime> modifiedAt = mockStatic(StubTime.class)){
            LocalDateTime fakeModifiedTime = LocalDateTime.of(2023, 11, 28, 22, 30, 10);
            given(StubTime.of(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt())).willReturn(fakeModifiedTime);

            testEntity.modify(putDto, fakeModifiedTime);
            assertThat(testEntity.getTitle()).isEqualTo(putDto.getTitle());
            assertThat(testEntity.getContent()).isEqualTo(putDto.getContent());
            assertThat(testEntity.getModifiedAt()).isEqualTo(fakeModifiedTime);
        }
    }

 

 기존의 modify()에서 많은 부분이 추가되지 않았다. 다만, StubTime의 정적 팩터리 메서드인 of()를 사용하기 위해 MockedStatic을 사용했다는 점에만 집중하면 되겠다. MockedStatic을 사용하기 위해선 아래와 같은 코드를 build.gradle의 의존성에 추가해주어야한다.

testImplementation 'org.mockito:mockito-inline:3.6.0'

 

 우리는 StubTime.of()메서드를 위해 원하는 시간을 직접 만들어냈다. 그리고 해당 시간을 수정 메서드에 투입했고 원하는 시간이 그대로 나옴을 확인할 수 있다.

 

 다음으로 FreeBoardCommandServiceTest의 수정 메서드 테스트를 수정하자.

    @Test
    void updateTest() {
        FreeBoard testEntity = FreeBoard.builder()
                .title("test")
                .content("content")
                .build();
        testEntity.setId(1L);

        FreeBoardDto.Put putDto = new FreeBoardDto.Put();
        putDto.setTitle("modifiedTitle");
        putDto.setContent("modifiedContent");

        try(MockedStatic<StubTime> modifiedAt = mockStatic(StubTime.class)){
            LocalDateTime fakeModifiedTime = LocalDateTime.of(2023, 11, 28, 22, 30 ,10);
            given(StubTime.of(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt())).willReturn(fakeModifiedTime);

            freeBoardCommandService.update(testEntity, putDto, fakeModifiedTime);

            assertThat(testEntity.getTitle()).isEqualTo(putDto.getTitle());
            assertThat(testEntity.getContent()).isEqualTo(putDto.getContent());
            assertThat(testEntity.getModifiedAt()).isEqualTo(fakeModifiedTime);
        }
    }

 

 사실 해당 메서드는 FreeBoard#modify();를 호출만 하는 메서드라 내용이 완전히 같다. 머쓱~

 

 마지막으로 ControllerTest를 수정하자.

@WebMvcTest(FreeBoardController.class)
@AutoConfigureRestDocs
class FreeBoardControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private Gson gson;

    @MockBean
    private FreeBoardServiceImpl freeBoardService;

    @MockBean
    private StubTime stubTime;

    ...

    @Test
    @DisplayName("정상적인 게시글 수정 요청 테스트")
    void putFreeBoardTest() throws Exception {
        // given
        String testTitle = "changedTitle";
        String testContent = "changedContent";
        long testId = 1L;

        FreeBoardDto.Put testPut = new FreeBoardDto.Put();
        testPut.setTitle(testTitle);
        testPut.setContent(testContent);

        FreeBoardDto.Response testResponse = FreeBoardDto.Response
                .builder()
                .boardId(testId)
                .title(testTitle)
                .content(testContent)
                .createdAt(LocalDateTime.now().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
                .modifiedAt(LocalDateTime.now().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
                .build();

        String content = gson.toJson(testPut);

        given(freeBoardService.putFreeBoard(anyLong(), any(), any())).willReturn(testResponse);

        // when // then
        mockMvc.perform(
                        put("/free-board/{free-board-id}", testId)
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                ).andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value(testPut.getTitle()))
                .andExpect(jsonPath("$.content").value(testPut.getContent()))
                .andDo(document("put-free-board",
                        getRequestPreProcessor(),
                        getResponsePreProcessor(),
                        pathParameters(
                                parameterWithName("free-board-id").description("자유 게시글 식별자")
                        ),
                        requestFields(
                                List.of(
                                        fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
                                        fieldWithPath("content").type(JsonFieldType.STRING).description("내용")
                                )
                        ),
                        responseFields(
                                List.of(
                                        fieldWithPath("boardId").type(JsonFieldType.NUMBER).description("게시글 식별자"),
                                        fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
                                        fieldWithPath("content").type(JsonFieldType.STRING).description("내용"),
                                        fieldWithPath("createdAt").type(JsonFieldType.STRING).description("작성일"),
                                        fieldWithPath("modifiedAt").type(JsonFieldType.STRING).description("수정일")
                                )
                        )
                ));
    }

    ...

 

 StubTIme stubTime @MockBean을 추가해주고, Response Dto의 수정 사항을 반영했다. 

 

 일단 이번에는 적절한 구현과 테스트 작성에만 집중했지만, 추후 공부해야할 부분들이 남았다.

  • 테스트하기 어려운 영역을 최대한 컨트롤러 영역까지 빼내는 연습
  • static 메서드 테스트와 관련한 공부
  • @MockBean vs @Component에서 왜 차이가 났었는지.

 우선은 이렇게 남겨본다. 사실 어려운 도전이 될거라 생각했는데, 구글은 신이고 무적이었다. 생각보다 쉽게 마무리했다. 그럼에도 부족한 부분은 추가 학습을 진행해서 매꿔보자.

 

반응형

댓글