이번에 서비스 레이어 테스트 코드를 작성하면서 겪었던 고민과 해결 과정을 작성해보려고 합니다.

주의
저도 아직 배우는 과정으로 잘못된 부분이 있을 수 있습니다. 혹시라도 잘못된 부분을 발견하시면 피드백 부탁드립니다.

서비스 레이어 역할

먼저 제가 생각하는 서비스 레이어의 역할을 간단하게 정리해봤습니다.

  • 컨트롤러에 대한 프로바이더
  • 도메인을 연결, 협업 및 흐름 제어
  • 실제 비즈니스 로직은 도메인에 위임
  • 리포지터리로부터 도메인 객체를 구한다
  • 트랜잭션 관리 주체

과연 서비스 레이어는 어디까지, 얼마나 테스트해야 할까? 고민이 되었습니다.

'블로그 보니까 Mockito를 사용하라는데?'
'음.. 이거 짜인 시나리오대로 테스트가 진행되니까 성공이 당연한 거 아냐?'
'Mockito 잘 모르겠고 그냥 아는 방식으로 해보자!' 일단 무작정 작성했습니다.

기존의 테스트 코드

@SpringBootTest
class ArticleServiceTest {

    @Autowired
    ArticleService articleService;

    private Article article;

    @BeforeEach
    void setUp() {
        article = Article.builder()
                .contents("contents")
                .coverUrl("coverUrl")
                .title("title")
                .build();

        article = articleService.save(article);
    }

    @Test
    void 게시글_조회() {
        assertThat(articleService.findById(article.getId())).isNotNull();
    }

    @Test
    void 존재하지_않는_게시글_조회_예외처리() {
        assertThrows(IllegalArgumentException.class, () -> articleService.findById(100L));
    }

    ...

} 

작성하고 나서 보니

  • 난 서비스 테스트를 하는데 repository까지 테스트 하는 거 같은데?
  • 이것은 단위테스트가 아니라 통합테스트 아닌가?
  • 컨트롤러에서 인수테스트를 하는데 굳이 여기서도 굳이 해야 하나?

이런 생각이 들면서 이 테스트는 잘 못되었다는 생각이 들었습니다.
그래서 서비스 레이어 테스트에 대해서 자료를 찾다가 스프링 개발팀 Pivotal에서 쓴 Spring Boot Testing best practices를 읽어봤습니다.

image.png

이 글에서 가장 눈에 띄었던 부분은 MockitoF.I.R.S.T 였습니다.
그리고 추가로 Service Layer는 Persistence Layer 연결 없이 테스트할 수 있어야 한다는 사실도 알았습니다.

단위테스트 F.I.R.S.T 원칙

단위테스트를 잘 짜기 위한 원칙으로 로버트 C. 마틴의 클린코드에서 확인할 수 있습니다.

F - Fast (빠르게)

테스트는 빨라야 한다. 테스트가 느리면 자주 돌릴 엄두를 못 낸다. 자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.

I - Independent (독립적으로)

각 테스트는 서로 의존하면 안 된다. 각 테스트는 독립적으로 그리고 어떤 순서로 실행해도 괜찮아야 한다. 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 순겨진다.

R - Repeatable (반복가능하게)

테스트는 어떤 환경에서도 반복 가능해야 한다. 실제환경, QA 환경, 버스를 타고 가는 집으로 가는 길에 사용하는 노트북 환경에서도 실행할 수 있어야 한다.
반복 가능한 테스트는 외부 서비스나 리소스같은 항상 사용 가능하지 않은 것에 의존하지 않는다. 네트워크, 개발 서버의 네트워크 환경에 산관 없이 실행한다. 단위 테스트는 외부 시스템을 테스트하지 않는다.

S - Self Validating (자가검증하는)

테스트는 bool값으로 결과를 내야 한다. 성공 아니면 실패다. 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.

T - Timely (적시에)

테스트는 적시에 작성해야 한다. 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다. 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다. 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.

단위테스트를 위한 Mockito 사용

Mockto란?

목 객체를 만들어주는 프레임워크로 테스트하는 데 유용하다.
목 객체란 테스트를 진행할 때 해당 코드 외의 의존하는 객체를 가짜로 만든 것을 지칭한다. 이러한 목 객체는 테스트하고 싶은 코드에 대해서 정확하게 테스트하기 위해서 사용된다.

Mockito에 대한 사용 방법은 아래 링크를 추천합니다.

왜, 언제 Mockito를 사용하는가?

  • 통제된 방식으로 클래스의 기능을 독립적으로 테스트하기 위해서
  • 실제 객체는 느릴 수 있다. (네트워크, 데이터베이스 연결 같은).
  • 객체에는 많은 종속성이 있으며, 이는 보통 구성 및 인스턴스화가 복잡하다.
  • 코드가 아직 코딩되지 않고 인터페이스만 존재할 경우
  • 코드가 부작용을 발생시키는 경우 (예 : 호출 시 전자 메일을 보내는 코드)
  • 해당 코드가 아닌 의존관계에 있는 다른 코드에서 오류가 나는 경우

테스트 코드 리팩토링

그래서 제 코드에 Mockito를 적용했습니다.

Mockito 적용

@Service
@Transactional
public class ArticleService {
    private final UserService userService;
    private final ArticleRepository articleRepository;

    public Long save(final Long userId, final ArticleDto.Request articleDto) {
        User author = userService.findById(userId);
        Article article = articleDto.toArticle(author);

        return articleRepository.save(article).getId();
    }

현재 테스트 하려는 ArticleServiceUserServiceArticleRepository를 의존하고 있습니다. 의존하는 두 객체는 Mock으로 만들어줘야 합니다.

참고로 Mockito를 사용할 때는 spring-boot-starter-test에 이미 Mockito가 포함되어 있어 따로 의존성은 추가 해주지 않아도 사용이 가능합니다.
다만, 좀 더 편한 사용을 위해서 @ExtendWith(SpringExtension.class) 또는 @ExtendWith(MockitoExtension.class) 를 용도에 맞게 선택해 추가 해주겠습니다. 둘의 공통점은 @Mock이 붙어 있는 목객체를 초기화해주고, @InjectMocks이 붙어있는 객체에는 자동으로 목객체로 의존주입합니다.

SpringExtension?
Spring5 스펙으로 Spring TestContext Framework를 JUnit5 프로그래밍 모델에 통합 시켜줍니다.

MockitoExtension
MockitoExtension를 사용하려면 testImplementation 'org.mockito:mockito-junit-jupiter' 를 추가해줘야 합니다.

@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
    private static final Long ARTICLE_ID = 1L;
    private static final Long USER_ID = 1L;

    @InjectMocks
    ArticleService articleService;

    @Mock
    UserService userService;

    @Mock
    ArticleRepository articleRepository;

    private User user = new User(USER_ID, "email@gamil.com", "name", "P@ssw0rd");
    private ArticleDto.Request articleRequest = new ArticleDto.Request(ARTICLE_ID, "contents", "title", "coverUrl");
    private Article article = articleRequest.toArticle(user);

    @Test
    void 게시글_저장() {
        // given
        when(userService.findById(USER_ID)).thenReturn(user);
        when(articleRepository.save(article)).thenReturn(article);

        // when
        articleService.save(USER_ID, articleRequest);

        // then
        verify(articleRepository).save(article); 
        // artcileRepository.save(article) 호출 되었는지 확인
    }
}

ArticleService.save() 의 역할은 전달받은 ArticleArticleRepository.save()의 파라미터로 호출하는 것입니다.
즉, 여기서 테스트해야 할 부분은 articleRepository.save(article)이 호출되었는지 확인하는 것입니다. (verify를 사용하면 호출 여부, 횟수를 확인 가능합니다)

현재 Mockito를 사용함으로써 얻은 장점은

  • Mockito를 사용함으로써 내가 원하는 Service에 대해서만 독립적으로 테스트할 수 있다
  • 서버를 실행하지 않으므로 속도가 훨씬 빠르다 (SpringBootTest 제거)

고민 사항

'도메인도 Mock으로 만들어야 진정한 단위테스트가 아닌가?' 생각이 들었지만, 도메인까지 Mock으로 만들어주면 stub을 하나하나 만들어줘야 하고 그러면 복잡해져서 도메인은 그냥 사용했습니다.

또 다른 방법 SpringBoot Slices Test

@SpringBootTest(classes = ArticleService.class)
class ArticleServiceTest {

    @Autowired
    ArticleService articleService;

    @MockBean
    UserService userService;

    @MockBean
    ArticleRepository articleRepository;

Pivotal에서 제시한 두가지 방식 중 다른 하나입니다.
@SpringBootTest(classes = 에 필요한 클래스만 넣어주면 해당하는 빈만 등록해주기 때문에 서버를 키는 속도가 훨씬 빨라집니다.
어떤 것을 사용할지는 선택에 맡기겠습니다.

정리

이전에는 서비스에서 로직의 흐름 중에 생기는 문제도 있을 수 있으니 통합테스트 해야 하는 것이 아닌가? 라는 생각을 가졌습니다. 하지만 이 부분은 컨트롤러에서 이미 인수테스트를 하고 있고 흐름 중에 생기는 문제는 다른 클래스에서 생기는 문제로 오히려 어디서 문제가 발생했는지 발견하기가 좋습니다.
즉, 서비스에서 하는 역할만 독립적으로 테스트하면 된다고 생각합니다.

References