[Spring] Mockito와 BDDMockito을 이용한 테스트 코드 작성
Mock에 대한 이해
Spring에서는 DI 컨테이너에서 객체들 간의 의존성을 자동으로 관리해주기 때문에, 개발자는 소스 코드 상에서 의존성 주입에 대해 크게 신경쓰지 않고 개발할 수 있다.
하지만, 이렇게 작성한 소스 코드를 단위 테스트하는 과정에서 객체들 간 맺어진 의존성은 객체들이 서로 영향을 끼치도록 하여 테스트에서 각 객체들 간의 독립성을 해치고 테스트 코드의 신뢰성을 떨어트릴 수 있다.
@RequiredArgsConstructor
public class BookService {
private final BookRepository bookRepository;
public List<Book> getBooksUnderPrice(int price) {
List<Book> allBooks = bookRepository.findAll();
return allBooks.stream().filter(book -> book.getPrice() < price).collect(Collectors.toList());
}
}
예컨대 다음과 같이 BookRepository 클래스에 의존성이 있는 BookService 클래스가 있고, 이 상태에서 getBooksUnderPrice 메서드를 테스트하고자 했을 때, 내부적으로 호출되는 BookRepository의 findAll 메서드에 의해서 테스트의 결과가 영향을 받는 문제가 발생한다.
다시 말해, bookRepository가 findAll 메서드를 통해 가져오는 데이터 값이 달라질 때마다 bookService 테스트의 성공 / 실패 여부도 달라질 수 있는 것이다.
public class BookServiceTest {
private BookService bookService = new BookService(new BookRepository());
@Test
void getBooksUnderPriceTest() {
assertThat(bookService.getBooksUnderPrice(10000).size())
.isEqualTo(); // ???
}
}
이 때, 우리는 의존성이 있는 객체를 가짜 객체 즉, Mock 객체로 정의하고 해당 객체가 동작하는 방식을 우리가 원하는대로 Stubbing하는 방식으로 위 문제를 해결할 수 있다.
Mockito 사용하기
해당 작업을 위해 Mockito 라이브러리를 사용할 수 있다.
Mockito는 테스트 클래스에 아래와 같은 어노테이션을 붙여 사용한다.
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
}
@Mock, @InjectMocks
다음으로, @Mock과 @InjectMocks를 사용하여 가짜 객체와 해당 가짜 객체를 주입시키고자 하는 객체를 정의할 수 있다. (Mock과 InjectMocks.. 말이 꽤 직관적이다)
BookService와 BookRepository 예제의 경우 다음과 같이 정의할 수 있다.
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
@Mock
private BookRepository bookRepository;
@InjectMocks
private BookService bookService;
}
@Mockito.when().thenReturn()
다음으로, BookRepository 객체가 어떤 식으로 동작하는지 Mocking 해줄 수 있다.
테스트하고자 하는 BookService의 getBooksUnderPrice 메서드 안에서 호출되는 BookRepository의 findAll 메서드가 특정 값을 리턴하도록 Mocking해보자.
Mockito의 when().thenReturn()으로 동작을 정의해줄 수 있다.
@Test
void getBooksUnderPriceTest() {
Mockito.when(bookRepository.findAll()).thenReturn(List.of(
Book.builder().title("A").price(1000).build(),
Book.builder().title("B").price(5000).build(),
Book.builder().title("C").price(10000).build(),
Book.builder().title("D").price(15000).build(),
Book.builder().title("E").price(50000).build()
));
}
bookRepository가 findAll 메서드가 호출되면 해당 메서드는 5개의 Book 객체를 리스트 형태로 반환할 것이다.
즉, BookRepository가 동작하는 방식을 개발자가 직접 정의해줌으로써 테스트 중 각 객체의 독립성을 보장할 수 있다.
@Test
void getBooksUnderPriceTest() {
Mockito.when(bookRepository.findAll()).thenReturn(List.of(
Book.builder().title("A").price(1000).build(),
Book.builder().title("B").price(5000).build(),
Book.builder().title("C").price(10000).build(),
Book.builder().title("D").price(15000).build(),
Book.builder().title("E").price(50000).build()
));
// 10000원 미만의 책들만 가져올 경우, 그 개수는 2개이다.
assertThat(bookService.getBooksUnderPrice(10000).size())
.isEqualTo(2);
}
when().thenReturn() 이외에도, 다양한 상황에서 사용할 수 있는 메서드들이 정의되어 있다.
- Mockito.doNothing().when(Mock Instance).XXX : Mock Instance가 XXX라는 메서드를 호출했을 때 아무 일도 일어나지 않도록 함 (주로 void 메서드에서 많이 사용한다)
- Mockito.when(Mock Instance).thenThrow() : Mock Instance가 특정 메서드를 호출할 때 특정 에러를 던짐
- Mockito.when(Mock Instance).thenAnswer() : Mock Instance가 특정 메서드를 호출할 때, 고정된 값이 아닌 연산 or 계산 후의 값을 리턴함
Mock.mock()
BookRepository와 같이 단 하나의 Mock 인스턴스만 필요한 경우에는 위와 같은 방법으로 해결할 수 있겠지만, 하나의 객체에 대해 여러 개의 Mock 인스턴스가 필요한 경우에는 어떻게 할까?
물론, 아래 방식대로 여러 개의 객체를 생성해서 모조리 @Mock을 붙여줄 수도 있다.
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
@Mock
private BookRepository bookRepository;
@Mock
private Book book1;
@Mock
private Book book2;
@Mock
private Book book3;
@Mock
private Book book4;
@Mock
private Book book5;
@InjectMocks
private BookService bookService;
}
하지만, 여러 개의 테스트 코드를 작성하는 경우, 각 케이스에서 몇 개의 Mock 인스턴스가 필요할지 알 수 없고, 무엇보다도 코드가 너무 지저분해진다.
특정 테스트에서만 사용되는 객체 혹은 인스턴스는 해당 테스트 블록 내에 모두 정의해 분리 해놓는 것이 훨씬 보기 깔끔하다.
Mockito의 mock 메서드를 통해 어노테이션을 대체할 수 있다. (보통은 반대로 말하긴한다)
@Test
void getBooksUnderPriceTest() {
Book book1 = Mockito.mock(Book.class);
Book book2 = Mockito.mock(Book.class);
Book book3 = Mockito.mock(Book.class);
Book book4 = Mockito.mock(Book.class);
Book book5 = Mockito.mock(Book.class);
Mockito.when(bookRepository.findAll()).thenReturn(List.of(
book1, book2, book3, book4, book5
));
assertThat(bookService.getBooksUnderPrice(10000).size())
.isEqualTo(2);
}
물론, bookService에서 해당 Book 객체의 getPrice() 메서드를 사용하기 때문에 이것들도 모두 Mocking 해주어야 한다.
마찬가지로 Mockito.when().thenReturn()을 사용할 수 있다.
@Test
void getBooksUnderPriceTest() {
Book book1 = Mockito.mock(Book.class);
Book book2 = Mockito.mock(Book.class);
Book book3 = Mockito.mock(Book.class);
Book book4 = Mockito.mock(Book.class);
Book book5 = Mockito.mock(Book.class);
Mockito.when(book1.getPrice()).thenReturn(1000);
Mockito.when(book2.getPrice()).thenReturn(5000);
Mockito.when(book3.getPrice()).thenReturn(10000);
Mockito.when(book4.getPrice()).thenReturn(15000);
Mockito.when(book5.getPrice()).thenReturn(50000);
Mockito.when(bookRepository.findAll()).thenReturn(List.of(
book1, book2, book3, book4, book5
));
assertThat(bookService.getBooksUnderPrice(10000).size())
.isEqualTo(2);
}
@Spy
Spy 어노테이션은 특정 객체를 상황에 따라 실제 작동하도록 할수도, Mocking을 할수도 있게 해준다.
다시 말해, 특정 객체에 대한 Mocking을 진행하지 않으면 실제 객체로, Mocking을 진행하면 Mock 객체로 작동한다.
@RequiredArgsConstructor
@Getter
public class BookService {
private final BookRepository bookRepository;
// 가장 싼 책의 가격을 반환
public int getCheapestPrice(List<Book> books) {
checkBooksExistInDB(books);
return books.stream().mapToInt(Book::getPrice).min().getAsInt();
}
// 입력받은 책들이 데이터베이스에 실존하는지 확인
public void checkBooksExistInDB(List<Book> books) {
List<Book> allByIds = bookRepository.findAllByIds(
books.stream().map(Book::getId).collect(Collectors.toList()));
if (books.size() != allByIds.size()) {
throw new IllegalArgumentException("실존하지 않는 책이 존재합니다.");
}
}
}
클래스와 메서드가 다음과 같이 정의되어 있고, getCheapestPrice를 테스트하고 싶다고 가정해보자.
getCheapestPrice에는 BookService 내부에 정의된 checkBooksExistInDB를 호출하는데, 이 메서드는 bookRepository의 findAllByIds메서드를 호출하고 있다.
물론, 이전에 했던 방식대로 bookRepository의 findAllByIds를 Mocking 해주는 방식으로도 테스트를 진행할 수 있지만, getCheapestPrice 내부에 작성되지 않은 코드를 Mocking 해주는 것은 영 별로다.
이 때, @Spy를 통해 아예 checkBooksExistInDB 메서드 자체를 Mocking 해줄 수 있다.
우선, 기존의 설정에서 BookService 클래스에 @Spy를 추가해준다.
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
@Mock
private BookRepository bookRepository;
@Spy // Spy 어노테이션 추가
@InjectMocks
private BookService bookService;
}
추가 후에는 bookService의 checkBooksExistInDB 메서드를 Mocking 해줄 수 있다.
@Test
void getCheapestPriceTest() {
// bookService.checkBooksExistInDB에 대한 Mocking
Mockito.doNothing().when(bookService).checkBooksExistInDB(any(List.class));
Book book1 = Mockito.mock(Book.class);
Book book2 = Mockito.mock(Book.class);
Book book3 = Mockito.mock(Book.class);
Mockito.when(book1.getPrice()).thenReturn(10);
Mockito.when(book2.getPrice()).thenReturn(5000);
Mockito.when(book3.getPrice()).thenReturn(10000);
List<Book> bookList = List.of(book1, book2, book3);
assertThat(bookService.getCheapestPrice(bookList)).isEqualTo(10);
}
이러면 Mocking을 해준 checkBooksExistInDB 메서드는 가짜 객체로, Mocking을 하지 않은 getCheapestPrice는 실제 객체로 작동하게 된다.
만약 @Spy를 추가하지 않는다면, bookService에 대한 Mocking 자체가 불가능해지므로, checkBooksExistInDB 메서드 내에서 호출되는 bookRepository.findAllByIds를 Mocking 해주어야 한다.
BDD(Behavior Driven Development) Mockito
BDD Mockito는 행위 주도 개발(BDD)의 관점에서 테스트 코드를 읽기 쉽도록 해주는 라이브러리이다.
위와 같이 BDDMockito는 Mockito를 상속하고 있는 것을 확인할 수 있는데, 사용법은 사실상 앞서 쭉 봐왔던 Mockito와 거의 동일하고, 메서드의 이름만 BDD에 친화적으로 변경한 라이브러리이다.
given - when - then 세 가지 파트로 시나리오를 만들어, 테스트 코드를 BDD 관점에 기반하여 가독성을 높일 수 있다.
본인 또한 실제 테스트 코드에선 Mockito보다는 BDDMockito를 사용하는 편이다.
// BDDMockito로 변경 후
@ExtendWith(MockitoExtension.class)
public class BookServiceTest {
@Mock
private BookRepository bookRepository;
@Spy
@InjectMocks
private BookService bookService;
@Test
void getBooksUnderPriceTest() {
Book book1 = BDDMockito.mock(Book.class);
Book book2 = BDDMockito.mock(Book.class);
Book book3 = BDDMockito.mock(Book.class);
Book book4 = BDDMockito.mock(Book.class);
Book book5 = BDDMockito.mock(Book.class);
BDDMockito.given(book1.getPrice()).willReturn(1000);
BDDMockito.given(book2.getPrice()).willReturn(5000);
BDDMockito.given(book3.getPrice()).willReturn(10000);
BDDMockito.given(book4.getPrice()).willReturn(15000);
BDDMockito.given(book5.getPrice()).willReturn(50000);
BDDMockito.given(bookRepository.findAll()).willReturn(List.of(
book1, book2, book3, book4, book5
));
assertThat(bookService.getBooksUnderPrice(10000).size())
.isEqualTo(2);
}
@Test
void getCheapestPriceTest() {
BDDMockito.doNothing().when(bookService).checkBooksExistInDB(any(List.class));
Book book1 = BDDMockito.mock(Book.class);
Book book2 = BDDMockito.mock(Book.class);
Book book3 = BDDMockito.mock(Book.class);
BDDMockito.given(book1.getPrice()).willReturn(10);
BDDMockito.given(book2.getPrice()).willReturn(5000);
BDDMockito.given(book3.getPrice()).willReturn(10000);
List<Book> bookList = List.of(book1, book2, book3);
assertThat(bookService.getCheapestPrice(bookList)).isEqualTo(10);
}
}