[#04] Unit Test 도입기

전반숙·2024년 1월 5일
0
post-thumbnail

프리뷰

여지껏 작성한 로직들의 테스트 코드 작성 과정을 기록해 보려고 한다. 사실 지금까지 제대로 된 테스트 코드를 작성해본 적이 없어서, 테스트의 종류에는 무엇이 있는지, 어떤 목적으로 테스트를 하는지 잘 몰랐었다.

왜 테스트 코드를 짤까?
그 전에 테스트는 뭘까? -> 당연하게도 내가 짠 로직이 잘 돌아가나 확인하는 과정이다.

나는 여지껏 로컬에서 서버를 띄워서 화면이나 포스트맨으로 요청을 보내고 결과를 확인하는 식으로 테스트를 해왔다.

이렇게 하면서 느꼈던 불편함은 내가 테스트하려는 상황마다 값을 각각 바꿔서 넣어줘야하고, 그걸 눈으로 확인해야 한다는 것이었다. 그래서 시간도 오래 걸리고 실수도 잦았지만 테스트 코드 작성하는게 귀찮아서 그냥 내가 일일히 노다가로 테스트를 해왔다ㅎㅎ,,

지금까지는 프로젝트 규모도 작고 혼자 개발하다보니 내 시간을 넣어서 하면 그만이지만 이제부터라도 이런 비효율적인 짓을 그만해야한다.
이 기능, 이 메서드에 이런 입력값을 넣으면 어떤 반환값이 나온다라는 것을 검증해 놓으면 어디에서든 사용할 수 있는 생산적인 코드가 되고, 검증이 된 코드가 된다. 이제부터 그 작업을 시작해보자.



Controller Unit Test

Controller의 역할과 무엇을 검증할지 생각해보기

@PostMapping("/join")
@ResponseBody
public ResponseEntity<ResponseDto> join(@Validated @RequestBody MemberJoinDto memberJoinDto, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) {
        ErrorCode errorCode = ErrorCode.INVALID_INPUT;
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(new ResponseDto(false, errorCode.getReason(), null));
    }

    MemberJoinCommand memberJoinCommand =
            MemberCommandMapper.mapToMemberJoinCommand(memberJoinDto);
    memberService.join(memberJoinCommand);

    return ResponseEntity.status(HttpStatus.OK)
            .body(new ResponseDto(true, null, null));
}

컨트롤러의 회원가입 /join API 를 테스트해 볼건데 간단히 이 메서드의 로직을 살펴보면 다음과 같다.

  1. Http Request Body 회원가입 입력 내용을 validation 한 후 MemberJoinDto 객체로 바인딩
  2. 이때 검증 실패 시 400 응답
  3. 검증 성공 시 MemberJoinCommand 객체로 Mapping
  4. memberService.join(memberJoinCommand) 를 호출해서 회원 가입 비즈니스 로직 실행
  5. 해당 실행과정 중 예외 발생 시 @RestControllerAdvice 에서 예외 처리
  6. 성공 시 200 응답

여기서 간단히 정리하면 컨트롤러의 역할은 다음과 같다.

  1. 유저의 회원가입 입력값을 검증하고
  2. 서비스가 잘 처리할 수 있도록 데이터를 가공해서 넘겨주고
  3. 처리하지 못한 예외가 있으면 처리하고 (@RestControllerAdvice 에서)
  4. 적절한 응답을 내보는 것이다.

이 역할들만을 어떻게 잘 뽑아서 검증, 테스트할 수 있을까?


일단 통합테스트 해보기

우선 생각하기 머리 아프니까 단순하게 @SpringBootTest 로 프로덕션 코드와 거의 같은 환경을 만들어서 Http 요청을 보내고 의도한 대로 잘 동작하는지 살펴보자. 즉, 통합 테스트를 먼저 해보자

MockMvc 를 이용해서 모의 HTTP 요청을 보내 컨트롤러를 호출해봤다.

@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)
public class MemberIntegratedTest {

    @Autowired
    private MockMvc mockMvc;
    private final MemberJoinDto memberJoinDto = new MemberJoinDto();
    private final ObjectMapper objectMapper = new ObjectMapper();

    @TestConfiguration
    static class TestConfig {
    
        @Bean
        public DataSource dataSource() {
            return new EmbeddedDatabaseBuilder()
                    .setType(EmbeddedDatabaseType.H2)
                    .addScript("classpath:schema.sql")
                    .setScriptEncoding("UTF-8")
                    .build();
        }
    }

    @BeforeEach
    void beforeEach() {
        memberJoinDto.setLoginEmail("wjsdj2008@naver.com");
        memberJoinDto.setLoginPassword(Qweasdzxc1!");
        memberJoinDto.setLoginPasswordVerify("Qweasdzxc!");
        memberJoinDto.setCompanyName("SIT");
    }

    @Test
    @DisplayName("회원가입 폼 정상 입력")
    void joinFormReceive() throws Exception {
        mockMvc
                .perform(
                        MockMvcRequestBuilders
                        .post("/join")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(memberJoinDto))
                        .accept(MediaType.APPLICATION_JSON)
                )
                .andExpect(jsonPath("success").value(true))
                .andExpect(status().isOk());
    }
}

여기서 DataSource 빈은 테스트 환경에서 쓸 인메모리 DB를 설정하기 위함인데, 이는 통합 테스트이기 때문에 DB 접근까지 필요해서 설정했다.
원래 스프링부트 에서는 따로 DB에 관한 설정을 하지 않으면 자동으로 H2 인메모리 DB를 사용하고 test/resources/ 경로에 있는 schema.sql 파일을 기반으로 스키마를 설정한다고 알고 있는데, 이상하게 이 경로를 못찾아서 이렇게 수동으로 경로를 설정하는 dataSource를 추가해서 해결했다.(GPT의 힘으로,,) 나중에 이에 대해서 좀 더 알아봐야겠다.

그래서 테스트 결과는 어떨까?
알맞은 값들만 넣었기 때문에 테스트는 통과했다. 근데 성공 케이스 하나에 무려 3~4초가 걸렸다. 왜 그럴까? 그 이유는 이 회원가입 서비스 로직안에 인증 메일 전송 로직과 DB 접근 등 오래 걸리는 작업들이 포함되어 있기 때문이다.
실제로 테스트 케이스를 실행할때마다 이렇게 인증 메일이 쌓이고 있었다.
이것이 통합 테스트의 단점이다. 해당 기능에 대해 필요한 모든 계층의 모든 로직이 다 수행되어야 하다 보니 시간이 오래 걸리고, 테스트가 무거워진다.
그리고 컨트롤러 메서드의 역할을 테스트하는데에 필요없는 이런 인증 메일 프로세스와 DB 접근 프로세스까지 이루어지고 있다. 이것들을 모두 분리하여 컨트롤러의 역할만 테스트 하려면 어떻게 해야할까?


유닛 테스트로 가볍게 테스트하기

바로 컨트롤러의 역할과 관계없이 단순히 호출하는 객체에 가짜 객체를 주입해서 실제로 동작하지 않게 하고, 만약 결과값을 받아야 한다면 그 결과 값을 직접 지정해줌으로써 컨트롤러 만의 역할을 테스트할 수 있다. 이런 가짜 객체를 Mock 객체라고 한다.

그래서 MemberService 객체를 Mocking 하게 되면 해당 메서드에 가짜 객체가 들어가 DB접근, 인증 메일 전송과 같은 컨트롤러의 역할과 무관한 것들을 테스트에서 생략할 수 있게 된다.

@SpringBootTest
@AutoConfigureMockMvc(addFilters = false)
public class MemberIntegratedTest {

    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private MemberService memberService;
	
    ...
}

가짜 객체를 주입하는 방법은 간단하다. Mockito 프레임워크에서 제공하는 기능을 사용하면 되는데, 자세한 내용은 아래 블로그를 참고했다.
https://mangkyu.tistory.com/145

나는 위와 같이 MemberService@MockBean 을 붙여서 가짜 객체를 주입했다. 해당 메서드가 어떤 값을 반환하는지 지정해줄 수 있는데, 이 메서드는 타입이 void 이므로 그냥 아무 작업도 하지 않게 했다.
테스트가 얼마나 빨라졌을까? 확인해보자 약 100ms 로 기존의 3600ms 에서 무려 36배나 빨라졌다. 스프링이 띄워지는 시간이 대부분일테니 엄청 가벼워진 셈이다.


@WebMvcTest, Spring Security

여기서 한 발 더 나아가면 @SpringBootTest 는 해당 패키지 하위에 있는 모든 패키지에 있는 클래스에 ComponentScan을 하여 스프링 빈으로 등록하기 때문에, 컨트롤러와 관계 없는 @Service@Repository 같은 빈들도 같이 등록이 된다.
딱 봐도 낭비같아 보인다. 그래서 스프링 부트에서는 @WebMvcTest 애너테이션을 제공하는데, 이는 컨트롤러에 필요한 객체들만 스프링 빈으로 등록해준다. 아래 @WebMvcTest 관련 API 설명을 보면
https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/autoconfigure/web/servlet/WebMvcTest.html

...
(i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).
...
By default, tests annotated with@WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver).
...
If you are looking to load your full application configuration and use MockMVC, you should consider @SpringBootTest combined with @AutoConfigureMockMvc rather than this annotation.
...

  1. @Controller
  2. @ControllerAdvice
  3. @JsonComponent
  4. Converter/GenericConverter
  5. Filter
  6. WebMvcConfigurer
  7. HandlerMethodArgumentResolver

이렇게 컨트롤러 테스트에 필요한 빈들만 컨테이너에 등록해주고, 또한 스프링 시큐리티와 MockMvc도 자동으로 빈으로 등록해준다고 한다. 그리고 마지막에 전체 어플리케이션의 설정을 MockMVC와 사용하고 싶다면 @SpringBootTest 를 쓰라고 하고 있다.

처음에 이 API문서를 읽지 않고 ChatGPT와 구글링으로 찾다가 시큐리티 관련 문제의 원인을 찾느라 시간을 너무 허비했다. 어떻게 보면 처음으로 API문서의 설명을 읽어본 것 같은데, 역시 설명서부터 읽는 습관을 들여야겠다.

이 프로젝트는 현재 스프링 시큐리티가 적용되어 있는데, 때문에 처음에 계속 시큐리티 관련 오류가 발생했다. 그래서 디버깅을 해봤더니 내가 설정한 SecurityConfig와는 전혀 다른 시큐리티 필터체인이 등록되어있었다.

테스트 시에 자동으로 등록된 시큐리티 필터체인프로덕션 시 등록되는 커스텀 SecurityConfig 클래스 기반 필터체인

정확하게는 모르겠지만 필터체인에 BasicAuthenticationFilter 가 있는것으로 보아 HTTP Basic 필터체인이 자동으로 등록된 것 같다.

이 상태에서 발생하는 문제의 해결방법은 아래 블로그 글에 자세히 나와있는데, csrf와 권한을 잘 설정하면 해결이 되긴 된다.
https://velog.io/@cieroyou/WebMvcTest와-Spring-Security-함께-사용하기


하지만 나는 근본적으로 컨트롤러 테스트에 스프링 시큐리티가 적용되면 안된다고 생각해서 이들을 분리할 방법을 모색했다.
그런데 생각해보면 우리가 처음 스프링 시큐리티 의존성을 프로젝트에 등록했을 때 아무 설정도 하지 않았는데 기본으로 시큐리티 환경이 설정됐었다.
그 이유와 같은 이유가 아닐까해서 찾아봤다. 지금 테스트 환경에서도 내가 설정한 SecurityConfig 클래스는 빈으로 등록하지 않기 때문에 (@WebMvcTest) 기본 시큐리티 설정이 적용되었다면, 이 시큐리티 자동 설정을 꺼주면 해결될 일이다.

언제부턴가 스프링에 관련되서 검색을 해보면 baeldung 사이트의 글이 많이 보였는데, 정말 깔끔하게 잘 정리된 글이 많은 것 같다. 이번에 처음 제대로 읽어보고 도움을 받았다.
https://www.baeldung.com/spring-boot-security-autoconfiguration
이 글의 2,3번 챕터를 읽어보면 알 수 있는데, 원인은 바로 스프링 시큐리티 의존성을 추가할 때, SecurityAutoConfiguration 클래스가 포함되어 자동으로 기본 시큐리티 설정이 되는 것이었다. 그래서 테스트에서는 해당 클래스를 빈으로 등록되지 않게 제외하면 스프링 시큐리티 없는 온전한 컨트롤러만의 테스트를 할 수 있게 된다.

방법은 2가지이다.

  1. @AutoConfigureMockMvc(addFilters = false) 를 붙이거나

  2. @WebMvcTestexcludeAutoConfiguration = SecurityAutoConfiguration.class 를 넣어주면

자동으로 스프링 시큐리티가 적용되는 설정을 제외할 수 있다.

@WebMvcTest(
        controllers = MemberController.class,
        excludeAutoConfiguration = SecurityAutoConfiguration.class
)
class MemberControllerTest {

    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private MemberService memberService;
    @SpyBean
    private JoinValidator joinValidator;
    @SpyBean
    private ErrorCode.ErrorMessageInjector errorMessageInjector;

    private final MemberJoinDto memberJoinDto = new MemberJoinDto();
    private final ObjectMapper objectMapper = new ObjectMapper();

    @BeforeEach
    void beforeEach() {
        memberJoinDto.setLoginEmail("wjsdj2008@naver.com");
        memberJoinDto.setLoginPassword("Qweasdzxc1!");
        memberJoinDto.setLoginPasswordVerify("Qweasdzxc1!");
        memberJoinDto.setCompanyName("SIT");
    }

    @TestConfiguration
    static class MemberControllerTestConfig {
        @Bean
        public ErrorCodeRepositoryJdbcTemplate errorCodeRepository() {
            ErrorCodeRepositoryJdbcTemplate mockRepository = mock(ErrorCodeRepositoryJdbcTemplate.class);
            when(mockRepository.findByNameAndLocale(anyString(), anyString()))
                    .thenAnswer(invocation -> {
                        ErrorCodeDto errorCodeDto = new ErrorCodeDto();
                        errorCodeDto.setReason("test");
                        errorCodeDto.setHttpStatus(HttpStatus.BAD_REQUEST);
                        return Optional.of(errorCodeDto);
                    });
            return mockRepository;
        }
    }

    @Test
    @DisplayName("회원가입 폼 정상 입력")
    void join() throws Exception {
        // when
        ResultActions resultActions = mockMvc.perform(
                        MockMvcRequestBuilders
                                .post("/join")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(memberJoinDto))
                                .accept(MediaType.APPLICATION_JSON)
        );

        // then
        resultActions
                .andExpect(jsonPath("success").value(true))
                .andExpect(status().isOk());
    }
    
    ...
    
}

이렇게 해서 순수한 컨트롤러 로직만을 테스트하는 코드를 완성했다.
그런데 @SpringBootTest 와 비교해봤을때, 이론적으로는 적은 수의 Bean만을 등록하는 @WebMvcTest 가 처음 스프링을 띄울 때 더 빠를 줄 알았는데, 거의 비슷하거나 오히려 느리기도 했다. 너무 간단한 테스트여서 그런가? 근데 처음 세팅의 이점말고는 없을텐데,, 이부분은 아직 잘 모르겠다.

이렇게 컨트롤러의 역할인 입력값 검증, 적절한 응답, @ControllerAdvice 가 예외를 잘 잡아서 처리하는지를 모두 검증했다. 아주 가볍게? 테스트가 돌아가고 이런 식으로 모든 메서드들의 역할을 검증해나가면 될 것 같다.


추가로 위에 ErrorCodeRepositoryJdbcTemplate 빈을 @TestConfiguration 로 추가한 이유는 ErrorCode Enum 클래스에서 메시지와 상태코드를 관리하고, 이를 아래와 같이 초기화 시점에 DB에서 가져오고 있다. 그래서 해당 DB에 접근하는 로직을 담당하는 레포지토리 클래스를 모킹한 것이다. 근데 왜 @MockBean을 사용하지 않고 수동으로 빈을 정의하고 그 안에 설정 로직을 넣어줬을까? ErrorCode 클래스가 어떻게 DB에서 에러메시지 정보들을 초기화하는지 보면 알 수 있다.

@Getter
public enum ErrorCode {
    INVALID_INPUT,
    EXIST_MEMBER
    ;

    private String reason;
    private HttpStatus httpStatus;

    @RequiredArgsConstructor
    @Component
    public static class ErrorMessageInjector {

        private final ErrorCodeRepositoryJdbcTemplate errorCodeRepositoryJdbcTemplate;
        
        @Value("${error.message.locale}")
        private String locale;

        @PostConstruct
        public void postConstruct() {
            Arrays.stream(ErrorCode.values())
                    .forEach(errorCode ->
                            errorCodeRepositoryJdbcTemplate.findByNameAndLocale(errorCode.name(), locale)
                                    .ifPresent(errorCodeDto -> {
                                        errorCode.httpStatus = errorCodeDto.getHttpStatus();
                                        errorCode.reason = errorCodeDto.getReason();
                                    })
                    );
        }
    }
}

@SpyBean 으로 ErrorMessageInjector 빈이 등록되는 시점에 같이 postConstruct() 메서드가 실행되기 때문에, 그 전에 저 리포지토리를 미리 모킹해놔야 에러 메시지와 상태 코드를 초기화할 수 있다. 이렇게 안하면 또 컨트롤러 테스트와 상관없는 DB접근이 일어날 것이다.

아무튼 이 부분 때문에 테스트 코드를 짜기가 좀 불편했는데, 이 코드 구조가 테스트를 짜기 좋은 구조는 아닌 것 같은 생각이 든다. 이건 한번 고수분들의 의견을 들어보고 싶다.



마무리

우선 나는 테스트 코드를 완성시키는데에 초점을 맞추어서 진행해 봤는데, 이 하나를 테스트하기 위해서 뭔가 이것저것 하는게 많았다. 그 와중에 테스트 코드를 작성하기 좋은 코드, 즉 Testable 한 코드를 짜기 위한 방법 중 하나를 소개하는 글을 보게 되었는데, 지금 내가 딱 이 글의 예제의 Before에 해당하는 것 같다. 한 번 참고해서 나중에 리펙토링 해봐야겠다.
https://jojoldu.tistory.com/320

아무튼 오늘 나는 컨트롤러의 /join 메서드의 역할을 분명히 나누고 테스트 코드를 작성했다. 더 이상 포스트맨이나 서버를 띄워서 확인해볼 필요가 없는 확실히 검증된 하나의 메서드를 만들었다. 이런식으로 다른 기능, 메서드들도 테스트 코드를 통해 검증해나가면서 개발한다면 더 깔끔한 코드, 프로젝트가 되지 않을까 기대해본다.



이번 포스팅 소스 코드 링크
https://github.com/JP-company/smartwire-backend2/tree/02-1.login_spring_security

썸네일 출처
https://velog.io/@oneook/썸네일-메이커Thumbnail-Maker-Toy-Project

profile
과정 기록

0개의 댓글