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("에러!");
}