통합 테스트 말고 단위 테스트만 하고 싶으면 어떻게 하죠?

Shinny·2022년 8월 16일
3

처음으로 Controller, Service, Repository 별로 격리된 환경에서 단위 테스트 코드를 작성하다보니 모든 게 낯설고 생소했습니다. 처음에는 어노테이션 하나, Mock 객체 주입 하나도 제대로 이해하지 못하고 사용하니 Failed to load Application Context 등 셀수 없는 오류를 만났습니다.
아래는 각 단위 테스트 코드를 작성하며 공부하고 이해한 내용을 정리한 것입니다.

🧪 Controller Test

@WebMvcTest(controllers = {BoardController.class})
    class BoardControllerTest {
    
        @Autowired
        MockMvc mockMvc;
    
        @MockBean
        @Autowired
        BoardService boardServiceMock;
    
        @Autowired
        WebApplicationContext was;
    
        @BeforeEach
        public void setUp() {
    				// 1번 - webAppContextSetup
    				this.mockMvc = MockMvcBuilders
                            .webAppContextSetup(was)
                            .build();
    
    				// 2번 - standaloneSetup
            this.mockMvc = MockMvcBuilders
                    .standaloneSetup(new BoardController(boardServiceMock))
                    .build();
        }
    
        @Test
        @DisplayName("GET : 전체 게시글 조회")
        void getPosts() throws Exception {
            mockMvc.perform(get("/posts"))
                    .andExpect(status().isOk())
                    .andExpect(view().name("board/posts"));
        }
    
        @Test
        @DisplayName("GET : 검색 게시글 조회")
        @WithMockCustomAccount
        void getSearchPosts() throws Exception {
            mockMvc.perform(get("/search"))
                    .andExpect(status().isOk())
                    .andExpect(view().name("board/posts"));
        }
    }

BoardController Class의 단위테스트 코드를 먼저 보자.

📗 WebMvcTest

스프링 공식문서를 보면 이 어노테이션에 대해 제대로 이해할 수가 있다.

Annotation that can be used for a Spring MVC test that focuses only on Spring MVC components.

Spring MVC 컴포넌트들에만 집중해서 MVC 테스트를 진행하고 싶을 때 사용하는 어노테이션이라고 한다.

Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests (i.e. @Controller@ControllerAdvice@JsonComponentConverter/GenericConverterFilterWebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component@Service or @Repository beans).

이 어노테이션을 사용하면 MVC 테스트에 필요한 설정, 빈 정보들만 가져오게 되고 통합테스트 처럼 전체 설정내용을 다 가져오지는 않는다. 예를 들어, Controller, WebMvcConfigurer 와 같은 컴포넌트들은 가져오지만 예를 들어 Controller 테스트에 필요없는 일반 Component, Service, Repository 빈들은 가져오지 않는다. (그래서 원래 Controller가 Service 의존성을 주입받고 있다면 MockBean을 붙여줘서 객체를 생성해줘야 하는 것이다.)

By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver). For more fine-grained control of MockMVC the @AutoConfigureMockMvc annotation can be used.

그리고 WebMvcTest 어노테이션이 붙은 테스트는 자동으로 Spring Security 와 MockMvc를 자동설정해준다고 한다. (그래서 http.antMatcher에 없는 즉 로그인 인증이 필요한 url의 경우 접근이 막혀 있는 것까지 테스트가 가능했다.)

Typically @WebMvcTest is used in combination with @MockBean or @Import to create any collaborators required by your @Controller beans.

그래서 위에 말했던 것처럼 Controller 관련 되지 않은 Bean들은 가져오지 않기 때문에 MockBean 이런 어노테이션과 함께 쓰인다고 한다.

If you are looking to load your full application configuration and use MockMVC, you should consider @SpringBootTest combined with @AutoConfigureMockMvc rather than this annotation.

만약에 애플리케이션 전체의 configuration 을 다 가져오고 싶다면 이 어노테이션 보다는 SpringBootTest 즉 통합 테스트 때 쓰이는 이 어노테이션 사용을 한번 고려해보라고 한다.

When using JUnit 4, this annotation should be used in combination with @RunWith(SpringRunner.class).

JUnit 4에서는 이 어노테이션은 @Runwith(SpringRunner.class)와 함께 쓰였다고 하는데 나는 JUnit 5를 사용했고, 해당없는 사항이었다.

어쨌든, 단위테스트의 목적으로 위와 같은 여러 사항들을 고려해서 WebMvcTest 어노테이션을 달아주었고 어떤 Controller를 테스트할 것인지도 옆에 적어주었다.

📗 MockMvc

MockMvc는 컨트롤러 테스트를 할 때, 실제 서버에 구현한 애플리케이션을 올리지 않고(실제 서블릿 컨테이너를 사용하지 않고) 테스트용 MVC환경을 만들어 요청 및 전송, 응답기능을 제공해주는 유틸리티 클래스다.

이 기능으로 실제 서블릿 컨테이너에서 컨트롤러를 실행하지 않고도 컨트롤러에 HTTP 요청을 할 수 있다.

📗 webAppContextSetup vs standaloneSetup

mockMvc를 설정하기 위해서는 MockMvcBuilder를 사용해야 하는데, standaloneSetup과 webAppContextSetup이라는 정적 메서드 중 하나를 선택해야 한다.

  • standaloneSetup : 테스트할 Controller를 하나 만들고 수동으로 종속성을 주입할 수가 있다.
  • webAppContextSetup : WebApplicationContext를 사용하여 MockMvc Context를 작성한다.

처음에는 standaloneSetup으로만 하면 충분할 거라고 생각했다.

// 2번 - standaloneSetup
            this.mockMvc = MockMvcBuilders
                    .standaloneSetup(new BoardController(boardServiceMock))
                    .build();

그래서 2번 방식으로 setup을 해주었는데 문제가 생겼다. 게시글 전체 조회 기능의 경우 로그인 하지 않은 사용자도 접근이 가능하기 때문에 테스트에 통과했는데, 나머지 기능의 경우 Authentication 정보를 가져와야 했다. 그러다 보니 1번 방식으로 바꿀 수 밖에 없었다.

// 1번 - webAppContextSetup
    			this.mockMvc = MockMvcBuilders
                        .webAppContextSetup(was)
                        .build();

📗 @WithMockCustomAccount

WithSecurityContextFactory 인터페이스를 상속받은 WithMockCustomAccountSecurityContextFactory 클래스를 하나 만들어주었다. 이를 통해 SecurityContext를 임의로 만들어주었고, 이를 적용한 Annotation을 하나 만들었다. 코드는 아래와 같다.

WithMockCustomAccountSecurityContextFactory

    public class WithMockCustomAccountSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomAccount> {
        @Override
        public SecurityContext createSecurityContext(WithMockCustomAccount annotation) {
            // 1
            SecurityContext context = SecurityContextHolder.createEmptyContext();
    
            // 2
            Map<String, Object> attributes = new HashMap<>();
            attributes.put("username", annotation.username());
            attributes.put("loginId", annotation.loginId());
            attributes.put("password", annotation.password());
    
            Member member = new Member((String) attributes.get("username"),
                    (String) attributes.get("loginId"),
                    (String) attributes.get("password")
            );
    
            // 3
            UserDetailsImpl principal = new UserDetailsImpl(member);
    
            // 4
            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                    principal,
                    principal.getAuthorities(),
                    Collections.emptyList());
    
            // 5
            context.setAuthentication(token);
            return context;
        }
    }

WithMockCustomAccount

    @Retention(RetentionPolicy.RUNTIME)
    @WithSecurityContext(factory = WithMockCustomAccountSecurityContextFactory.class)
    public @interface WithMockCustomAccount {
    
        String username() default "username";
    
        String loginId() default "loginId";
    
        String password() default "password";
    }

🧪 Service Test

    @ExtendWith(MockitoExtension.class)
    class BoardServiceImplTest {
    
        @InjectMocks
        BoardServiceImpl boardService;
        @Mock
        BoardRepository boardRepository;
        @Mock
        MemberRepository memberRepository;
        @Mock
        CommentRepository commentRepository;
    
        @Test
        void findPostAndComments() {
            // given
            Member member = new Member("username", "loginId", "password");
            Post post = new Post(100L, "title", "content", MainCategory.FRAMEWORK, SubCategory.DJANGO);
            post.setMember(member);
            Comment comment = new Comment(100L, "comment-content");
            comment.setMember(member);
            comment.setPost(post);
    
            // stub
            when(boardRepository.findById(100L)).thenReturn(Optional.of(post));
            when(memberRepository.findByUsername(member.getUsername())).thenReturn(Optional.of(member));
    
            // when
            PostAndCommentResponseDto dtoCorrect = boardService.findPostAndComments(100L, member.getUsername());
    
            // then
            assertThat(100L).isEqualTo(dtoCorrect.getId());
            assertThat(1).isEqualTo(dtoCorrect.getComments().size());
            assertThatThrownBy(() -> boardService.findPostAndComments(101L, member.getUsername())).isInstanceOf(CustomException.class);
        }
    }

📙 @ExtendWith(MockitoExtension.class)

Mockito의 Mock, InjectMocks 어노테이션을 사용하기 위해서 Mockito의 테스트 실행을 확장해주어야 한다.

  • injectMocks : @Mock이나 @Spy 객체를 생성해서 자신의 멤버 클래스와 일치하면 주입한다.
  • Mock : Mock 객체를 만들어서 반환한다. 실제 인스턴스가 아니라 가상의 인스턴스를 만든 것이다.

📙 Stub

보통 단위테스트에서는 격리된 단위 테스트 케이스를 작성하기 위해 의존하고 있는 다른 객체를 대체하여 미리 만들어진 응답 결과(canned answer)를 반환하게 해야 한다. 그래서 우리는 단위테스트에서 테스트 스텁을 사용한다. 쉽게 말해 스텁은 메소드의 결과를 미리 지정하는 것이다.

만일, 테스트에서 사용하지 않는 Stub이 있을 경우 Unnecessary stubbings detected. 에러가 발생했다.

🧪 Repository Test

    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    class BoardRepositoryTest {
    
        @Autowired
        BoardRepository boardRepository;
        @Autowired
        MemberRepository memberRepository;
        @Autowired
        CommentRepository commentRepository;
    
        @Test
        @DisplayName("ID & 게시글 작성자 일치여부 확인")
        void findByIdAndMember_Username() {
            // given
            Member member = new Member("username", "loginId", "password");
            memberRepository.save(member);
            Post post = new Post("title1", "content1", MainCategory.FRAMEWORK, SubCategory.DJANGO);
            post.setMember(member);
            Post savedPost = boardRepository.save(post);
            // when
            Post foundPost = boardRepository.findByIdAndMember_Username(savedPost.getId(), "username")
                    .orElseThrow(() -> new CustomException(Error.NO_AUTHORITY_POST));
            // then
            assertThat(post.getTitle()).isEqualTo(foundPost.getTitle());
            assertThatThrownBy(() -> boardRepository.findByIdAndMember_Username(savedPost.getId(), "wrong-username")
                    .orElseThrow(() -> new CustomException(Error.NO_AUTHORITY_POST)))
                    .isInstanceOf(CustomException.class);
        }
    }

📘 @DataJpaTest (아래는 스프링 공식문서 내용)

Annotation for a JPA test that focuses only on JPA components.

오직 JPA 컴포넌트들에만 집중해서 JPA 테스트용 어노테이션이다.

Using this annotation will disable full auto-configuration and instead apply only configuration relevant to JPA tests.

JPA 테스트와 관련한 설정들에만 적용되고 전체 설정 파일들을 다 불러오지는 않는다. 맥락은 위에서 본 WebMvcTest와 비슷하다고 보면 될 것 같다.

By default, tests annotated with @DataJpaTest are transactional and roll back at the end of each test. They also use an embedded in-memory database (replacing any explicit or usually auto-configured DataSource). The @AutoConfigureTestDatabase annotation can be used to override these settings.

기본적으로 @DataJpaTest라는 어노테이션이 붙어 있으면 Transactional이고 각 테스트 종료 후 롤백이 된다. 여기서는 인메모리 데이터 베이스를 사용한다고 하는데 이것때문에 에러가 나기도 해서, 나는 실제 내가 사용하는 MySQL 메모리로 테스트 하기위해 적힌 대로 @AutoConfigureTestDatabase를 써서 replace.None 값으로 변경해주었다.

📘 @AutoConfigureTestDatabase

실제 연결된 DB(MySQL)을 통해 테스트를 진행하려고 하면 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 을 붙여줘야 하는데 뭘 replace 하지 않겠다는 것인지 잘 몰라서 코드를 뜯어보았다.

우선 @DataJpaTest 를 보면 AutoConfigure를 해주는 부분이 상당히 많다.

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Inherited
    @BootstrapWith(DataJpaTestContextBootstrapper.class)
    @ExtendWith(SpringExtension.class)
    @OverrideAutoConfiguration(enabled = false)
    @TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
    @Transactional
    @AutoConfigureCache
    @AutoConfigureDataJpa
    @AutoConfigureTestDatabase
    @AutoConfigureTestEntityManager
    @ImportAutoConfiguration
    public @interface DataJpaTest {

이 중에서 @AutoConfigureTestDatabase 이 어노테이션을 살펴보면, default 값이 `Replace.ANY로 되어 있는 것을 알 수 있다. 만일,H2, DERBY, HSQLDB로 자동연결되고 싶지 않다면,@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)` 를 넣어주면 된다.

profile
비즈니스 성장을 함께 고민하는 개발자가 되고 싶습니다.

1개의 댓글

comment-user-thumbnail
2023년 9월 13일

이해하기 쉽게 작성해주셔서 큰 도움 되었습니다!!

답글 달기