1. '단위 테스트'의 정의부터 짚고 넘어가자.
프로젝트 개발 과정에서 일반적으로 한 개의 클래스 또는 메소드 수준으로 기능을 검증하는 테스트이다.
주로 각 service, controller 별로 테스트 클래스를 만들어서 진행하는데, 이것이 바로 단위 테스트를 의미한다.
2. 그럼 테스트를 할 때, 정의한 도메인 객체들을 그대로 사용해도 되나?
기존에 정의한 도메인 객체를 그대로 사용할 수도 있지만, 비효율적이며 다음과 같은 문제점을 가지고 있다.
- 테스트하려는 객체가 다른 객체에 의존하는 경우, 모든 의존성을 준비하는데 복잡하고 시간이 오래 걸린다.
- DB 또는 네트워크를 사용하는 테스트에서 관련 기능들을 호출하는 작업에서 많은 비용이 든다.
- 테스트 중 에러가 발생하면, 현재 시스템 문제인지 외부 시스템 문제인지 구분하기 어렵다.
따라서 기존 객체를 모방(mocking)하여 독립적으로 테스트를 진행하는데,
Java에서 기능 테스트를 하기 위해 지원하는 테스트 프레임워크가 바로 `Mockito`이다.
3. @ExtendWith()는 무슨 기능을 할까?
`@ExtendWith` 어노테이션은 JUnit5에서 테스트 클래스에 대한 다양한 확장 기능을 제공하기 위해 사용되는 어노테이션이다.
따라서 `@ExtendWith(MockitoExtension.class)` 를 사용하여
Mockito가 제공하는 기능을 JUnit 테스트 클래스에서 사용할 수 있게, 확장을 추가해주면 된다.
추가로 많이 사용되고 있는 확장 기능으로 `@ExtendWith(SpringExtension.class)`이 있다.
이는 Spring Framework 내에서 테스트를 진행하고자 할 때 사용된다.
4. @Mock, @InjectMocks의 개념과 각 어노테이션이 어느 상황에서 사용될까?
@ExtendWith(MockitoExtension.class)
class ArticleServiceTest {
@InjectMocks private ArticleService sut; // System Under Test: 테스트 작성할 때 많이 사용하는 네이밍
@Mock private ArticleRepository articleRepository;
}
@Mock
: 실제 객체를 모방한 가짜 객체로, 모방한 객체의 동작에 대해서 테스트를 진행할 수 있다.
- 그 안에 메소드를 호출해서 사용하려면 반드시 스터빙(stubbing) 해야 한다.
- 만약 스터빙을 하지 않고, 그냥 호출한다면 primitive type은 0 / 참조형은 null을 반환한다.
@InjectMocks
: 테스트 대상 객체에, @Mock으로 생성된 객체를 주입한다.
위의 예시 코드에서 ArticleService 내에 들어가 보면, ArticleRepository 객체가 필요함을 알 수 있다.
이와 같이 @InjectMock(Service), @Mock(DAO) 와 같이, Service 테스트 Mock 객체에 DAO Mock 객체를 주입시켜 사용한다.
@Service
public class ArticleService {
private final ArticleRepository articleRepository;
}
+. Stub, Stubbing
: 실제 객체를 대신하여, 미리 정의된 응답을 제공하는 데 중점을 두는 가짜 객체이다. (Stub)
Mock 객체의 메소드를 호출해도, 실제 객체의 메소드가 실행되지 않기 때문에 메소드의 호출에 대한 응답을 정의해야 한다. (Stubbing)
when(), thenReturn(), thenThrow() 등을 사용해, 리턴 값 또는 예외 발생을 정의할 수 있다.
아래 코드를 예시로 Stubbing을 설명해보면
userRepositoryMock이라는 UserRepository 클래스의 mock 객체가 생성하였고, 이 mock 객체의 findById 메소드가 1L이라는 인자로 호출될 때, 새로운 User 객체를 반환하도록 설정한 코드이다.
즉, 이 코드는 findById 메소드 호출과 그 응답을 Stubbing한 것이다.
@Test
public void testGetUserName() {
// UserRepository를 모킹하고 stub을 설정한다.
UserRepository userRepositoryMock = mock(UserRepository.class);
when(userRepositoryMock.findById(1L)).thenReturn(new User(1L, "John Doe"));
UserService userService = new UserService(userRepositoryMock);
String userName = userService.getUserName(1L);
assertEquals("John Doe", userName);
}
5. MockMvc와 관련 라이브러리가 무엇이 있을까?
1) MockMvcRequestBuilders
- HTTP 요청을 생성하는데 사용된다.
// HTTP Get 요청 경로를 생성한다.
MockMvcRequestBuilders.get("/")
2) MockMvcResultMatchers
- HTTP 응답을 검증하는데 사용된다.
// HTTP 응답 상태를 검증한다.
MockMvcResultMatchers.status().isOk()
6. 그럼 이제 서비스 로직 테스트를 직접 작성해보자!
이처럼 만들고자 하는 기능을 점검할 단위 테스트를 먼저 작성하고,
단위 테스트를 통과시킬 수 있을 정도의 서비스 로직의 코드를 작성하면 된다.
cf. 예제 코드를 참고하였습니다.
https://github.com/djkeh/fastcampus-project-board
[예시 1.]
게시글을 검색하면, 게시글 리스트를 반환하는 articleService의 searchArticles 메소드를 테스트 하는 코드이다.
@DisplayName("게시글을 검색하면, 게시글 리스트를 반환한다.")
@Test
public void searchArticles() throws Exception {
//given
//when
Page<ArticleDto> articles = articleService.searchArticles(SearchType.TITLE, "search keyword"); // 제목, 본문, ID, 닉네임, 해시태그
//then
assertThat(articles).isNotNull();
}
given : 초기 상황
- 특별한 준비 작업이 없다.
when: 행위, 이벤트
- searchArticles 메소드를 호출하는 데 있어, 검색과 관련된 인자로는 '검색 타입', '검색 키워드'로 정의할 수 있다.
- 또한 return 값을 Page<ArticleDto>로 받으면서 게시글에 따른 Paging과 정렬 처리도 가능하게끔 한다.
(이 때, ArticleDto와 SearchType의 정의가 필요하다.)
then: 기대 결과
- articles 객체가 null이 아님을 검증한다.
즉, 검색 결과가 존재함을 확인한다.
[예시 2.]
게시글 정보를 입력하면, 게시글을 생성하는 articleService의 saveArticle 메소드가 articleRepository의 save 메소드를 정확히 호출하는지 테스트 하는 코드이다.
@DisplayName("게시글 정보를 입력하면, 게시글을 생성한다.")
@Test
public void saveArticle() throws Exception {
//given
BDDMockito.given(articleRepository.save(ArgumentMatchers.any(Article.class))).willReturn(null);
//when
articleService.saveArticle(ArticleDto.of("spring 공부 방법", "내용", "#java", LocalDateTime.now(), "sun"));
//then
BDDMockito.then(articleRepository).should().save(ArgumentMatchers.any(Article.class)); // save를 1번 호출했는가?
// 데이터베이스에 원하는 데이터가 들어갔는지 판단하는 테스트
// persistence 레이어까지 내려가서 확인을 해야한다. → unit test에서 더 확장된 sociable test
}
given : 초기 상황
- articleRepository.save() 메소드의 호출 결과를 미리 정의된 결과인 null로 모의한다.
이 코드는 단지 테스트 환경을 준비하는데 사용되고, 실제로 어떠한 테스트 동작을 수행하지도 않는다.
그럼 왜 필요할 지 생각해 볼 필요가 있는데 "메소드 return 값의 구조를 잡는 것"이라고 생각하면 된다.
예를 들어, articleRepository.save() 성공 시 null / 실패 시 error 구조로 잡을 수 있다.
when: 행위, 이벤트
- saveArticle 메소드를 호출하는 데 있어, 관련된 인자로는 '게시글 데이터'로 정의할 수 있다.
then: 기대 결과
.then(articleRepository)
: articleRepository에 대한 BDDMockito 검증 시작
.should()
: articleRepository 메소드 중 어떤 메소드가 호출되었는지 유무 검증
.save(...)
: articleRepository 메소드 중 save 메소드 호출
.save(ArgumentMatchers.any(Article.class))
: save 시, Article 클래스 타입의 객체와 매칭되어야 함
- 즉, articleRepository 메소드 중에서 save 메소드가 정확히 한 번 호출되었는지를 검증한다.
[예시 3.]
게시글 ID를 입력하면, 게시글을 삭제하는 articleService의 deleteArticle 메소드가 articleRepository의 delete 메소드를 정확히 호출하는지 테스트 하는 코드이다.
@DisplayName("게시글의 ID를 입력하면, 게시글을 삭제한다.")
@Test
public void deleteArticle() throws Exception {
//given
BDDMockito.willDoNothing().given(articleRepository).delete(ArgumentMatchers.any(Article.class));
//when
articleService.deleteArticle(1L);
//then
BDDMockito.then(articleRepository).should().delete(ArgumentMatchers.any(Article.class)); // save를 1번 호출했는가?
}
given : 초기 상황
- 저번 코드에서는 articleRepository.save() 메소드의 호출 결과를 미리 정의된 결과인 null로 모의하였다.
- 이번 코드에서는 articleRepository.delete() 메소드의 호출될 때 아무런 동작도 하지 않도록 모의하였다.
이 코드도 똑같이 "메소드 return 값의 구조를 잡는 것"이라고 생각하면 된다.
when: 행위, 이벤트
- deleteArticle 메소드를 호출하는 데 있어, 관련된 인자로는 '게시글 ID'로 정의할 수 있다.
then: 기대 결과
- saveTest와 똑같이, articleRepository 메소드 중에서 delete 메소드가 정확히 한 번 호출되었는지를 검증한다.
참고.
단위테스트와 Stub 개념
https://velog.io/@u-nij/JUnit5-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8Extendwith
@Mock, @Spy, @InjectMocks
https://cornswrold.tistory.com/369
https://effortguy.tistory.com/142
MockMvc - MockMvcRequestBuilders, MockMvcResultHandlers, MockMvcResultMatchers 주요 메서드 정리
https://ktko.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81Spring-MockMvc-%ED%85%8C%EC%8A%A4%ED%8A%B8
'Test' 카테고리의 다른 글
Mockito가 있는데 왜 BDDMockito를 사용할까? (0) | 2024.06.10 |
---|