STOVE DEVCAMP 3기/알림 서비스

[알림 서비스] 테스트 코드 작성

sw_develop 2023. 3. 10. 04:20

테스트 코드 작성 규칙을 세워 알림 서비스 구현 시 단위 테스트 코드 작성을 수행함

 

📌 어느 부분을 작성했는지

  • 알림 서비스 구현 시 클린 아키텍처를 도입하여 아래와 같이 구성함 - 참고) 클린 아키텍처 도입

  • 따라서 패키지 구조는 다음과 같음

  • 헥사고날 아키텍처를 기반으로 비즈니스 로직을 포함한 내부 영역과 외부 영역을 분리하였고, 이를 기반으로 단위 테스트 코드 작성 부분은 아래의 3가지로 설정
    • adapter/in 패키지의 Web Adatper
    • adapter/out 패키지의 Persistence Adapter
    • application 패키지의 Service

 

📌 어떤 방식으로 작성했는지

공통

  • Given-When-Then 패턴 사용
    • 테스트 코드를 작성하는 표현 방식 중 하나임
    • Given : 테스트 하기 위해 기본적으로 세팅하는 값
    • When : 테스트 하기 위한 조건 지정
    • Then : 테스트 하기 위한 행위가 우리가 예상하는대로 동작하는지 검증하는 행동 / 절차
  • 성공, 실패 테스트 케이스 작성

 

adapter/in 패키지의 Web Adatper

  • @WebMvcTest 사용
    • Application Context를 완전히 시작 시키지 않고, Web Layer에 대한 단위 테스트 수행을 위해 사용함
  • HTTP Request의 Param, Request Body에 대한 유효성 검사 수행

작성 예시 - 알림 수신 디바이스 등록 API 호출

@WebMvcTest(DeviceController.class)
public class DeviceControllerTest {
 
    @Autowired
    MockMvc mockMvc;
 
    @MockBean
    RegisterDeviceUseCase registerDeviceUseCase;
 
    @MockBean
    DeleteDeviceUseCase deleteDeviceUseCase;
 
    @MockBean
    UpdateNotificationSettingUseCase updateNotificationSettingUseCase;
 
    @MockBean
    GetNotificationSettingUseCase getNotificationSettingUseCase;
 
    @Test
    @DisplayName("디바이스 등록 성공")
    void registerDeviceSuccess() throws Exception {
        //given
        String requestBody = """
                {
                    "token": "clutter-token"
                }
                """;
 
        //when, then
        mockMvc.perform(post("/api/notification/device")
                        .header("userId", "clutter-user-id")
                        .content(requestBody)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("001"))
                .andExpect(jsonPath("$.message").value("성공했습니다."))
                .andExpect(jsonPath("$.data").isEmpty());
    }
 
    @Test
    @DisplayName("디바이스 토큰 값이 null인 경우 예외 반환")
    void throwExceptionIfTokenNull() throws Exception {
        //given
        String requestBody = """
                {
                    "token": null
                }
                """;
 
        //when, then
        mockMvc.perform(post("/api/notification/device")
                .header("userId", "clutter-user-id")
                .content(requestBody)
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().is4xxClientError())
                .andExpect(jsonPath("$.code").value("-401"))
                .andExpect(jsonPath("$.message").value("디바이스 토큰 값은 필수입니다."));
    }
}
  • @MockBean으로 의존성 주입을 받아 테스트 진행함

 

adapter/out 패키지의 Persistence Adapter

  • @DataMongoTest
    • MongoDB의 컴포넌트들을 중점적으로 테스트 가능
    • @Document를 스캔하여 MongoTemplate 생성
  • 실제 데이터베이스와 통신하여 로직이 예상과 동일하게 동작하는지 검증 수행

작성 예시 - 디바이스 조회 로직

@Disabled
@ActiveProfiles("test")
@DataMongoTest(excludeAutoConfiguration = MongoDBConfig.class)
@Import({DevicePersistenceAdapter.class,
        DeviceMapper.class,
        TokenMapper.class,
        DeviceMongoTemplate.class,
        MongoDBTestConfig.class})
class DevicePersistenceAdapterTest {
 
    @Autowired
    DevicePersistenceAdapter devicePersistenceAdapter;
 
    @Autowired
    DeviceRepository deviceRepository;
 
    @Autowired
    DeviceMongoTemplate deviceMongoTemplate;
 
    @AfterEach
    void cleanUp() {
        deviceRepository.deleteAll();
    }
 
    @Test
    @DisplayName("디바이스가 존재하는 경우 객체를 반환함")
    void findByUserIdAndTokenExist() {
        //given
        TokenDocument tokenDocument = TokenDocument.builder()
                .value("clutter-token")
                .timestamp(LocalDateTime.now()).build();
        DeviceDocument deviceDocument = DeviceDocument.builder()
                .userId("clutter-user")
                .pushNotificationOpt(true)
                .tokenDocument(tokenDocument).build();
        deviceRepository.save(deviceDocument);
 
        //when
        Device device = devicePersistenceAdapter.findByUserIdAndToken("clutter-user", "clutter-token");
 
        //then
        assertThat(device.getToken().getValue()).isEqualTo("clutter-token");
        assertThat(device.getUserId()).isEqualTo("clutter-user");
    }
 
    @Test
    @DisplayName("디바이스가 존재하지 않는 경우 null을 반환함")
    void findByUserIdAndTokenNotExist() {
        //when
        Device device = devicePersistenceAdapter.findByUserIdAndToken("clutter-user", "clutter-token");
 
        //then
        assertThat(device).isNull();
    }
 
    ...
}

 

application 패키지의 Service

  • JUnit
  • 내가 작성한 로직이 예상과 동일하게 동작하는지 검증 수행

작성 예시

  • 알림 목록 조회 시 사용되는 커서 페이지네이션 로직에 대한 검증 수행함
  • 분기문에 대한 테스트 케이스를 상세히 작성함
public class GetNotificationServiceTest {
 
    private final GetNotificationPort getNotificationPort = Mockito.mock(GetNotificationPort.class);
    private final GetNotificationService getNotificationService = new GetNotificationService(getNotificationPort);
 
    @Test
    @DisplayName("알림 데이터 리스트의 크기가 (요청 pageSize + 1)보다 작으면 hasNextPage=false")
    void hasNextPageFalseIfListSizeLtPageSize() {
        //given
        NotificationListQueryReq listQueryReq = NotificationListQueryReq.builder()
                .userId("clutter-user-id")
                .direction(true)
                .nextIndex(null)
                .pageSize(20).build();
 
        List<Notification> notifications = createNotificationList(20);
        given(getNotificationPort.findAllByUserId(listQueryReq)).willReturn(notifications);
 
        //when
        NotificationListQueryRes listQueryRes = getNotificationService.getNotificationList(listQueryReq);
 
        //then
        assertThat(listQueryRes.getNotifications().size()).isEqualTo(20);
        assertThat(listQueryRes.getNextIndex()).isEqualTo("-1");
        assertThat(listQueryRes.isHasNextPage()).isFalse();
    }
 
    @Test
    @DisplayName("알림 데이터 리스트의 크기가 (요청 pageSize + 1) 이상이고 아래 방향 스크롤이면, nextIndex=리스트의 마지막 원소의 id이고 hasNextPage=true")
    void hasNextPageTrueIfListSizeGtePageSizeWithDownScroll() {
        //given
        NotificationListQueryReq listQueryReq = NotificationListQueryReq.builder()
                .userId("clutter-user-id")
                .direction(true)
                .nextIndex(null)
                .pageSize(20).build();
 
        List<Notification> notifications = createNotificationList(21);
        given(getNotificationPort.findAllByUserId(listQueryReq)).willReturn(notifications);
 
        //when
        NotificationListQueryRes listQueryRes = getNotificationService.getNotificationList(listQueryReq);
 
        //then
        assertThat(listQueryRes.getNotifications().size()).isEqualTo(20);
        assertThat(listQueryRes.getNextIndex()).isEqualTo("notification-id1");
        assertThat(listQueryRes.isHasNextPage()).isTrue();
    }
 
    @Test
    @DisplayName("알림 데이터 리스트의 크기가 (요청 pageSize + 1) 이상이고 위 방향 스크롤이면, nextIndex=리스트의 첫번째 원소의 id이고 hasNextPage=true")
    void hasNextPageTrueIfListSizeGtePageSizeWithUpScroll() {
        //given
        NotificationListQueryReq listQueryReq = NotificationListQueryReq.builder()
                .userId("clutter-user-id")
                .direction(false)
                .nextIndex("notification-id1")
                .pageSize(20).build();
 
        List<Notification> notifications = createNotificationList(21);
        given(getNotificationPort.findAllByUserId(listQueryReq)).willReturn(notifications);
 
        //when
        NotificationListQueryRes listQueryRes = getNotificationService.getNotificationList(listQueryReq);
 
        //then
        assertThat(listQueryRes.getNotifications().size()).isEqualTo(20);
        assertThat(listQueryRes.getNextIndex()).isEqualTo("notification-id21");
        assertThat(listQueryRes.isHasNextPage()).isTrue();
    }
 
    ...
}