코드 훔쳐보는 변태 코더
춤 좋아하는 백엔드 개발자(였으면 좋겠다)
단위테스트 (1)
내가 기억하기 쉬우라고 작성한 단위테스트

단위테스트는 어떻게 진행될까?

  • 사람마다 다를 수 있으나 (?) 기본적으론 gwt (given & when & then)으로 진행된다.
    • Given ⇒ 어떠한 상황이 주어진다.
    • When ⇒ 어떠한 행동을 한다면
    • Then ⇒ 어떠한 행동이 돌아온다.
  • 단순히 이렇게 기억하면 쉽다.

사용되는 라이브러리

  • 보통 테스트는 비즈니스 로직 단위테스트, 컨트롤러 단위테스트, 리포지토리 단위테스트 이렇게 이뤄지는것 같다.
    • 비즈니스 로직 단위테스트에는 MockitoExtension을 사용한다.
    • 컨틀롤러 단위테스트에는 SpringBootTest 어노테이션을 달고 MockMvc 를 객체로 사용한다.
    • 리포지토리 단위테스트에는 DataJPATest 어노테이션을 달고 테스트한다.
  • 단순히 하나하나가 어떠한 행동들을 지원하는지는 나중에 알아보고, 어떻게 테스트해야지 성공적인 결과를 가져올 수 있는지 일단 행동해보자.

비즈니스 로직 단위테스트

테스트 클래스 생성

단순히 테스트 폴더에 테스트 클래스를 생성한다.

spring boot starter test 디펜던시를 추가하면 자동으로 junit 라이브러리까지 사용이 가능하다.

@DisplayName("비즈니스 로직 - 게시글")
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {

테스트를 원하는 클래스에 MockitoExtension 을 상속받아주고 어떠한 테스트를 진행할 것인지 @DisplayName으로 명시해 준다.

@InjectMocks private ArticleService sut;
@Mock private ArticleRepository articleRepository;

핵심이 되는 서비스클래스를 @InjectMocks 어노테이션을 사용하여 필드변수로 선언해 준다.

sut는 System Under Test 테스트 대상 시스템을 뜻한다.

그리고 해당 서비스 클래스에 주입되어야 하는 Repository 들을 @Mock 어노테이션을 사용해 주입시켜 준다.

(주축이 되는 객체에 주입되어야 하는 객체들을 하나라도 주입시키지 않으면 오류가 발생한다.)

테스트에 사용되는 메서드

  • given → 테스트가 진행되는 목업 객체가 가 어떠한 메서드를 호출할 때의 결과를 가정할 수 있다.
    • 리턴값이 있다면 목업 객체를, 예외가 발생해야 한다면 예외를 던져주는 등 상황을 지정할 수 있다.
      • 보통 테스트 대상 시스템의 비즈니스 로직 속 호출되는 메서드들에 대한 리턴값을 지정하는데 쓰인다.
  • when & then → 해당 비즈니스 로직을 호출했을 시에 어떠한 결과가 나와야 하는지를 명시한다.
    • when에는 예외가 발생되어야 하는 상황이나 메서드를 호출한다.
    • then 에는 AssertJ 라이브러리를 활용해 assertThat 메서드로 검증을 진행하거나 then() 메소드로 어떠한 메서드를 호출을 했는지 확인할 수 있다.

비즈니스 로직 단위테스트 코드 작성

  • 단위테스트 메서드의 네이밍은 given 주어진 것_when 어떠한 행동을 할 때_then 어떠한 것을 하거나 반납한다. 이런 느낌으로 작성해도 좋고, @DisplayName 어노테이션을 사용하여 따로 상황만 명시해줘도 된다.
  • 어떠한 행동을 했을 때 대상이 되는 엔티티에 변경사항이 존재하는지, 혹은 엔티티매니저가 어떠한 행동을 실행했는지 에 대한 상황을 가정 후 테스트를 진행할 수 있다.
  • 예외가 발생할 때는 Throwable 클래스의 정적 메서드인 catchThrowable()를 이용하여 람다식으로 예외를 캐치해 준다.
@Test
    @DisplayName("댓글 단건 조회시 없는 댓글을 조회하면 예외가 발생한다$")
    void givenNotExistArticleCommentId_whenGetAnArticleComment_thenThrowsException() {
        //given
        given(articleCommentRepository.findById(any())).willReturn(Optional.empty());

        //when
        Throwable throwable = catchThrowable(() -> sut.getArticleComment(1L));

        //then
        then(articleCommentRepository).should().findById(any());
        assertThat(throwable).isInstanceOf(EntityNotFoundException.class);
    }
  • 이때의 생각해볼 수 있는 상황들은 무엇이 있을까? 바로 단위테스트를 진행하는 목업 객체의 역할분담이다.
    • 사실 data repository 테스트를 따로 진행한 후 비즈니스 로직 테스트는 단순히 then에 해당 비즈니스 로직이 호출되었는지 를 확인할 수 있다.
    • 하지만 위에 작성해둔 코드로 실행시켜도 문제는 없다. 단순히 해당 계층의 테스트인데 다른 계층까지 점령해도 되느냐의 차이인 것 같다.
    • 더 세분화한다면 Data Repository 테스트 / 비즈니스 로직 테스트 / MVC테스트 이렇게 세분화하여 진행할 수 있지만 결국엔 비즈니스 로직에서는 해당 비즈니스 로직을 호출할 때 모든 repository 메서드를 호출할 것이며, MVC 테스트를 진행할 때도 리퀘스트를 보내면 컨트롤러가 비즈니스 로직을 호출하게 될 것이다.
      • 이에 대한 고민을 할 필요가 있으나, 현재 상황에서는 repository 자체에 여러 커스텀 메서드 (쿼리 dsl을 활용하거나 jpql을 활용해 직접 쿼리문으로 조회하는 메서드)가 존재하지 않기 때문에 비즈니스 로직에서 데이터계층까지 테스트하는 식으로 작성했다.

예외가 발생하지 않는 정상적인 상황에서의 테스트

@Test
    @DisplayName("getArticleCommet() - 댓글 단건 조회")
    void givenArticleCommentId_whenGetAnArticleComment_thenGetsArticleComment() {
        //given
        ArticleComment articleComment = createArticleComment(createArticle(createUserAccount()),createUserAccount());
        given(articleCommentRepository.findById(any())).willReturn(Optional.of(articleComment));

        //when
        ArticleComment.ArticleCommentDto articleCommentDto = sut.getArticleComment(articleComment.getId());

        //then
        then(articleCommentRepository).should().findById(any());
        assertThat(articleCommentDto).isNotNull();
    }
  • getArticleComment 메서드의 내용물로는 articleCommentRepository 가 findById로 댓글을 조회한 후 존재한다면 해당 댓글을, 존재하지 않는다면 EntityNotFoundException을 발생시키도록 되어있다.
  • given으로 상황을 만들어준다. 모키토는 여러 가지 메서드를 제공해주는데, 그중 any() 는 말그대로 어떠한 상황에서든지 willReturn() 메소드의 매개값을 리턴해준다.
  • when은 해당 시점을 뜻한다. 어떠한 메소드를 호출할 때 then에서 검증을 진행한다.
  • then에서는 assertThat() 메서드로 어떠한 엔티티를 영속시켰을 때 해당 레포지토리에 저장된 데이터의 수가 +1 이 되었는지 , 혹은 해당 엔티티 객체의 멤버변수에 변동사항이 있는지 까지 테스트가 가능하다.

이렇게만 작성하면 성공적인 테스트 결과를 얻을 수 있다.

 

이렇게 여러 상황을 지정할 수 있다.


컨트롤러 단위테스트

테스트 클래스 생성

컨트롤러 테스트도 마찬가지로 새 패키지를 생성해 테스트가 진행되는 클래스의 이름뒤에 test를 붙여서 클래스를 새로 생성한다.

여기에 쓰이는 어노테이션은

  • @AutoConfigureMockMvc
  • @SpringBootTest
  • *@Import*(*SecurityConfig*. class)

이렇게 어노테이션을 달아주면 된다.

스프링 시큐리티를 사용 중에 컨트롤러 테스트를 진행하면 시큐리티 설정도 같이 가져와야지 허튼짓을 하지 않고 테스트를 마무리 지을 수 있다.

private final MockMvc mvc;
    @MockBean
    private final SaveFileService saveFileService;
    private final ObjectMapper objectMapper;

    public SaveFileControllerTest(@Autowired MockMvc mvc, @Autowired SaveFileService saveFileService, @Autowired ObjectMapper objectMapper) {
        this.mvc = mvc;
        this.saveFileService = saveFileService;
        this.objectMapper = objectMapper;
    }

이렇게 필드변수로 MockMvc 객체를 주입해 주고, 본인의 입맛에 맞게 주입할 객체를 선언해주면 된다.

생성자에 꼭 Autowired 어노테이션을 달아주자.. 여기에 달아놓지 않으면 오류가 발생한다..

테스트에 사용되는 메서드

  • given → 비즈니스 로직 테스트와 마찬가지로 뷰에서 요청이 들어왔을 때 호출되는 메서드에 대한 리턴값이나 상황을 지정해줄 수 있다.
  • perform
    • get → GET 요청을 할 때 사용한다.
    • post → POST 요청을 할 때 사용한다. 위에 있는 objectMapper 객체로 dto를 json 형태로 변환해서 매개값으로 전달할 수 있다.
      • contentType → 데이터를 어떠한 타입으로 전송하는지를 명시한다.
      • cotent → 전달하는 데이터를 목업 데이터로 전송한다.
    • put → PUT 요청을 할 때 사용한다. 제공하는 메서드는 post와 같다.
    • delete → DELETE 요청을 할 때 사용한다.
      • 마찬가지로 delete 요청을 보내기 위해 전달되어야 하는 데이터들을 contentType과 content로 정의해 전달할 수 있다.
    • andExpect / andDo → 해당 요청을 보내면 어떠한 응답이 와야 하는지를 명시한다.
      • view → 어떠한 페이지로 이동이 된다든가.. 하는지를 명시한다.
      • model → 어떠한 모델이 attribute 되었는지를 명시한다.
  • 해당 메서드만 적절히 잘 사용하면 모든 테스트를 실패 없이 끝낼 수 있다.

컨트롤러 테스트 코드 작성

이전에 신경 써야 하는 부분이 있다.

  • 스프링 시큐리티를 적용한 프로젝트인데, 시큐리티 콘텍스트에 담긴 인증정보를 가져오는 메서드가 존재한다거나, authenticatedPrincipal 어노테이션을 사용하는 메소드가 존재할 때의 상황이다.
  • 이때를 대비해 @BeforeEach 어노테이션을 사용한 메서드를 선언해 각 테스트메서드 별로 호출 전 상황을 잡아줄 수 있다.
    • 계정을 생성해 놓는다던지, 글을 등록해놓는다던지 할 수 있다.
  • 상황을 잡아줬다면 @WithUserDetails 같은 @With~~ 어노테이션을 사용하여 해당 문제를 해결할 수 있다.
  • 만약 커스텀 필터를 구현하여 토큰을 사용하거나 하는 상황에서는 따로 어노테이션과 그에 대한 설정 클래스를 생성해 구현할 수 있다.
    • Spring Securit Test 디펜던시를 추가해야 사용이 가능하니 꼭 추가하자.
@DisplayName("[view][GET] 게시글 페이지 ")
    @Test
    public void givenNothing_whenRequestingArticlesView_thenReturnsArticlesView() throws Exception {
        //given
        given(articleService.searchArticles(eq(null), eq(null), any(Pageable.class))).willReturn(Page.empty());
        //when & then
        mvc.perform(get("/articles")).andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
                .andExpect(view().name("articles/index"))
                .andExpect(model().attributeExists("articles"));
        then(articleService).should().searchArticles(eq(null), eq(null), any(Pageable.class));
    }
  • 기본적인 GET 요청을 보냈을 때의 테스트코드이다.
  • @DisplayName에 마찬가지로 테스트 상황을 명시한다.
    • 이때 의문점은 메서드의 이름에 given 에 해당 메소드 필드 속 given 메소드의 내용을 명시해야 하는지, 아니면 실제 상황을 명시해야 하는지를 아직 정확히 이해하지 못했다.
  • perform() 메서드를 호출해 어떠한 url로 어떠한 요청을 보냈을 때, 어떠한 응답이 올 때 어떠한 것을 반환하는지를 명시해 준다.
  • 본인은 then에 해당 url로 요청을 보냈을 때 어떠한 비즈니스 로직이 호출되는지를 명시해 주었다.
    • 해당 부분도 솔직히 정답은 없을 거라 본다.. 각자 프로젝트를 진행할 때 정해둔 규칙에 따라서 작성하면 될 것 같다.
@DisplayName("[view][POST] 게시글 등록 ")
    @Test
    @WithUserDetails("test")
    public void givenArticleInfo_whenSavingArticle_thenSavesArticle() throws Exception {
        //given
        Article.ArticleRequest articleRequest = Article.ArticleRequest.builder()
                .title("haha421")
                .content("haha412")
                .fileIds("")
                .build();
        Article article = createArticle(createUserAccount());
        given(saveFileService.getFileDtosFromRequestsFileIds(any())).willReturn(new HashSet<>());
        given(articleService.saveArticle(any(), any())).willReturn(Article.ArticleDto.from(article));

        //when & then
        mvc.perform(post("/articles")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(articleRequest)))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN));
        then(saveFileService).should().getFileDtosFromRequestsFileIds(any());
    }
  • POST / PUT 요청을 보낼 때의 테스트코드이다.
  • ObjectMapper 객체를 활용해 json으로 변환 후 전달 할 수 있다.
  • given 에는 마찬가지로 해당 url로 요청을 보냈을 때 호출되는 비즈니스 로직 속 또 다른 메서드까지 반환값을 지정해줄 수 있다.
@DisplayName("[view][DELETE] 로그인이 되어있지 않은 상태로 게시글을 삭제하면 BAD_REQUEST를 반환한다.")
    @Test
    public void givenArticleId_whenTryingToDELETEArticle_thenReturnBadRequest() throws Exception {
        Map<String, String> articleId = Map.of("articleId", "1");
        //when&then
        mvc.perform(delete("/articles").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(articleId)))
                .andExpect(status().isBadRequest());
    }
  • DELETE 요청을 보냈을 때, 예외가 발생하는 상황을 담은 테스트코드이다.

 

이렇게 각 REST API 별로 상황을 만들어서 테스트가 가능하다.

 


마무리

테스트코드 작성하는 법을 배워보진 않았지만, 어쩌다 보니 규칙을 알게 되어 제 나름대로 정리해본 글입니다.

 

제가 직접 실행해보았을 때의 각각의 역할은 어느 정도 알게 되었지만, 해당 메서드들의 원리는 아직 알지 못합니다.

 

조언해주시거나 고쳐야 할 부분이 존재한다면 댓글 꼭 달아주세요! 감사합니다.

 

  Comments,     Trackbacks