Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

테스트 코드3 - Service와 Manager 계층이 분리되어 있을 때의 테스트 본문

스프링

테스트 코드3 - Service와 Manager 계층이 분리되어 있을 때의 테스트

쿠마냥 2024. 7. 2. 09:03

현재 작업 중인 프로젝트는 멤버와 파트너스로 크게 나뉜다. 이 두 서비스는 동일한 도메인(아이템, 브랜드 등)을 자주 공유하는데, 이를 아이템 서비스나, 브랜드 서비스에 몰아넣으면 이후 유지보수에 어려움이 생긴다. 더불어, 서비스 계층에 비즈니스 로직이 몰리는 문제를 해결하기 위해 매니저 계층을 도입하였다. 비즈니스 로직은 Service - Manager 클래스에 나눠서 배치되며, Repository와 직접적으로 연결되는 계층은 Manager 계층이다. 

 

예를 들어, 멤버가 브랜드를 조회하는 경우를 살펴보자. 이 경우의 흐름은 다음과 같다:

 

MemberBrandController - MemberService - ItemManager / BrandManager - ItemRepository / BrandRepository

 

// MemberBrandController.java
@RestController
@RequiredArgsConstructor
public class MemberBrandController {

    private final MemberService memberService;

    @GetMapping("/members/brands/{brandId}")
    public ResponseEntity<CommonResponse<BrandsForMemberResponse>> getBrandsForMember(
            @RequestParam (required = false, defaultValue = "NEW") SortCondition sort,
            @RequestParam (required = false, defaultValue = "DESC") Sort.Direction sortOrder,
            @PathVariable Long brandId,
            @RequestParam (required = false, defaultValue = "0") int page) {
        BrandsForMemberResponse brandsForMemberResponse = memberService.getBrandsForMember(sort, sortOrder, brandId, page, 50);
        return ResponseEntity.ok(CommonResponse.success(brandsForMemberResponse));
    }
}


// MemberService.java
@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final BrandManager brandManager;
    private final ItemManager itemManager;

	public BrandsForMemberResponse getBrandsForMember(SortCondition sort, Sort.Direction sortOrder, Long brandId, int page, int size) {
        Brand brand =brandManager.getBrand(brandId);
        Sort sortObj = itemManager.createItemSort(sort, sortOrder);
        Pageable pageable = PageRequest.of(page, size, sortObj);
        Page<Item> items = itemManager.getAllItemByBrandId(brandId, pageable);
        return BrandsForMemberResponse.of(brand, items.getContent());
    }
}


// BrandManager.java
@Component
@RequiredArgsConstructor
public class BrandManager {
    private final BrandRepository brandRepository;

    public Brand getBrand(Long brandId) {
        return brandRepository.findById(brandId)
                .orElseThrow(() -> new EntityNotFoundException("일치하는 브랜드가 없습니다."));
    }

    public boolean existByBrandName(String brandName) {
        return brandRepository.existsByName(brandName);
    }
}


// ItemManager.java
@Component
@RequiredArgsConstructor
public class ItemManager {
    private final ItemRepository itemRepository;

    public Page<Item> getAllItemByBrandId(Long brandId, Pageable pageable) {
        return itemRepository.findAllByBrandId(brandId, pageable);
    }

    public Sort createItemSort(SortCondition sort, Sort.Direction sortOrder) {
        String sortProperty = sort.getCondition();
        return Sort.by(sortOrder, sortProperty);
    }
}

 

이처럼 매니저 계층을 도입하여 비즈니스 로직을 분리할 경우 서비스 계층이 무척 읽기 쉬워진다. 

 

또한 테스트 코드가 간결해진다는 장점이 있는데, 이번에 테스트 코드를 작성하며 실제로 확인해 볼 수 있었다.

 
// MemberServiceTest.java
@Nested
    class getBrands {
        @Test
        void BrandsForMemberResponse반환_멤버_브랜드_조회() {
            // given
            Long brandId = 1L;
            SortCondition sort = SortCondition.NEW;
            Sort.Direction sortOrder = Sort.Direction.DESC;

            Brand mockBrand = mock(Brand.class);
            when(mockBrand.getName()).thenReturn("브랜드 이름");
            when(mockBrand.getImage()).thenReturn("브랜드 이미지");
            when(mockBrand.getDescription()).thenReturn("브랜드 설명");

            List<Item> mockItems = List.of(mock(Item.class), mock(Item.class));
            Page<Item> mockItemPage = new PageImpl<>(mockItems);

            // 브랜드 리턴 설정
            when(brandManager.getBrand(brandId)).thenReturn(mockBrand);
            when(itemManager.createItemSort(sort, sortOrder)).thenReturn(mock(Sort.class));
            
            /*
            이 부분은 아이템 매니저 테스트에서 확인하므로, 여기서는 스킵. 메서드가 호출되는지만 확인
            Sort sortObj = itemManager.getSortObject(sort, sortOrder);
            Pageable pageable = PageRequest.of(page, size, sortObj);
             */
             
            // Page<Item> 리턴 설정
            when(itemManager.getAllItemByBrandId(eq(brandId), any(Pageable.class))).thenReturn(mockItemPage);

            // when
            BrandsForMemberResponse response = memberService.getBrandsForMember(sort, sortOrder, brandId, 0, 50);

            // then
            verify(brandManager).getBrand(brandId);
            verify(itemManager).createItemSort(sort, sortOrder);
            verify(itemManager).getAllItemByBrandId(eq(brandId), any(Pageable.class));

            // 응답 존재 여부와 반환되는 아이템 수만 검증
            // 브랜드 정도만 좀 더 자세하게 확인한다. 
            assertThat(response).isNotNull();
            assertThat(response.brandName()).isEqualTo("브랜드 이름");
            assertThat(response.brandImage()).isEqualTo("브랜드 이미지");
            assertThat(response.brandDescription()).isEqualTo("브랜드 설명");
            assertThat(response.itemResponse()).hasSize(2);
        }
    }
 
Sort, Pegeable 처럼 ItemManager에서 처리하는 부분은 테스트를 스킵하고 핵심 기능인 브랜드가 잘 조회되는지만 확인한다. 
 
@ExtendWith(MockitoExtension.class)
@SuppressWarnings("NonAsciiCharacters")
public class ItemManagerTest {

    @InjectMocks
    private ItemManager itemManager;

    @Mock
    private ItemRepository itemRepository;

    private static Long brandId = 1L;
    private static int page = 0;
    private static int size = 50;

    @Nested
    class getBrand {

        @Test
        void 모든아이템반환_브랜드_조회() {
            // given
            Sort sortObj = Sort.by(DESC, SortCondition.NEW.getCondition());
            Pageable pageable = PageRequest.of(page, size, sortObj);

            Item item1 = mock(Item.class);
            Item item2 = mock(Item.class);
            Item item3 = mock(Item.class);

            Page<Item> itemPage = new PageImpl<>(Arrays.asList(item1, item2, item3));
            when(itemRepository.findAllByBrandId(brandId, pageable)).thenReturn(itemPage);

            // when
            Page<Item> result = itemManager.getAllItemByBrandId(brandId, pageable);

            // then
            assertThat(result).isEqualTo(itemPage);
            assertThat(result.getTotalElements()).isEqualTo(3);
        }

        @Test
        void 빈페이지반환_아이템이_없는_경우() {
            // given
            Sort sortObj = Sort.by(DESC, SortCondition.NEW.getCondition());
            Pageable pageable = PageRequest.of(page, size, sortObj);

            Page<Item> emptyPage = new PageImpl<>(Arrays.asList());
            when(itemRepository.findAllByBrandId(brandId, pageable)).thenReturn(emptyPage);

            // when
            Page<Item> result = itemManager.getAllItemByBrandId(brandId, pageable);

            // then
            assertThat(result).isEqualTo(emptyPage);
            assertThat(result.getTotalElements()).isEqualTo(0);
            assertThat(result.getContent()).isEmpty();
        }
    }

    @Nested
    class getSortObjectTest {

        @Test
        void Sort반환_정렬_조건_최신순() {
            // given
            SortCondition sortCondition = SortCondition.NEW;
            Sort.Direction sortOrder = Sort.Direction.DESC;

            // when
            Sort result = itemManager.createItemSort(sortCondition, sortOrder);

            // then
            assertThat(result).isEqualTo(Sort.by(sortOrder, sortCondition.getCondition()));
        }

        @Test
        void Sort반환_정렬_조건_가격오름차순() {
            // given
            SortCondition sortCondition = SortCondition.PRICE;
            Sort.Direction sortOrder = Sort.Direction.ASC;

            // when
            Sort result = itemManager.createItemSort(sortCondition, sortOrder);

            // then
            assertThat(result).isEqualTo(Sort.by(sortOrder, sortCondition.getCondition()));
        }
    }
}

레포지토리와 직접 연결되는 부분은 ItemManager에서 따로 관리한다. 한꺼번에 복잡하게 생각하는 것보다 훨씬 작성하기가 수월했으며, 테스트 코드 또한 간결해질 수 있었다.