테스트 코드 입문자라면 겪어보는 문제
테스트 코드를 작성한지 얼마 안 되었을 때
의존성이 있는 객체의 테스트 코드를 작성하면서, 의존하는 객체의 실제 인스턴스를 생성해서 진행한 적이 많았다.
나중에 변경이 생기면 관련된 테스트가 모두 실패하는 상황이 잦았다. (고치는 것도 고역이였다.)
그러다 보니 나중엔 테스트 코드 때문에 변경이 무서워지는 상황이 생겼다.
유연하게 변경하기 위한 테스트 코드 때문에 변경이 무섭다니... 아이러니한 상황이다.
또한 외부 API를 의존할 경우에 테스트 코드를 어떻게 작성해야 할지 막히는 경우도 있다.
잘못된 걸 인식했으면 해결하기 위해 고민을 해야 했는데
당시에는 '테스트 코드가 복잡해지면 이런갑다~' 하고 아무 생각없이 넘어갔다.
DDD 세레나데 교육과 인프런 백기선 강의를 통해 Mockito를 접하면서 테스트에서 의존성을 관리하는 방법을 배우게 되었다. (이미 상반기에 이규원의 TDD 교육으로 개념을 접했는데, 기억 못했음)
이제라도 Mockito를 포스팅한다.
Mockito란?
Mock 객체를 쉽게 만들고 관리하여 검증하는 방법을 제공하는 프레임워크다.
Mock : 진짜 객체와 비슷하게 동작하지만 프로그래머가 직접 그 객체의 행동을 관리하는 객체
언제 사용하는가?
타 부서의 개발이 덜된 API를 이용할 경우나, 테스트 진행에 외부 API가 필요한 경우 등
외부API를 신경 안 쓰고 객체를 테스트할 때 사용한다.
Mockito 시작하기
스프링부트 2.2 이상이라면 spring-boot-starter-test에 Mockito가 포함되어있다.
스프링부트가 아니라면 직접 의존성을 추가해준다.
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.1.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>3.1.0</version>
<scope>test</scope>
</dependency>
처음 Mock을 접한다면 생소하겠지만, 다음의 세 가지를 알면 오히려 JUnit보다 쉽게 적응할 수 있다.
- Mock을 만드는 방법
- Mock이 어떻게 동작해야 하는지 관리하는 방법
- Mock의 행동을 검증하는 방법
Mockito 레퍼런스
Mock 객체 만들기
Mock객체를 만드는 방법은 여러 가지인데, 우선 예제는 DDD 세레나데 교육에서 진행한 레거시 코드를 이용한다.
Service는 Order, Menu, Product 중에서 Product 로 진행한다.
아래는 ProductService 코드 일부분이다.
@Service
public class ProductService {
private final ProductRepository productRepository;
private final MenuRepository menuRepository;
private final PurgomalumClient purgomalumClient;
public ProductService(
final ProductRepository productRepository,
final MenuRepository menuRepository,
final PurgomalumClient purgomalumClient
) {
this.productRepository = productRepository;
this.menuRepository = menuRepository;
this.purgomalumClient = purgomalumClient;
}
@Transactional
public Product create(final Product request) {
...
}
@Transactional
public Product changePrice(final UUID productId, final Product request) {
...
}
@Transactional(readOnly = true)
public List<Product> findAll() {
...
}
}
- Mockito 없이 직접 Mock 만들어보기
Mock 객체를 만드는 방법은 단순하다. 직접 원하는 인터페이스나 클래스를 간략히 구현하면 된다. (길어서 더보기로 숨긴다.)
class ProductServiceMockitoTest {
@Test
void mockito_test() {
ProductRepository productRepository = new MockProductRepository();
}
}
public class MockProductRepository implements ProductRepository {
@Override
public List<Product> findAll() {
return null;
}
@Override
public List<Product> findAll(Sort sort) {
return null;
}
@Override
public List<Product> findAllById(Iterable<UUID> uuids) {
return null;
}
@Override
public <S extends Product> List<S> saveAll(Iterable<S> entities) {
return null;
}
@Override
public void flush() {
}
@Override
public <S extends Product> S saveAndFlush(S entity) {
return null;
}
@Override
public <S extends Product> List<S> saveAllAndFlush(Iterable<S> entities) {
return null;
}
@Override
public void deleteAllInBatch(Iterable<Product> entities) {
}
@Override
public void deleteAllByIdInBatch(Iterable<UUID> uuids) {
}
@Override
public void deleteAllInBatch() {
}
@Override
public Product getOne(UUID uuid) {
return null;
}
@Override
public Product getById(UUID uuid) {
return null;
}
@Override
public <S extends Product> List<S> findAll(Example<S> example) {
return null;
}
@Override
public <S extends Product> List<S> findAll(Example<S> example, Sort sort) {
return null;
}
@Override
public Page<Product> findAll(Pageable pageable) {
return null;
}
@Override
public <S extends Product> S save(S entity) {
return null;
}
@Override
public Optional<Product> findById(UUID uuid) {
return Optional.empty();
}
@Override
public boolean existsById(UUID uuid) {
return false;
}
@Override
public long count() {
return 0;
}
@Override
public void deleteById(UUID uuid) {
}
@Override
public void delete(Product entity) {
}
@Override
public void deleteAllById(Iterable<? extends UUID> uuids) {
}
@Override
public void deleteAll(Iterable<? extends Product> entities) {
}
@Override
public void deleteAll() {
}
@Override
public <S extends Product> Optional<S> findOne(Example<S> example) {
return Optional.empty();
}
@Override
public <S extends Product> Page<S> findAll(Example<S> example, Pageable pageable) {
return null;
}
@Override
public <S extends Product> long count(Example<S> example) {
return 0;
}
@Override
public <S extends Product> boolean exists(Example<S> example) {
return false;
}
}
단점은 의존 개수와 크기에 따라 구현하는데 많은 부담감이 생긴다.
- Mockito를 이용하여 Mock 생성하기
위와는 다르게 Mockito를 이용하면 간단하게 Mock을 사용할 수 있다.
사용방법은 단순하다 Mockito.mock()에 원하는 인터페이스나 클래스를 지정해준다.
import kitchenpos.domain.ProductRepository;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class ProductServiceMockitoTest {
@Test
void mockito_test() {
ProductRepository productRepository = Mockito.mock(ProductRepository.class);
}
}
이제 생성된 Mock 객체를 이용하여 외부API를 사용하는 ProductService 인스턴스를 생성한다.
class ProductServiceMockitoTest {
@Test
void mockito_test() {
ProductRepository productRepository = mock(ProductRepository.class);
MenuRepository menuRepository = mock(MenuRepository.class);
PurgomalumClient purgomalumClient = mock(PurgomalumClient.class);
ProductService productService = new ProductService(productRepository, menuRepository, purgomalumClient);
assertThat(productService).isNotNull();
}
}
Mock을 주입하고 생성되는 걸 볼 수 있다.
이것도 손이 많이 간다고 느껴지면 좀 더 간단히 할 수 있는 방법이 있다.
- @Mock 애노테이션으로 생성하기
가장 단순한 방법으로 JUnit5 extension 이용하여 사용할 수 있다.
@ExtendWith(MockitoExtension.class)
class ProductServiceMockitoTest {
@Mock 사용방법은 2가지로 나눠진다.
주입된 @Mock 객체로 직접 생성자에 주입하는 경우.
@ExtendWith(MockitoExtension.class)
class ProductServiceMockitoTest {
@Test
void mockito_test(@Mock ProductRepository productRepository,
@Mock MenuRepository menuRepository,
@Mock PurgomalumClient purgomalumClient) {
ProductService productService = new ProductService(productRepository, menuRepository, purgomalumClient)
assertThat(productService).isNotNull();
}
}
@InjectMocks를 통해 자동으로 @Mock 객체를 주입하는 경우
@ExtendWith(MockitoExtension.class)
class ProductServiceMockitoTest {
@Mock
ProductRepository productRepository;
@Mock
MenuRepository menuRepository;
@Mock
PurgomalumClient purgomalumClient;
@InjectMocks
ProductService productService;
@Test
void mockito_test() {
assertThat(productService).isNotNull();
}
}
@Mock 객체를 생성했으니 테스트 코드를 작성해보자.
Mockito로 테스트 작성하기
ProductService.create() 코드를 살펴보면
의존하는 외부API는 productRepository, purgomalumClient 2개가 있다.
@Transactional
public Product create(final Product request) {
final BigDecimal price = request.getPrice();
if (Objects.isNull(price) || price.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException();
}
final String name = request.getName();
// 외부API로 비속어 확인
if (Objects.isNull(name) || purgomalumClient.containsProfanity(name)) {
throw new IllegalArgumentException();
}
final Product product = new Product();
product.setId(UUID.randomUUID());
product.setName(name);
product.setPrice(price);
// DB에 저장
return productRepository.save(product);
}
실제 운영은 외부API를 호출해서 응답받은 결과로 작동시켰지만
외부API를 Mock 객체로 변경했으니, 개발자가 직접 어떻게 동작할지 가정해서 테스트 코드를 실행해야 한다.
Mock 객체 Stubbing
Mock 객체의 기본적인 행동은 다음과 같다.
- Null을 리턴한다. (Optional 타입은 Optional.empty()로 리턴한다.)
- Primitive 타입은 기본 Primitive 값을 따른다.
- 콜렉션은 비어있는 콜렉션으로 만들어진다.
- Void 메소드는 아무런 일도 발생하지 않는다.
위와 같은 이유로 생성된 Mock 객체는 아무것도 안하기 때문에 기존 코드가 작동하는 것처럼 흉내낸다. 이를 Stub이라 하며 다음과 같이 조작한다.
@Test
void mockito_test() {
UUID id = UUID.randomUUID();
Product product = new Product();
product.setId(id);
product.setPrice(BigDecimal.valueOf(6000L));
product.setName("옛날통닭");
// Mock 객체의 행동을 조작한다.
when(productRepository.findById(any())).thenReturn(Optional.of(product));
Optional<Product> findById = productRepository.findById(UUID.randomUUID());
assertThat(findById).isNotNull();
assertThat(findById.get().getName()).isEqualTo("옛날통닭");
assertThat(findById.get().getId()).isEqualTo(id);
assertThat(findById.get().getPrice()).isEqualTo(BigDecimal.valueOf(6000L));
}
Mock 객체를 org.mockito.Mockito의 when 함수로 조작한다.
when(productRepository.findById(any()))를 보면 any()를 명시했는데, any()는 아무 파라미터를 넣어도 된다는 뜻이고, thenReturn(값)은 리턴할 값을 명시하는 거다.
따라서 productRepository.findById()를 아무 값이나 넣고 호출이 되면 명시한 product 값이 계속 리턴된다.
@Test
void mockito_test() {
...
when(productRepository.findById(any())).thenReturn(Optional.of(product));
Optional<Product> findById = productRepository.findById(UUID.randomUUID());
Optional<Product> findById2 = productRepository.findById(UUID.randomUUID());
assertThat(findById).isEqualTo(findById2);
}
위와 같이, Mock 객체를 조작하는 방법은 여러 가지다.
- 특정한 매개변수를 받은 경우 특정한 값을 리턴하거나 예외를 발생시킨다.
@Test
void mockito_test() {
UUID id = UUID.randomUUID();
Product product = new Product();
product.setId(id);
product.setPrice(BigDecimal.valueOf(6000L));
product.setName("옛날통닭");
when(productRepository.findById(any()))
.thenReturn(Optional.of(product))
.thenThrow(NullPointerException.class);
Optional<Product> findById = productRepository.findById(UUID.randomUUID());
assertThat(findById.get().getName()).isEqualTo("옛날통닭");
assertThatNullPointerException()
.isThrownBy(() -> productRepository.findById(UUID.randomUUID()));
}
- void 메소드 경우, 특정 매개변수 혹은 호출이 될 때 예외를 발생시킨다.
@Test
void mockito_test() {
when(purgomalumClient.containsProfanity(any()))
.thenThrow(NullPointerException.class);
assertThatNullPointerException()
.isThrownBy(() -> purgomalumClient.containsProfanity("loop"));
}
- 메소드가 동일한 매개변수로 호출될 때, 각기 다르게 행동하게 조작할 수 있다.
@Test
void mockito_test() {
UUID id = UUID.randomUUID();
Product product = new Product();
product.setId(id);
product.setPrice(BigDecimal.valueOf(6000L));
product.setName("옛날통닭");
Product product2 = new Product();
product2.setId(UUID.randomUUID());
product2.setPrice(BigDecimal.valueOf(16000L));
product2.setName("후라이드치킨");
Product product3 = new Product();
product3.setId(UUID.randomUUID());
product3.setPrice(BigDecimal.valueOf(17000L));
product3.setName("양념치킨");
when(productRepository.findById(any()))
.thenReturn(Optional.of(product), Optional.of(product2), Optional.of(product3));
Optional<Product> findById = productRepository.findById(UUID.randomUUID());
Optional<Product> findById2 = productRepository.findById(UUID.randomUUID());
Optional<Product> findById3 = productRepository.findById(UUID.randomUUID());
assertThat(findById.get().getName()).isEqualTo("옛날통닭");
assertThat(findById2.get().getName()).isEqualTo("후라이드치킨");
assertThat(findById3.get().getName()).isEqualTo("양념치킨");
}
Mock 기반으로 테스트 코드를 작성해보자.
이전에 봤던 create를 코드를 살펴보자.
@Transactional
public Product create(final Product request) {
final BigDecimal price = request.getPrice();
if (Objects.isNull(price) || price.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException();
}
final String name = request.getName();
if (Objects.isNull(name) || purgomalumClient.containsProfanity(name)) {
throw new IllegalArgumentException();
}
final Product product = new Product();
product.setId(UUID.randomUUID());
product.setName(name);
product.setPrice(price);
return productRepository.save(product);
}
여기서 Mock 객체는 productRepository, purgomalumClient 2개로 stubbing 하고 동작하게 해보자.
@Test
void create_product() {
UUID id = UUID.randomUUID();
Product product = new Product();
product.setId(id);
product.setPrice(BigDecimal.valueOf(6000L));
product.setName("옛날통닭");
when(purgomalumClient.containsProfanity("옛날통닭")).thenReturn(false);
when(productRepository.save(any())).thenReturn(product);
Product createProduct = productService.create(product);
assertThat(createProduct.getId()).isEqualTo(product.getId());
}
ProductService.create의 외부 API를 Mock 객체로 작동하는걸 볼 수 있다.
* 주의사항
아래처럼 내부에서 인스턴스를 생성해서 사용하기 때문에 매개변수를 제어 못하는 경우가 있다.
@Transactional
public Product create(final Product request) {
...
// 내부에서 매개변수를 생성한다.
final Product product = new Product();
product.setId(UUID.randomUUID());
product.setName(name);
product.setPrice(price);
return productRepository.save(product);
}
제어할 수 없는 매개변수는 any()를 이용하여 처리하자.
@Test
void create_product() {
...
when(productRepository.save(any())).thenReturn(product);
...
}
Mock 객체의 확인
- 특정 메소드가 특정 매개변수로 몇번 호출되었는지
Mock 객체의 행동을 조작하면 결과를 확인할 수 있지만, 결과가 아니라 조작한 동작을 확인하고 싶으면 어떻게 해야할까? Mockito의 verify를 이용하면 된다.
사용방법은 간단하다. verify를 호출하고 인자로 Mock 객체를 넘겨주면 된다. 그리고 확인하고 싶은 함수를 호출한다.
@Test
void create_product() {
...
when(purgomalumClient.containsProfanity("옛날통닭")).thenReturn(false);
when(productRepository.save(any())).thenReturn(product);
Product createProduct = productService.create(product);
verify(purgomalumClient).containsProfanity("옛날통닭");
verify(productRepository, times(1)).save(any());
assertThat(createProduct.getId()).isEqualTo(product.getId());
}
verfiy 코드를 확인해보면 다음과 같다.
verify는 첫번째 인자로 Mock 객체를 넣고 두번째에는 동작확인 mode를 넣는다.
verify의 첫번째 인자에는 Mock 객체를 넣고, 두번째 인자는 동작 방법(실행 횟수 등)을 넣는다.
verify(Mock)로 호출하면 default로 times(1) 실행된다.
가이드 예제로 간단히 알아보자.
@Test
void mockito_verfiy() {
List mockedList = mock(List.class);
mockedList.add("once");
mockedList.add("twice");
mockedList.add("twice");
mockedList.add("three times");
mockedList.add("three times");
mockedList.add("three times");
//following two verifications work exactly the same - times(1) is used by default
verify(mockedList).add("once");
verify(mockedList, times(1)).add("once");
//exact number of invocations verification
verify(mockedList, times(2)).add("twice");
verify(mockedList, times(3)).add("three times");
//verification using never(). never() is an alias to times(0)
verify(mockedList, never()).add("never happened");
//verification using atLeast()/atMost()
verify(mockedList, atMostOnce()).add("once");
verify(mockedList, atLeastOnce()).add("three times");
verify(mockedList, atLeast(2)).add("three times");
verify(mockedList, atMost(5)).add("three times");
}
- times(n) : n번 실행 여부
- never() : 실행 안되어야함.
- atMostOnce() : 최대 1번 실행
- atLeastOnce() : 최소 1번 실행
- atLest(n) : 최소 n번 실행
- atMost(n) 최대 n번 실행
- 어떤 순서대로 호출되었는지
InOrder를 이용하면 실행 순서까지 확인할 수 있다.
가이드의 예제다.
@Test
void inorder() {
// A. Single mock whose methods must be invoked in a particular order
List singleMock = mock(List.class);
//using a single mock
singleMock.add("was added first");
singleMock.add("was added second");
//create an inOrder verifier for a single mock
InOrder inOrder = inOrder(singleMock);
//following will make sure that add is first called with "was added first", then with "was added second"
inOrder.verify(singleMock).add("was added first");
inOrder.verify(singleMock).add("was added second");
}
InOrder의 실행 순서 검증은 간단하다.
객체를 호출하고 이전에 봤던 verify()를 똑같이 사용하면 된다.
순서가 틀리면 어떻게 될까? 다음과 같은 에러를 반환한다.
2개 이상의 Mock 객체의 순서를 확인할 경우 다음과 같이 사용한다.
@Test
void inorder() {
// B. Multiple mocks that must be used in a particular order
List firstMock = mock(List.class);
List secondMock = mock(List.class);
//using mocks
firstMock.add("was called first");
secondMock.add("was called second");
//create inOrder object passing any mocks that need to be verified in order
InOrder inOrder = inOrder(firstMock, secondMock);
//following will make sure that firstMock was called before secondMock
inOrder.verify(firstMock).add("was called first");
inOrder.verify(secondMock).add("was called second");
}
만약에 firstMock.add()를 연속 2번 호출하고 확인하면 어떻게 될까?
@Test
void inorder() {
...
List firstMock = mock(List.class);
List secondMock = mock(List.class);
firstMock.add("was called first");
firstMock.add("was called first");
secondMock.add("was called second");
InOrder inOrder2 = inOrder(firstMock, secondMock);
inOrder2.verify(firstMock).add("was called first");
inOrder2.verify(firstMock).add("was called first");
inOrder2.verify(secondMock).add("was called second");
}
왜 에러가 발생했을까? verify 두번째 인자를 명시 안 하면 default로 times(1)이 넘어가기 때문이다.
다시 보면 아래와 같다.
inOrder2.verify(firstMock, times(1)).add("was called first");
inOrder2.verify(firstMock, times(1)).add("was called first");
inOrder2.verify(secondMock, times(1)).add("was called second");
다음과 같이 수정해야 정상적으로 작동한다.
inOrder2.verify(firstMock, times(2)).add("was called first");
inOrder2.verify(secondMock, times(1)).add("was called second");
- 특정 시간 내에 호출됐는지
외부API 사용에 중요한 응답 시간까지 확인할 수 있다.
(개인적으로 실제 외부API가 아닌 Mock 객체의 응답시간을 확인해야 하는지 모르겠다.)
사용 방법은 단순하다.
verify(productRepository, timeout(100)).save(any());
verify(productRepository, timeout(100).times(2)).save(any());
verify(productRepository, timeout(100).atMostOnce()).save(any());
- 특정 시점 이후에 실행이 안되었는지 확인
Mock객체가 테스트를 위해 실행된 이후, 실행되면 잘못되었다고 알려주는 방법도 존재한다.
사용방법은 단순하다.
@Test
void mockito_verfiy() {
List mockedList = mock(List.class);
mockedList.add("once");
mockedList.add("twice");
verify(mockedList).add("once");
verifyNoMoreInteractions(mockedList);
}
verify()로 검증하고 나서 verifyNoMoreInteractions(Mock)으로 추가적인 실행이 있었는지 확인한다.
BDD (Behaviour-Driven Development) 스타일
애플리케이션이 어떻게 "행동"해야 하는지에 대한 공통된 이해를 구성된 방법으로 TDD에서 창안.
TDD가 코드를 중시하면, BDD는 행동을 중시한다.
행동에 대한 스펙
- title
- Narrative
- As a / I want / so that
- Acceptance criteria
- Given / When / Then
BDDMockito 사용하기
Mockito는 BDDMockito 클래스로 BDD 스타일 API을 제공해준다.
사용방법
이전 예제코드를 보면 Mockito.when(), Mockito.verify()를 사용했었다. 이를 다음과 같이 변경하면 BDD 스타일 적용이 끝난다.
@Test
void create_product() {
// given
UUID id = UUID.randomUUID();
Product product = new Product();
product.setId(id);
product.setPrice(BigDecimal.valueOf(6000L));
product.setName("옛날통닭");
BDDMockito.given(purgomalumClient.containsProfanity("옛날통닭"))
.willReturn(false);
BDDMockito.given(productRepository.save(any()))
.willReturn(product);
// when
Product createProduct = productService.create(product);
// then
BDDMockito.then(purgomalumClient).should().containsProfanity("옛날통닭");
BDDMockito.then(productRepository).should(times(1)).save(any());
assertThat(createProduct.getId()).isEqualTo(product.getId());
}
BDDMockito는 기존 Mockito 상속받아 BDD 스타일로 사용하게끔 변경된 것으로 자세한 내용은 BDDMockito 코드를 참고하면 이해가 빠르다.
참고자료
더 자바, 애플리케이션을 테스트하는 방법
DDD 세레나데
Mockito 가이드
'개발 & 방법론 > TDD' 카테고리의 다른 글
AssertJ 알아보기 (부제 : Jupiter, Hamcrest 맛보기 ) (0) | 2021.11.21 |
---|---|
JUnit5 알아보기 (0) | 2021.10.26 |
테스트 코드를 작성하는 이유 (0) | 2021.03.03 |
테스트코드 첫걸음 (0) | 2021.02.15 |