토이 프로젝트 스터디 #12

appti·2022년 6월 23일
0

토이 프로젝트 스터디 #12

  • 스터디 진행 날짜 : 6/23
  • 스터디 작업 날짜 : 6/21 ~ 6/23

토이 프로젝트 진행 사항

  • 카테고리 응답 DTO 계층화 적용
  • 테스트 코드 작성

내용

NestedConvertUtils

public class NestedConvertUtils<K, E, D> {
    private List<E> entities;
    private Function<E, D> toDto;
    private Function<E, E> getParent;
    private Function<E, K> getKey;
    private Function<D, List<D>> getChildren;

    public static <K, E, D> NestedConvertUtils newInstance(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
        return new NestedConvertUtils<K, E, D>(entities, toDto, getParent, getKey, getChildren);
    }

    private NestedConvertUtils(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
        this.entities = entities;
        this.toDto = toDto;
        this.getParent = getParent;
        this.getKey = getKey;
        this.getChildren = getChildren;
    }

    public List<D> convert() {
        try {
            return convertInternal();
        } catch (NullPointerException e) {
            throw new CannotConvertNestedStructureException(e);
        }
    }

    private List<D> convertInternal() {
        Map<K, D> map = new HashMap<>();
        List<D> roots = new ArrayList<>();

        for (E e : entities) {
            D dto = toDto(e);

            map.put(getKey(e), dto);

            if (hasParent(e)) {
                E parent = getParent(e);
                K parentKey = getKey(parent);
                D parentDto = map.get(parentKey);
                getChildren(parentDto).add(dto);
            } else {
                roots.add(dto);
            }
        }
        return roots;
    }

    private List<D> getChildren(D d) {
        return getChildren.apply(d);
    }

    private E getParent(E e) {
        return getParent.apply(e);
    }

    private boolean hasParent(E e) {
        return getParent(e) != null;
    }

    private K getKey(E e) {
        return getKey.apply(e);
    }

    private D toDto(E e) {
        return toDto.apply(e);
    }
}
  • 다른 Entity도 적용할 수 있도록 Generic 활용
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
public class NestedCategoryResponse {

    private Long id;
    private String name;
    private long depth;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private List<NestedCategoryResponse> children;

    public static List<NestedCategoryResponse> fromEntity(List<Category> categories) {
        NestedConvertUtils utils = NestedConvertUtils.newInstance(
                categories,
                c -> new NestedCategoryResponse(c.getId(), c.getName(), c.getDepth(), new ArrayList<>()),
                c -> c.getParent(),
                c -> c.getId(),
                d -> d.getChildren()
        );

        return utils.convert();
    }
}
  • 위와 같이 DTO 변환 시에 NestedConvertUtils 활용


테스트 코드

카테고리 계층화 테스트

@ExtendWith(MockitoExtension.class)
@DisplayName("CategoryController 테스트")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class CategoryControllerTest {

    @Mock
    CategoryService categoryService;

    @InjectMocks
    CategoryController categoryController;

    ObjectMapper objectMapper = new ObjectMapper();
    MockMvc mockMvc;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders
                .standaloneSetup(categoryController)
                .alwaysDo(print())
                .build();
    }

    @Test
    void findAllCategories_테스트() throws Exception {
        // given
        Category category1 = CategoryEntityFactory.createCategory("a");
        Category category2 = CategoryEntityFactory.createCategory("b");
        Category category3 = CategoryEntityFactory.createCategoryWithParent("c", category1);

        setField(category1, "id", 1L);
        setField(category2, "id", 2L);
        setField(category3, "id", 3L);

        List<NestedCategoryResponse> response = CategoryDtoFactory.createFindAllCategoriesDto(List.of(category1, category2, category3));

        given(categoryService.findAll()).willReturn(response);

        // when
        ResultActions result = mockMvc.perform(
                get("/api/categories")
        );

        // then
        result
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.categories[0].id").value(1))
                .andExpect(jsonPath("$.categories[0].name").value("a"))
                .andExpect(jsonPath("$.categories[0].depth").value(0))
                .andExpect(jsonPath("$.categories[0].children[0].id").value(3))
                .andExpect(jsonPath("$.categories[0].children[0].name").value("c"))
                .andExpect(jsonPath("$.categories[0].children[0].depth").value(1))
                .andExpect(jsonPath("$.categories[1].id").value(2))
                .andExpect(jsonPath("$.categories[1].name").value("b"))
                .andExpect(jsonPath("$.categories[1].depth").value(0));
    }

    @Test
    void addCategory_테스트() throws Exception {
        // given
        CreateCategoryRequest request = CategoryDtoFactory.createCategoryRequestDto("a", null);

        willDoNothing().given(categoryService).save(any(CreateCategoryRequest.class));

        // when
        ResultActions result = mockMvc.perform(
                post("/api/categories")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request))
        );

        // then
        result
                .andExpect(status().isCreated());

        verify(categoryService).save(any(CreateCategoryRequest.class));
    }

    @Test
    void deleteCategory_테스트() throws Exception {
        // given
        willDoNothing().given(categoryService).delete(anyLong());

        // when
        ResultActions result = mockMvc.perform(
                delete("/api/categories/{id}", 1L)
        );

        // then
        result
                .andExpect(status().isOk());

        verify(categoryService).delete(anyLong());
    }
}
  • 위와 같이 카테고리 DTO 반환 시 계층형으로 출려되는지 테스트 적용

CategoryService Lambda 테스트 검증 누락

@Test
void save_성공_테스트() {
    // given
    CreateCategoryRequest request = CategoryDtoFactory.createCategoryRequestDto("a", null);
    Category category = CategoryEntityFactory.createCategory("a");

    given(categoryRepository.save(any(Category.class))).willReturn(category);

    // when
    categoryService.save(request);

    // then
    verify(categoryRepository).save(any(Category.class));
}

@Test
void save_부모_category_조회_실패_테스트() {
    // given
    CreateCategoryRequest request = CategoryDtoFactory.createCategoryRequestDto("a", 2L);
    
    given(categoryRepository.findById(anyLong())).willThrow(CategoryNotFoundException.class);

    // when, then
    assertThrows(CategoryNotFoundException.class, () -> categoryService.save(request));
}

  • Lambda로 작성한 코드 중 정상적으로 테스트 하지 못한 부분 발견
    • 부모가 없는 카테고리 테스트만 진행했으므로 부모가 있는 카테고리 테스트 필요
@Test
void save_parent_테스트() {
    // given
    Category parent = CategoryEntityFactory.createCategory("a");
    categoryRepository.save(parent);

    Category category = CategoryEntityFactory.createCategoryWithParent("b", parent);

    // when
    Category saveCategory = categoryRepository.save(category);

    // then
    assertEquals(category.getName(), saveCategory.getName());
    assertEquals(category.getParent().getName(), saveCategory.getParent().getName());
}

  • 해당 라인을 수행하는 테스트 코드 추가

UnnecessaryStubbingException

@ParameterizedTest
@CsvSource(
        value = {
                ":",
                "''"
        },
        delimiter = ':'
)
void addCategory_createCategoryRequest_name_notblank_검증_실패_테스트(String name) throws Exception {
    // given
    CreateCategoryRequest request = CategoryDtoFactory.createCategoryRequestDto(name, null);

    willDoNothing().given(categoryService).save(any(CreateCategoryRequest.class));

    // when
    ResultActions result = mockMvc.perform(
            post("/api/categories")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request))
    );

    // then
    result
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value(1003))
            .andExpect(jsonPath("$.message").value("이름을 입력해주세요."));
}
  • 위와 같이 테스트 코드를 작성했을 때 동작 결과는 다음과 같이 UnnecessaryStubbingException 발생
org.mockito.exceptions.misusing.UnnecessaryStubbingException: 
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
  1. -> at com.project.board.application.category.controller.CategoryControllerAdviceTest.addCategory_createCategoryRequest_name_notblank_검증_실패_테스트(CategoryControllerAdviceTest.java:84)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.
  • willDoNothing()을 제거하니 정상적으로 동작
  • 해당 테스트의 경우 Service 계층으로 가기 전 Controller에서 @Validated에 의해 바로 예외를 던지게 됨
    • Controller -> @Validated -> RestControllerAdvice로 동작
    • Service 계층의 코드가 동작하지 않기 때문에 무의미한 Stubbing이였고, 이를 Mockito가 감지한 것
    • 테스트 코드를 작성할 때 어느 계층의 코드가 동작하는지 정확히 판단해야 한다고 느낌

카테고리 테스트 진행도

  • 작성한 코드에 대한 모든 테스트 완료
profile
안녕하세요

0개의 댓글