[Spring Boot] Service 계층의 단위 테스트 코드 작성

hellonayeon·2021년 11월 30일
17
post-thumbnail
post-custom-banner

Service

게시물에 대한 비즈니스 로직을 수행하는 ArticleService 계층에는 게시물 생성 수정 메서드가 구현되어 있다. 게시물 본문 내용을 파라미터로 받아 이 데이터들을 바탕으로 게시물을 생성하고, 게시물 본문 내용게시물 아이디를 바탕으로 게시물을 수정한다. 실제 프로젝트 코드는 더 많은 수의 파라미터를 받고 여러 객체들과 의존 관계를 맺고 있지만 핵심 내용만 정리하기 위해 생략한다.

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository articleRepository;

    @Transactional
    public Article createArticle(String text) {
        return articleRepository.save(new Article(text));
    }

    @Transactional
    public Article updateArticle(Long id, String text) {
        Article article = articleRepository.findById(id).orElseThrow(
                () -> new NullPointerException(String.format("해당되는 아이디(%d)의 게시물이 없습니다.", id))
        );

        article.update(text);
        return articleRepository.save(article);
    }
}

ServiceTest

단위 테스트 코드를 작성하며 난감한 부분이 있었다. ArticleService는 다른 객체들과 의존 관계를 맺고 있는데 ArticleService 테스트를 어떻게 하는걸까..🤔 Bean Container에 주입된 실제 객체들을 가져다 쓰는 방법이 있고 Mock 객체를 만들어서 쓰는 방법이 있다. 두 방법은 Spring Boot 환경에 있는 객체를 가져와 테스트하는지, Mockito 클래스 객체를 이용해서 테스트하는지가 차이인 것 같다(?). 두 방법 모두 직접 구현해보고 차이를 정리해봐야겠다. 현재는 Mockito 프레임워크를 사용해서 의존성이 있는 객체들을 임의로 만들어서 테스트 코드를 작성했다.

가짜 객체임을 명시하기 위해 테스트 클래스에서 Mockito 클래스를 사용함을 알려주기 위해 @ExtendWith(MockitoExtension.class) 어노테이션을 붙여준다.

@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
	...
}

ArticleService 클래스와 ArticleRepository 클래스는 서로 의존관계에 있으며 ArticleService 내부의 로직을 수행하기 위해서는 ArticleRepository 클래스의 객체를 사용해야한다. 따라서 ArticleRepository 를 만들어두고 동작을 정의해준다.

@Mock 어노테이션을 통해 Mock 클래스로 생성해야하는 가짜 객체임을 지정한다.

@Mock
ArticleRepository articleRepository;

프로그램에서 작성한 실제 ArticleRepository와 달리 가짜로 만들어낸 Mock ArticleRepository 객체는 별다른 동작 기술 없이는 동작할 수 없다. 따라서 테스트 케이스마다 해당 함수 내부에서 이 객체가 어떻게 동작돼야 하는지 명시적으로 작성해줘야한다.

// 어떤 객체가 저장되든 테스트 케이스에서 생성한 객체를 리턴
when(articleRepository.save(any(Article.class))).thenReturn(article);

// 아이디가 입력된 경우 테스트 케이스에서 생성한 객체를 리턴 (Optional 클래스)
when(articleRepository.findById(id)).thenReturn(Optional.of(article));

// 아이디가 입력된 경우 널 포인트 예외를 발생
when(articleRepository.findById(undefinedId)).thenThrow(
                        new NullPointerException(String.format("해당되는 아이디(%d)의 게시물이 없습니다.", undefinedId)));

디버깅 모드로 Article 객체의 주소값과 ArticleRepository 객체의 주소값을 보면, ArticleService에서 save() 결과로 생성되는 Article 객체는 테스트 코드에서 작성한 객체와 동일하고 ArticleRepositoryMock 클래스에 의해 생성된 객체임을 확인할 수 있다.

처음에 Mock 객체가 어떻게 동작하는지 이해를 못해서 한참 헤맸었다. ArticleService 에서 새로운 게시물을 생성하기 위해 Article article = new Article(text);를 수행한다. 그래서 테스트 코드에서 ArticleRepository의 동작을 정의할때 save(article) 이런 식으로 테스트 코드에서 생성한 객체save 함수의 파라미터로 기술하면, 실제 ArticleService에서는 새로운 객체를 저장하기 때문에 테스트 코드에서 설정한 article과 다른 객체라 단위 테스트 결과가 올바르게 나오지 않는다. 그렇다면 어떤 객체가 들어가든 테스트 코드에서 비교하기 위해 생성한 Article 객체가 나오도록 설정해주면 되겠다 싶어서 수정했다. ArticleService에서 사용하는 ArticleRepository가 실제로 올바른 값을 리턴하는지는 생각할 필요 없다. 지금은 Service 단의 단위 테스트이기 때문에 Repository는 내가 정의한대로만 돌아가면 된다!

Repository의 동작을 잘못 정의한 코드

Article article = new Article(text, location, user);
when(articleRepository.save(article)).thenReturn(article);
assertEquals(result, article);

Repository의 동작을 올바르게 정의한 코드

when(articleRepository.save(any(Article.class))).thenReturn(null);

전체 코드

@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
    @Mock
    ArticleRepository articleRepository;

    @Nested
    @DisplayName("게시물 생성")
    class CreateArticle {
        private String text;

        @BeforeEach
        void setup() {
            text = "새로운 게시물 내용";
        }

        @Nested
        @DisplayName("정상 케이스")
        class SuccessCase {
            @Test
            @DisplayName("새로운 게시물 생성")
            void createArticleSuccess1() {
                Article article = new Article(text);

                when(articleRepository.save(any(Article.class))).thenReturn(article);

                ArticleService articleService = new ArticleService(articleRepository);
                Article result = articleService.createArticle(text);

                assertThat(result.getText()).isEqualTo("새로운 게시물 내용");
            }
        }

        @Nested
        @DisplayName("비정상 케이스")
        class FailCase {
            @Test
            @DisplayName("반환된 게시물이 NULL인 경우")
            void createArticleFail1() {
                when(articleRepository.save(any(Article.class))).thenReturn(null);

                ArticleService articleService = new ArticleService(articleRepository);
                Article result = articleService.createArticle(text);

                assertThat(result).isNull();
            }
        }
    }

    @Nested
    @DisplayName("게시물 수정")
    class ArticleUpdate {

        private Long id;
        private String text;

        @Nested
        @DisplayName("정상 케이스")
        class SuccessCase {

            @BeforeEach
            void setup() {
                id = 100L;
                text = "게시물 내용";
            }

            @Test
            @DisplayName("기존의 게시물 본문 내용 수정")
            void updateArticleSuccess1() {
                Article article = new Article(text);
                article.setId(id);

                when(articleRepository.save(any(Article.class))).thenReturn(article);
                when(articleRepository.findById(id)).thenReturn(Optional.of(article));

                String modifiedText = "* 수정된 게시물 내용 *";

                ArticleService articleService = new ArticleService(articleRepository);
                Article result = articleService.updateArticle(id, modifiedText);

                assertThat(result.getText()).isEqualTo(modifiedText);
            }
        }

        @Nested
        @DisplayName("비정상 케이스")
        class FailCase {

            @BeforeEach
            void setup() {
                id = 100L;
                text = "게시물 내용";
            }

            @Test
            @DisplayName("아이디에 해당되는 게시물이 없는 경우")
            void updateArticleFail1() {
                Article article = new Article(text);
                article.setId(id);

                Long undefinedId = 200L;

                when(articleRepository.findById(undefinedId)).thenThrow(
                        new NullPointerException(String.format("해당되는 아이디(%d)의 게시물이 없습니다.", undefinedId)));

                String modifiedText = "* 수정된 게시물 내용 *";

                ArticleService articleService = new ArticleService(articleRepository);
                Exception exception = assertThrows(NullPointerException.class, () -> {
                    articleService.updateArticle(undefinedId, modifiedText);

                });
                assertThat(exception.getMessage()).isEqualTo(String.format("해당되는 아이디(%d)의 게시물이 없습니다.", undefinedId));
            }

        }
    }
}

생각 정리

여태까지 막연하게 테스트 코드를 작성해야지 해야지 했으나 실제로 작성해본건 이번이 처음이다. 프로그램을 작성하다보면 이게 왜 안되지? 이게 왜 되지? 라는 말을 하곤한다. 이런 혼란스러운 일을 방지하기 위해 내가 작성한 프로그램이 내가 의도한 대로 작동을 잘 하는지 확인하기 위해 테스트 코드 를 작성한다.

처음에 테스트 코드를 작성하는 이유는 안정성 있는 프로그램을 만들기 위해서라고 거창하게 생각했다. 그랬더니 실제로 코드를 작성하면서 의문을 많이 가졌다. 당연히 이런 입력을 넣었을 때 이런 결과가 나오는데 왜 이런식으로 테스트 코드를 작성해야하지? 라는 생각이 없지않아 있었다. 그리고 단위 테스트를 위한 코드를 작성하면서 하나의 함수에 대한 테스트 케이스들을 작성하는데 어떤 값을 검사해야 성공이라고 할 수 있지? 실패 케이스를 테스트 성공으로 만든다라.. 함수가 정상 동작 하지 않는 경우를 어떤 경우라고 정해야할까? 내가 어디까지 임의의 객체를 만들어줘야 하는걸까? 등등 머릿속이 뒤죽박죽이었다🤪

결론적으로 우리가 정하기 나름 이란 것을 깨달았다. 왜? 테스트는 내가 생각한 경우의 수 대로 프로그램이 작동하는지 확인하기 위함이니까! 어떤 값을 함수로 넘겨줬을때 함수 안의 로직이 성공적으로 수행되서 원하는 결과값을 받았는지, 함수가 예외를 만나 온전히 동작하지 못했다면 정해둔 예외가 제대로 발생했는지, 즉, 이런이런 상황들에는 프로그램이 이렇게 동작하기를 원해 라고 작성해 놓은 것이 테스트 코드이다.

어찌보면 위와 같이 "너무 당연한 결과를 내놓으라 하는 테스트가 왜 필요할까?" 생각이 들기도 했습니다. 게다가 그 당연한 결과값도 이러이러할 것이다 라는 것을 우리가 값을 직접 넣어줘야 했구요.

그러나 앞서 썼듯이 우리가 "당연히 그렇게 될 것이다" 라고 기대한 결과가 그렇지 못했을 때, 어느 부분에 이상이 있어 발생한 것인지 잘 눈치채기 힘듭니다. 특히나 초기에 만들었던 코드는 정상 동작했지만 리팩토링이나 최적화 등의 이유로 코드를 수정했더니 더 이상 올바르게 동작하지 않을 때 더 그렇습니다.

그렇기 때문에 실행 결과우리가 생각했던 당연한 결과와 일치하는지 매번 프로그램을 빌드하기 전에 한 번 검증하는 단계를 집어넣음으로서 오류 발생률을 저하시킬 수 있는 것입니다.

sanyoni. "[Spring] 단위 테스트, 통합 테스트란?", 산요니의 개발새발

내가 고민하던 문제들을 싸악 해결해주는 친절한 글을 보게 되어 첨부한다🙏

테스트 코드에 대해 갈 길이 멀어보이지만 일단 여기까지 정리를 해두고 코드를 지속적으로 작성해보며 익혀나가려 한다. 하다보면 테스트 코드도 좀 정갈해지리라 믿는다..🙄 그리고 함수 수행 시 일어날 수 있는 시나리오들을 충분히 생각해보자. 기능을 구현하기에 앞서 또는 기능을 구현하면서 이런 경우도 있겠다 저런 경우도 있겠다 싶은 것들을 정리해두고 테스트 코드로 작성하자.

참고문서

📌 sanyoni. "[Spring] 단위 테스트, 통합 테스트란?", 산요니의 개발새발, 29 Mar 2021.

📌 CheonHee-Park. "SpringBoot Service/Repository 단위 테스트",
Jimin's Daddy, 20 May 2021

post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 10월 2일

고민의 내역을 상세히 공유해줘서 고맙습니다. 많이 배우고 갑니다 :)

답글 달기