@ParemeterizedTest를 적극활용해 다양한 시나리오를 고려한 BDD 유닛 테스트 🔥

초록·2023년 11월 25일
0
post-thumbnail

날씨알리미 프로젝트에선 모킹과 TDD, BDD로 유닛 테스트를 실행했습니다.

테스트 중 제일 힘들었던 건 시나리오가 너무 많고 각각의 시나리오에 대해 테스트코드를 짜줘야하는 게 비효율적이고 힘들다 느껴졌습니다. 어떻게하면 더 편하게 쓸 수 있을까 하며 테스트방법에 대해 찾아보다가 @ParameterizedTest라는 걸 알게되었습니다.

@ParameterizedTest를 적극 활용해 다양한 시나리오의 테스트를 1개의 테스트 메서드로 테스트해 중복 코드를 줄이고 테스트 코드 관리를 쉽게 했습니다.

이번 글에선 @Parameterized의 구체적인 사용법을 다루진 않고, 제가 @ParameterizedTest를 활용한 예시를 보여드리겠습니다.

다양한 시나리오

아래 코드는 어떤 알림을 설정했느냐에 따라, 설정한 알림만 정확히 생성되는지 확인하는 테스트입니다.
테스트 메서드의 파라미터로, 테스트결과에 표시될 conditionName, 각 알림이 켜져있는지와 같은 조건값들, 이 테스트가 실패할지 성공할지에 대한 기대 결과값을 넣어줍니다. 파라미터로 전달된 조건값들에 따라서 모킹이 다르게 진행되게되고 결과도 달라집니다.

이렇게 다양한 시나리오의 테스트를 메서드 1개로 구현할 수 있게됩니다. 이는 테스트 추가와 관리를 훨씬 쉽게 만들어줍니다.

코드 아래쪽에 보면 실제 인자를 넣는 부분이 있습니다. 스트림 형태로 리턴하는 메서드 만들어주고, 테스트 메서드에 @MethodSource라는 애너테이션에 해당 메서드의 이름을 명시해줍니다.

	@ParameterizedTest(name = "{index} : 날씨 메시지 생성 - {0}")
    @MethodSource("generateMessageTestConditions")
    public void generateMessageTest(String conditionName, boolean hotNotiOn, boolean coldNotiOn, boolean rainNotiOn, User user, String expected){
        // GIVEN
        String hotMsg = hotNotiOn ? "더운 날 알림" : "";
        String coldMsg = coldNotiOn ? "추운 날 알림" : "";
        String rainMsg = rainNotiOn ? "비 알림" : "";
        String todayWeatherURL = "오늘의 날씨 URL";

        when(hotMessageGenerator.generate(any(User.class), any(WeatherInfoList.class))).thenReturn(hotMsg);
        when(coldMessageGenerator.generate(any(User.class), any(WeatherInfoList.class))).thenReturn(coldMsg);
        when(rainMessageGenerator.generate(any(User.class), any(WeatherInfoList.class))).thenReturn(rainMsg);
        when(weatherInfoService.getWeatherInfoListToday(any(WeatherRegion.class))).thenReturn(WeatherInfoListBuilder.build());

        // WHEN
        String result = weatherNotiGenerator.generateMessage(user);

        // THEN
        then(result).isEqualTo(expected);
    }

    private static Stream<Arguments> generateMessageTestConditions(){
        return Stream.of(
                Arguments.arguments("1개 지역, 3개 알림", true, true, true, UserBuilder.buildByOneRegion(), "서울 날씨 -----------\n\n더운 날 알림\n\n추운 날 알림\n\n비 알림"),
                Arguments.arguments("1개 지역, 2개 알림", true, true, false, UserBuilder.buildByOneRegion(), "서울 날씨 -----------\n\n더운 날 알림\n\n추운 날 알림"),
                Arguments.arguments("1개 지역, 1개 알림", true, false, false, UserBuilder.buildByOneRegion(), "서울 날씨 -----------\n\n더운 날 알림"),
                Arguments.arguments("1개 지역, 0개 알림", false, false, false, UserBuilder.buildByOneRegion(), ""),
                Arguments.arguments("2개 지역, 3개 알림", true, true, true, UserBuilder.buildByTwoRegion(), "서울 날씨 -----------\n\n더운 날 알림\n\n추운 날 알림\n\n비 알림\n\n부산 날씨 -----------\n\n더운 날 알림\n\n추운 날 알림\n\n비 알림")
        );
    }

실패에 대한 시나리오도 한 메서드에서

아래는 세션 인증에 대한 테스트 코드입니다.

액세스 토큰 값이 DB에 존재하는지 여부, 그리고 그랬을 때의 기대 결과를 인자로 받습니다.
액세스 토큰이 null이 아니며 DB에 존재해야만 인증이 성공하겠죠? 해당 시나리오를 아래쪽의 loginCheckFilterTestConditions 메서드에 Stream 형태으로 입력해놨습니다.

그리고 테스트 메서드인 loginCheckFilterTest를 보시면, WHEN은 성공과 실패 시나리오 모두 동일하게 진행하지만, THEN의 경우 기대결과가 성공이냐 실패냐에 따라 분기를 나눠 다른 테스트를 진행합니다. 성공을 기대하면 실제 결과가 성공응답이 맞는지, 실패를 기대하면 실제 결과가 실패응답인지를 확인합니다.

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class LoginCheckFilterTest {

    @Autowired
    MockMvc mvc;

    @MockBean
    SessionService sessionService;

    @Autowired
    ObjectMapper objectMapper;

    @ParameterizedTest(name = "{index} : {0}")
    @MethodSource("loginCheckFilterTestConditions")
    public void loginCheckFilterTest(String caseName, String accessToken, boolean existsByToken, boolean expectedTestResult) throws JsonProcessingException {
        // GIVEN
        doReturn(existsByToken).when(sessionService).existsByToken(any(String.class));
        ResultActions resultActions;
        AccessTokenDto accessTokenDto = new AccessTokenDto(accessToken);
        String requestJSON = objectMapper.writeValueAsString(accessTokenDto);

        // WHEN
        try {
            resultActions = mvc.perform(MockMvcRequestBuilders.get("/api/am_i_logged_in")
                    .contentType("application/json")
                    .content(requestJSON)
            );
            // THEN - SUCCESS
            if(expectedTestResult){
                then(existsByToken).isTrue();
                then(accessToken).isNotNull();
                resultActions
                        .andExpect(status().isOk())
                        .andExpect(jsonPath("status").value(HttpStatus.OK.value()))
                        .andExpect(jsonPath("data").value("로그인된 사용자가 맞습니다."));
            } else{
            // THEN - FAIL
                then(expectedTestResult).isEqualTo(false);
                resultActions
                    .andExpect(status().is(401))
                    .andExpect(jsonPath("status").value(HttpStatus.UNAUTHORIZED.value()))
                    .andExpect(jsonPath("data").doesNotExist())
                    .andExpect(jsonPath("errorResponse.errorCode").value(ErrorCode.NOT_LOGGED_IN.getCode()));
            }
        } catch (Exception e){
            e.printStackTrace();
        }

    }

    private static Stream loginCheckFilterTestConditions(){
        return Stream.of(
                Arguments.arguments("성공","access_token", true, true),
                Arguments.arguments("실패(미존재 토큰)","access_token", false,false),
                Arguments.arguments("실패(토큰 미포함 요청)", null, true, false),
                Arguments.arguments("실패(토큰 미포함 요청2)", null, false, false)
        );
    }

예외와 Mocking도 파라미터로 조정가능하다

이번엔 예외를 발생시키는 실패 시나리오를 보도록 하겠습니다. 이 또한 기대결과값을 기반으로 분기를 나눠 처리할 수 있습니다.

아래 코드는 세션정보가 DB에 있냐 없냐에 따라 예외를 발생시키는 테스트 코드입니다.

예외를 발생시키는 메서드를 테스트할 때는 의도한 예외가 발생되는지를 확인하는 게 중요합니다. 그래서 아래 코드에선 테스트의 파라미터에 예외의 클래스 정보를 넣어주고, 발생된 예외가 기대된 예외 클래스가 맞는지 확인합니다.

그리고 테스트의 파라미터에 넘겨진 '세션이 존재하는지 여부 값'에 따라 Mocking도 다르게 진행됩니다.

    @ParameterizedTest(name = "{index} : {0}")
    @MethodSource("getSessionElseThrowTestConditions")
    public void getSessionElseThrowTest(
            String caseName,
            SessionInfo found,
            boolean valueIsSaved,
            Class<BusinessException> expectedException
            ){

        // GIVEN
        String accessToken;
        if(valueIsSaved) accessToken = found.getAccessToken();
        else accessToken = "not_saved_token";
        
        // 세션정보가 DB에 있다는 시나리오일 땐 세션정보를 반환하도록 하지만, 없다는 시나리오일 땐 예외를 발생시킵니다.
        if (valueIsSaved) 
            lenient().when(cacheModule.getCacheOrLoad(any(String.class), any(String.class), any(Function.class))).thenReturn(found);
        else lenient().when(cacheModule.getCacheOrLoad(any(String.class), any(String.class), any(Function.class))).thenThrow(new SessionInfoNotExistsException());

        try {
            // WHEN
            SessionInfo result = sessionService.getSessionElseThrow(accessToken);
            // THEN - SUCCESS
            // 성공 시나리오라면 예외를 발생시키지 않습니다.
            then(expectedException).isNull();
            then(valueIsSaved).isTrue();
        } catch (BusinessException e){
            // THEN - FAIL
            // 실패 시나리오라면 기대한 예외가 실제 예외와 같은지 확인합니다.
            then(e.getClass()).isEqualTo(expectedException);
        }

    }

    private static Stream<Arguments> getSessionElseThrowTestConditions(){

        SessionInfo sessionInfo = SessionInfoBuilder.build();
        SessionInfo expiredSessionInfo = new SessionInfo.Builder("accessToken", 999L)
                .expirationTime(LocalDateTime.now().minusDays(1))
                .build();

        return Stream.of(
                Arguments.arguments("성공", sessionInfo, true, null),
                Arguments.arguments("실패 - 만료된 데이터", expiredSessionInfo, true, SessionInfoNotExistsException.class),
                Arguments.arguments("실패 - 존재하지 않는 데이터", null, false, SessionInfoNotExistsException.class)
        );
    }

마무리

@ParameterizedTest를 활용해 다양한 시나리오의 테스트를 쉽게 처리하는 걸 볼 수 있었는데요, 테스트 규모가 작을 땐 편하지만 너무 많은 시나리오를 한 메서드에 욱여넣게되면 테스트코드가 복잡해지는 경향이 있는 것 같으니 그 부분은 조심하셨으면 좋겠습니다.

모쪼록 여러분들이 @ParameterizedTest를 활용할 영감을 얻으셨으면 좋겠습니다.

감사합니다!

profile
몰입하고 성장하는 삶을 동경합니다

0개의 댓글