게시물
에 대한 비즈니스 로직을 수행하는 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);
}
}
단위 테스트 코드를 작성하며 난감한 부분이 있었다. 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
객체는 테스트 코드에서 작성한 객체와 동일하고 ArticleRepository
는 Mock
클래스에 의해 생성된 객체임을 확인할 수 있다.
처음에 Mock
객체가 어떻게 동작하는지 이해를 못해서 한참 헤맸었다. ArticleService
에서 새로운 게시물을 생성하기 위해 Article article = new Article(text);
를 수행한다. 그래서 테스트 코드에서 ArticleRepository
의 동작을 정의할때 save(article)
이런 식으로 테스트 코드에서 생성한 객체
를 save
함수의 파라미터로 기술하면, 실제 ArticleService
에서는 새로운 객체를 저장하기 때문에 테스트 코드에서 설정한 article
과 다른 객체라 단위 테스트 결과가 올바르게 나오지 않는다. 그렇다면 어떤 객체가 들어가든 테스트 코드에서 비교하기 위해 생성한 Article
객체가 나오도록 설정해주면 되겠다 싶어서 수정했다. ArticleService
에서 사용하는 ArticleRepository
가 실제로 올바른 값을 리턴하는지는 생각할 필요 없다. 지금은 Service
단의 단위 테스트이기 때문에 Repository
는 내가 정의한대로만 돌아가면 된다!
Article article = new Article(text, location, user);
when(articleRepository.save(article)).thenReturn(article);
assertEquals(result, article);
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
고민의 내역을 상세히 공유해줘서 고맙습니다. 많이 배우고 갑니다 :)