반응형

이번 시간에는 계층형 카테고리를 이어서 만들어보겠습니다.

 

지난 시간에는 service.category.CategoryService에서 readAll() 메소드가 어떻게 플랫한 구조의 엔티티 목록을 계층형 구조의 DTO 목록으로 변환하는지 살펴보았습니다.

소스코드는 다음과 같습니다.

@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class CategoryService {
    private final CategoryRepository categoryRepository;

    public List<CategoryDto> readAll() {
        List<Category> categories = categoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc();
        return CategoryDto.toDtoList(categories);
    }

    @Transactional
    public void create(CategoryCreateRequest req) {
        categoryRepository.save(CategoryCreateRequest.toEntity(req, categoryRepository));
    }

    @Transactional
    public void delete(Long id) {
        if(notExistsCategory(id)) throw new CategoryNotFoundException();
        categoryRepository.deleteById(id);
    }

    private boolean notExistsCategory(Long id) {
        return !categoryRepository.existsById(id);
    }
}

delete에 대한 설명은 생략하고, create 메소드에 대해서 조금 더 자세히 살펴보겠습니다.

 

 

// CategoryService.java

@Transactional
public void create(CategoryCreateRequest req) {
    categoryRepository.save(CategoryCreateRequest.toEntity(req, categoryRepository));
}

CategoryCreateRequest를 파라미터로 전달 받고,

스태틱 메소드 CategoryCreateRequest.toEntity를 통해 DTO를 엔티티로 변환하고 있습니다.

toEntity에는 CategoryRepository가 함께 전달됩니다.

해당 코드를 한 번 살펴보겠습니다.

 

 

dto.category.CategoryCreateRequest 입니다.

package kukekyakya.kukemarket.dto.category;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import kukekyakya.kukemarket.entity.category.Category;
import kukekyakya.kukemarket.exception.CategoryNotFoundException;
import kukekyakya.kukemarket.repository.category.CategoryRepository;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.util.Optional;

@ApiModel(value = "카테고리 생성 요청")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CategoryCreateRequest {

    @ApiModelProperty(value = "카테고리 명", notes = "카테고리 명을 입력해주세요", required = true, example = "my category")
    @NotBlank(message = "카테고리 명을 입력해주세요.")
    @Size(min = 2, max = 30, message = "카테고리 명의 길이는 2글자에서 30글자 입니다.")
    private String name;

    @ApiModelProperty(value = "부모 카테고리 아이디", notes = "부모 카테고리 아이디를 입력해주세요", required = false, example = "7")
    private Long parentId;

    public static Category toEntity(CategoryCreateRequest req, CategoryRepository categoryRepository) {
        return new Category(req.getName(),
                Optional.ofNullable(req.getParentId())
                        .map(id -> categoryRepository.findById(id).orElseThrow(CategoryNotFoundException::new))
                        .orElse(null));
    }
}

CategoryCreateRequest는 카테고리 이름(name)과 부모 카테고리의 id(parentId)를 필드로 가지고 있습니다.

API 문서 작성과 제약 조건을 위한 어노테이션들의 설명은 생략하겠습니다.

우리는 스태틱 메소드 toEntity를 집중적으로 살펴볼 것입니다.

 

public static Category toEntity(CategoryCreateRequest req, CategoryRepository categoryRepository) {
    return new Category(req.getName(),
            Optional.ofNullable(req.getParentId())
                    .map(id -> categoryRepository.findById(id).orElseThrow(CategoryNotFoundException::new))
                    .orElse(null));
}

카테고리의 부모는 없을 수도 있습니다. 루트 카테고리인 경우가 그렇습니다.

즉, parentId는 null일 수도 있습니다.

parentId가 null이라면, Category 생성자의 두번째 인자(부모 Category)도 null이어야 합니다.

parentId가 null이 아니라면, Category 생성자의 두번째 인자로 부모 Category를 지정해주어야합니다.

하지만 parentId에 해당하는 id가 없다면, CategoryNotFoundException 예외가 발생해야합니다.

이러한 로직을 간단히 구현하기 위해, Optional을 이용하였습니다.

 

ofNullable에 주어진 값이 null이라면, map은 수행되지 않습니다. orElse의 null이 반환됩니다.

ofNullable에 주어진 값이 null이 아니지만, 없는 카테고리 id라면, CategoryNotFoundException 예외가 던져집니다.

ofNullable에 주어진 값이 null이 아니지만, 있는 카테고리 id라면, 해당하는 Category가 반환되어 부모로 지정할 수 있습니다.

 

이러한 Optional의 작동 방식에 대해서 검증할 수 있도록 학습 테스트를 작성해보겠습니다.

test 디렉토리에서 learning 패키지에 OptionalTest를 작성해줍니다.

package learning;

import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class OptionalTest {

    @Test
    void doseNotInvokeOptionalInnerFunctionByOuterNullValueTest() { // 1
        // given, when
        Long result = Optional.ofNullable(null)
                .map(id -> Optional.ofNullable((Long)null).orElseThrow(RuntimeException::new))
                .orElse(5L);

        // then
        assertThat(result).isEqualTo(5L);
    }

    @Test
    void catchWhenExceptionIsThrownInOptionalInnerFunctionTest() { // 2
        // given, when, then
        assertThatThrownBy(
                () -> Optional.ofNullable(5L)
                    .map(id -> Optional.ofNullable((Long)null).orElseThrow(RuntimeException::new))
                    .orElse(1L))
                .isInstanceOf(RuntimeException.class);
    }
}

위에서 설명했던 Optional의 동작 방식을 검증하기 위한 간단한 테스트입니다.

1. ofNullable에 null이 주어진다면, map은 호출되지 않으며, 5L이 반환됩니다. map에서 RuntimeException을 던지도록 하였지만, 해당 예외는 던져지지 않았습니다.

2. ofNullable에 null이 아닌 값이 주어진다면, map은 호출됩니다. map에서 RuntimeException을 던지게 됩니다.

 

Optional의 동작 방식을 학습하고 테스트할 수 있었습니다.

 

 

아직 언급하지 않았었던,

exception.CategoryNotFoundException과 exception.CannotConvertNestedStructureException은 다음과 같습니다.

package kukekyakya.kukemarket.exception;

public class CategoryNotFoundException extends RuntimeException {
}
package kukekyakya.kukemarket.exception;

public class CannotConvertNestedStructureException extends RuntimeException {
    public CannotConvertNestedStructureException(String message) {
        super(message);
    }
}

 

자세한 설명은 생략하겠습니다.

 

해당 예외들도 어드바이스가 잡아낼 수 있도록 ExceptionAdvice에 등록해주겠습니다.

// ExceptionAdvice.java

@ExceptionHandler(CategoryNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Response categoryNotFoundException() {
    return Response.failure(-1010, "존재하지 않는 카테고리입니다.");
}

@ExceptionHandler(CannotConvertNestedStructureException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Response cannotConvertNestedStructureException(CannotConvertNestedStructureException e) {
    log.info("e = {}", e.getMessage());
    return Response.failure(-1011, "중첩 구조 변환에 실패하였습니다.");
}

CannotConvertNestedStructureException 예외는 사용자 요청이 원인이라고 볼 수 없습니다.

서버 애플리케이션에서 지정해둔 방식으로 조회된 데이터를 변환하는 와중에 발생하는 예외이기 때문입니다.

본질적인 문제는 서버의 잘못인 것입니다.

따라서 해당 예외는 상태 코드 500을 응답하도록 하고, 간단한 로그 메시지를 남겨주었습니다.

 

 

이제 CategoryService를 테스트해보겠습니다.

test 디렉토리에 동일한 패키지 경로 내에 CategoryServiceTest를 작성해줍니다.

package kukekyakya.kukemarket.service.category;

import kukekyakya.kukemarket.dto.category.CategoryCreateRequest;
import kukekyakya.kukemarket.dto.category.CategoryDto;
import kukekyakya.kukemarket.exception.CategoryNotFoundException;
import kukekyakya.kukemarket.factory.entity.CategoryFactory;
import kukekyakya.kukemarket.repository.category.CategoryRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static kukekyakya.kukemarket.factory.dto.CategoryCreateRequestFactory.createCategoryCreateRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class CategoryServiceTest {

    @InjectMocks CategoryService categoryService;
    @Mock CategoryRepository categoryRepository;

    @Test
    void readAllTest() {
        // given
        given(categoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc())
                .willReturn(
                        List.of(CategoryFactory.createCategoryWithName("name1"),
                                CategoryFactory.createCategoryWithName("name2")
                        )
                );

        // when
        List<CategoryDto> result = categoryService.readAll();

        // then
        assertThat(result.size()).isEqualTo(2);
        assertThat(result.get(0).getName()).isEqualTo("name1");
        assertThat(result.get(1).getName()).isEqualTo("name2");
    }

    @Test
    void createTest() {
        // given
        CategoryCreateRequest req = createCategoryCreateRequest();

        // when
        categoryService.create(req);

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

    @Test
    void deleteTest() {
        // given
        given(categoryRepository.existsById(anyLong())).willReturn(true);

        // when
        categoryService.delete(1L);

        // then
        verify(categoryRepository).deleteById(anyLong());
    }

    @Test
    void deleteExceptionByCategoryNotFoundTest() {
        // given
        given(categoryRepository.existsById(anyLong())).willReturn(false);

        // when, then
        assertThatThrownBy(() -> categoryService.delete(1L)).isInstanceOf(CategoryNotFoundException.class);
    }
}

자세한 설명은 생략하겠습니다.

 

 

factory.dto.CategoryCreateRequestFactory는 다음과 같습니다.

package kukekyakya.kukemarket.factory.dto;

import kukekyakya.kukemarket.dto.category.CategoryCreateRequest;

public class CategoryCreateRequestFactory {
    public static CategoryCreateRequest createCategoryCreateRequest() {
        return new CategoryCreateRequest("category", null);
    }

    public static CategoryCreateRequest createCategoryCreateRequestWithName(String name) {
        return new CategoryCreateRequest(name, null);
    }
}

자세한 설명은 생략하겠습니다.

 

 

CategoryCreateRequest에 지정해두었던 validation 제약 조건들도 테스트해주도록 하겠습니다.

CategoryCreateRequestValidationTest를 작성해줍니다.

package kukekyakya.kukemarket.dto.category;

import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import java.util.Set;

import static java.util.stream.Collectors.toSet;
import static kukekyakya.kukemarket.factory.dto.CategoryCreateRequestFactory.createCategoryCreateRequest;
import static kukekyakya.kukemarket.factory.dto.CategoryCreateRequestFactory.createCategoryCreateRequestWithName;
import static org.assertj.core.api.Assertions.assertThat;

class CategoryCreateRequestValidationTest {

    Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void validateTest() {
        // given
        CategoryCreateRequest req = createCategoryCreateRequest();

        // when
        Set<ConstraintViolation<CategoryCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isEmpty();
    }

    @Test
    void invalidateByEmptyNameTest() {
        // given
        String invalidValue = null;
        CategoryCreateRequest req = createCategoryCreateRequestWithName(invalidValue);

        // when
        Set<ConstraintViolation<CategoryCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByBlankNameTest() {
        // given
        String invalidValue = " ";
        CategoryCreateRequest req = createCategoryCreateRequestWithName(invalidValue);

        // when
        Set<ConstraintViolation<CategoryCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByShortNameTest() {
        // given
        String invalidValue = "c";
        CategoryCreateRequest req = createCategoryCreateRequestWithName(invalidValue);

        // when
        Set<ConstraintViolation<CategoryCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }

    @Test
    void invalidateByLongNameTest() {
        // given
        String invalidValue = "c".repeat(50);

        CategoryCreateRequest req = createCategoryCreateRequestWithName(invalidValue);

        // when
        Set<ConstraintViolation<CategoryCreateRequest>> validate = validator.validate(req);

        // then
        assertThat(validate).isNotEmpty();
        assertThat(validate.stream().map(v -> v.getInvalidValue()).collect(toSet())).contains(invalidValue);
    }
}

정상적인 케이스와 각 제약 조건의 예외 케이스를 검사해주었습니다.

 

* 코드 "c".repeat(50)은 자바 11 코드입니다. 본 프로젝트는 자바 11로 진행되고 있습니다.

 

 

 

이번 시간에는 CategoryService의 create를 살펴보면서, DTO를 entity로 변환하는 과정을 알아보고, 이에 대한 학습테스트를 진행하였습니다.

또, CategoryService, CategoryCreateRequest에 대해 테스트하였고, 새롭게 작성된 예외를 어드바이스에 등록해주었습니다.

 

다음 시간에는 웹 계층을 작성하며 카테고리 조회, 생성, 삭제에 관한 API를 생성하는 시간을 가져보도록 하겠습니다.

 

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts