ArgumentsMatcher와 ArgumentsCaptor, willAnswer의 활용으로 더 복잡한 테스트 수행하기
Mockito와 BDDMockito
- BDDMockito는 Mockito를 BDD친화적으로 만든 API이다.
- BDD는 given - when - then으로 이뤄지며, Mockito의 when은 BBDMockito의 given으로, verify는 then으로 이름이 변경된다.
- 현재 코드는 BDDMockito를 기반으로 작성되었다. ArgumentsMatcher와 ArgumentsCaptor를 BDDMockito에 사용할 수 있다.
ArgumentsMatcher와 ArgumentsCaptor
- 단순한 테스트 코드 작성은 Mockito로 충분히 가능하다. 사실 쉬운 이해와 가독성을 위해서는 최대한 단순한 코드 작성이 미덕이라 생각한다.
- 하지만 인자가 객체 혹은 컬렉션이 되거나, 인자의 값에 따른 리턴 값 제공이 필요한 경우 Mockito로만 통제하기 어렵다. 이런 경우 ArgumentsMatcher와 ArgumentsCaptor를 사용한다.
삽입된 인자를 가져오기. spy로서 ArgumentsCaptor 사용하기
- ArgumentsCaptor은 spy로서 로직의 수행과정에서 삽입되었던 인자를 추출한다.
- 아래는
String getValueO(Request r)
을 시그니처로 가지는 객체 Executor를 테스트 중이다. - given을 통해 참조변수
Request r
이 삽입될 경우 “world!”를 리턴하도록 정의하였다. // when
단락에서 실제로mock.getValueO(r)
을 실행한 결과 “world!”가 리턴함을 assertThat메서드로로 확인하였다.// when
단락을 수행했던 과거로 돌아가, 실제로 어떤 값이 삽입되었는지를 확인한다.- ArgumentCaptor#forClass을 사용하여 인자의 타입을 정의한다.
then(mock).should(times(1))
를 통해 해당 객체가 1회 호출됨을 확인한다..getValueO(captor.capture());
메서드를 사용하여 호출된 당시 인자의 값을 추출한다.- 마지막 assertThat 메서드로
Request r
과 실제로 삽입된 인자가 동일함을 확인한다.
import org.mockito.ArgumentCaptor;
import static org.mockito.BDDMockito.*;
Executor mock = mock(Executor.class);
// given
Request r = new Request("hello");
given(mock.getValueO(r)).willReturn("world!");
// when
String result = mock.getValueO(r);
// then
assertThat(result).isEqualTo("world!");
ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
then(mock).should(times(1)).getValueO(captor.capture());
assertThat(captor.getValue()).isEqualTo(r);
ArgumentsCaptor의 타입의 제네릭이 두 번 이상 반복될 경우
- ArgumentCaptor#forClass는 제네릭이 두 번 이상 겹칠 경우 초기화가 정상적으로 동작하지 않는다.
- 예를 들면
ArgumentsCaptor<List<Object>> captorSamples = ArgumentsCaptor.forClass(List.class);
는 동작하지 않는다. - 이 경우 지역 변수가 아닌 필드에 ArgumentsCaptor를 선언하고 @Captor를 달아 놓는다.
- 그리고 Mockito와 관련한 어너테이션이 있는 필드가 테스트마다 초기화 되도록
MockitoAnnotations.openMocks(this)
을 명시한다.
import org.mockito.Captor;
import org.mockito.MockitoAnnotations;
@Captor
ArgumentsCaptor<List<Sample>> captorSamples;
@BeforeEach
void beforeEach(){
MockitoAnnotations.openMocks(this);
}
인자에 따른 리턴 정의하기 : ArgumentsMatcher와 willAnswer
기본타입과 given
- 기본타입의 경우 특별한 제한 없이 given을 중복으로 정의하여, 인자에 따른 리턴 값을 정의할 수 있다.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
public class ArgumentsMatcherPriTest {
@Test
void test_primitive(){
Executor mock = mock(Executor.class);
given(mock.getValue("hello")).willReturn("world");
given(mock.getValue("HELLO")).willReturn("WORLD");
assertThat(mock.getValue("hello")).isEqualTo("world");
assertThat(mock.getValue("HELLO")).isEqualTo("WORLD");
}
interface Executor {
String getValue(String r);
}
}
객체를 비교하기 : ArgumentsMatcher
- 하지만 인자가 객체일 경우 상황은 달라진다. 왜냐하면 동일성이 아닌 동등성 비교를 해야하기 때문이다.
- ArgumentsMatcher를 사용하여 동등성 비교을 비교한다.
- 첫 번째 테스트는 객체의 동일성을 비교하였다. 이 경우 ArgumentsMatcher를 사용할 필요가 없다.
- 두 번째 테스트는 객체의 동등성을 비교하였다. ArgumentsMatcher#argThat을 사용하여
String getValue()
의 값을 비교하였다.
import static org.mockito.ArgumentMatchers.argThat;
@Test
void test1_givenSingle_whenEquality_thenFailed(){
Executor mock = mock(Executor.class);
Request r = new Request("hello");
given(mock.getValue(r)).willReturn("world");
assertThat(mock.getValue(r)).isEqualTo("world");
assertThat(mock.getValue(new Request("hello"))).isNull();
}
@Test
void test2_givenSingle_whenIdentity_thenSuccess(){
Executor mock = mock(Executor.class);
given(mock.getValue(argThat(a -> a.getValue().equals("hello")))).willReturn("world");
assertThat(mock.getValue(new Request("hello"))).isEqualTo("world");
assertThat(mock.getValue(new Request("HELLO"))).isEqualTo(null);
}
interface Executor {
String getValue(Request r);
}
@ToString
@Getter
@AllArgsConstructor
static class Request {
private String value;
}
객체에 대한 given의 중복 정의와 ArgumentsMatcher의 오류 발생
ArgumentsMatcherPriTest#test_primitive
테스트가 하나의 대역에 대해 여러 개의 기본 타입으로 given으로 정의하였고 잘 동작하였다.- 이처럼 객체에 대하여 여러 개의 given으로 정의하면 어떻게 될까? 이 경우 안타깝지만 정상적으로 동작하지 않는다. NPE가 발생한다.
- 해당 코드는 아래와 같다.
@Test
void test3_givenTwo_whenIdentity_thenThrown(){
Executor mock = mock(Executor.class);
given(mock.getValue(argThat(a -> a.getValue().equals("hello")))).willReturn("world");
given(mock.getValue(argThat(a -> a.getValue().equals("HELLO")))).willReturn("WORLD"); // 예외 발생, NPE
assertThat(mock.getValue(new Request("hello"))).isEqualTo("world");
assertThat(mock.getValue(new Request("HELLO"))).isEqualTo("WORLD");
}
- 이를 해소하는 방법은 크게 두 가지가 있다. 순서에 따른 값을 넘기거나 Answer객체를 새롭게 정의할 수 있다.
한 대역을 여러 차례 호출한다면, 순차적으로 willReturn을 정의하기
- given#willReturn을 사용하여 순서에 따른 리턴 값을 정의할 수 있다. 이 때 varargs 혹은 체이닝을 사용할 수 있다.
- 체이닝의 장점은 예외를 던질 수 있다.
@Test
void test4_givenVarargsReturnSequentially_thenSuccess(){
Executor mock = mock(Executor.class);
given(mock.getValue(any())).willReturn("world", "WORLD");
assertThat(mock.getValue(new Request("any string input!"))).isEqualTo("world");
assertThat(mock.getValue(new Request("any string input!"))).isEqualTo("WORLD");
}
@Test
void test5_givenChainReturnSequentially_thenSuccess(){
Executor mock = mock(Executor.class);
given(mock.getValue(any())).willReturn("world")
.willReturn( "WORLD")
.willThrow(new IllegalArgumentException("!!"));
assertThat(mock.getValue(new Request("any string input!"))).isEqualTo("world");
assertThat(mock.getValue(new Request("any string input!"))).isEqualTo("WORLD");
assertThatThrownBy(()-> mock.getValue(new Request("any string input!")))
.isInstanceOf(IllegalArgumentException.class)
.message().isEqualTo("!!");
}
- 다만, 인자에 따른 리턴을 정의할 수 없다는 문제가 있다.
- 짝을 “hello” - “world”와 “HELLO” - “WORLD”로 맵핑할 필요가 있을 경우 사용할 수 없다.
객체의 동등성 비교를 통한 리턴값 정의하기 : willAnswer와 Answer
- 객체의 동등성에 따른 리턴 값을 정의하려면 willAnswer나 will의 인자인
Answer<T> answer
를 구현해야 한다. - willAnswer과 will이 동일하게 동작하는 것으로 보인다.
@Test
void test6_givenAnswerImplements_thenSuccess(){
Executor mock = mock(Executor.class);
given(mock.getValue(any())).willAnswer(invocation -> {
List<String> list = new ArrayList<>();
for (Object a : invocation.getArguments()) {
Request r = (Request) a;
list.add(r.getValue());
}
if(list.contains("hello")) return "world";
if(list.contains("HELLO")) return "WORLD";
if(list.contains("안녕")) throw new IllegalArgumentException("에러!");
return null;
});
assertThat(mock.getValue(new Request("any string input!"))).isEqualTo(null);
assertThat(mock.getValue(new Request("hello"))).isEqualTo("world");
assertThat(mock.getValue(new Request("HELLO"))).isEqualTo("WORLD");
assertThatThrownBy(() -> mock.getValue(new Request("안녕")))
.isInstanceOf(IllegalArgumentException.class)
.message().isEqualTo("에러!");
}