Mockito를 활용하여 테스트 코드 작성하기

Mockito 란?

Mockito란 Java 오픈소스 테스트 프레임워크입니다. Mockito를 사용하면 실제 객체를 모방한 가짜 객체, Mock 객체 생성이 가능해집니다. 개발자는 이 Mock 객체를 통해 테스트를 보다 간단하고 통일성있게 구현할 수 있습니다.

그렇다면 Mockito가 필요한 순간은 언제일까요? 물론 작성한 프로그램을 테스트할 때일 것입니다. 그런데, 우리가 지금까지 만들어본 다양한 프로그램들을 떠올려봅시다. 간단한 연산만을 수행하는 프로그램도 존재하지만 구동하는데 오랜 시간이 걸리거나 여러 개의 외부 서비스, 모듈이 복잡하게 연결되어있는 프로그램도 존재할 것입니다.

예를 들어 새로운 API client 서비스를 만들어 테스트하거나, 외부 서비스의 API client 혹은 DB와의 연결이 포함된 비즈니스 로직을 테스트해야 한다면 웹 통신과 DB 연결을 위한 소프트웨어가 필요할 것입니다.

이런 복잡한 연관성을 가진 프로그램을 테스트하려면 어떻게 해야할까요? 지금이 바로 Mockito가 필요한 때입니다.

두 가지 간단한 예제 코드를 보며 Mockito의 Mock 객체를 사용하여 테스트 코드를 작성하는 법을 살펴보도록 하겠습니다.

Mockito를 사용한 테스트 코드 예시

Mock 객체를 생성하는 방법은 간단합니다. 기존에 의존성 주입을 위해 사용했던 @Autowired 애노테이션의 용법과 동일하게, 모조 객체를 만들어 사용하고 싶은 클래스를 @Mock 애노테이션혹은 @MockBean 애노테이션을 사용하여 필드 주입해주면 됩니다.

@Mock@MockBean 두 애노테이션의 용법 차이는 두 개의 테스트 코드 예시와 함께 설명하도록 하겠습니다.

0. Mockito Dependency 추가

Mockito를 사용하기 위해 각자의 환경에 맞게 Dependency를 추가해줍니다. 저는 SpringBoot와 Gradle Tool을 사용했기 때문에 다음과 같이 build.gradle 파일에 Mockito Core 를 추가해주었습니다.

// build.gradle
dependencies {
    testImplementation 'org.mockito:mockito-core:4.8.0
}

1. @Mock 기본 예제

다음과 같이 간단한 String 값을 반환하는 REST API가 있습니다. 우리의 목표는 이 API를 통해 만들어진 API client 서비스를 Mockito를 활용하여 테스트하는 것입니다.

@RequestMapping("tests")
@RestController
@RequiredArgsConstructor
public class TestResource {
    //
    @GetMapping("{id}")
    public String mockTest(@PathVariable("id") String id) {
        //
        return "actualResult";
    }
}

이제 Tester 클래스를 만들 차례입니다. 다음과 같은 Tester 클래스를 생성해줍니다. 저는 JUnit 5 버전을 사용했습니다.

@Slf4j
@ExtendWith(SpringExtension.class)
@AutoConfigureWebTestClient
@RequiredArgsConstructor
public class MockitoTester {
    //
    private WebTestClient webTestClient;
    @Mock
    private TestResource testResource;

    @BeforeEach
    public void beforeEach() {
        //
        this.webTestClient = WebTestClient.bindToController(this.testResource).build();
    }
}

@AutoConfigureWebTestClientWebTestClient 인터페이스를 사용할 수 있게 해주는 애노테이션입니다. WebTestClient 는 API client service에서 사용하는 WebClient와 같은 역할을 해주는 인터페이스로, HTTP connection을 내부적으로 수행하여 mock request와 response를 제공해 client service의 테스트를 가능케 해줍니다.

이제 테스터 클래스에 @Mock 을 통해 테스트하고자 하는 Controller인 TestResource의 모방 객체를 주입해주었습니다. 그리고 beforeEach 메서드를 통해 각 테스트를 실행하기 이전에 테스트할 Controller를 WebTestClient에 구성해줍니다.

여기까지 해주었다면 모방한 객체를 사용하여 테스트를 할 준비가 끝났습니다. 이제 모방한 객체의 구동을 테스트의 목적에 맞게 적절히 조작하기만 하면 됩니다.

@Test
public void mockTest() {
    //
    String id = "INPUT-ID";
    String expectedResult = "mockedResult";

		**// given**
    Mockito.when(testResource.mockTest(ArgumentMatchers.anyString()))
           .thenAnswer(new Answer<String>() {
        @Override
        public String answer(InvocationOnMock invocation) throws Throwable {
            if (id.equals(invocation.getArgument(0))) {
                return expectedResult;
            }
            return "failed";
        }
    });

		**// when**
    WebTestClient.ResponseSpec responseSpec = webTestClient.get().uri("/tests/" + id)
            .exchange().expectStatus().is2xxSuccessful();
    String result = responseSpec.expectBody(String.class).returnResult().getResponseBody();

		**// then**
    Assertions.assertEquals(expectedResult, result);
}

여기서 테스트는 메서드의 실행에 따른 상태의 변화를 테스트하기 위해 given, when, then 세 구조로 이루어진 패턴으로 실행될 것입니다. 테스트는 대상의 주어진 상태(given)에서 출발하여, 테스트 대상의 행위로 인해 상태 변화가 가해지면(when), 실행 결과로써 기대하는 상태로 완료되어야(then) 합니다.

  • given 단계

    먼저 Mockito의 whenthen 메서드를 통해 테스트 대상의 given을 설정합니다.

    (이때 위에서 말한 테스트 패턴의 when과는 다름을 명심하여야 합니다. 이러한 시나리오 기반 패턴에서의 혼동을 막기위해 BDDMockito가 제공되고 있습니다.)

    when 메서드는 테스트하고자 하는 메서드를 텅 빈 stub 상태로 불러옵니다. 테스트 대상의 실행 결과를 조작할 상태를 지정하는 것입니다. 여기서는 TestResourcemockTest 메서드가 String 타입 파라미터를 받아 실행될 때입니다. (ArgumentMatchers.anyString() 을 통해 ‘String 타입의 어떤 값이 parameter로 들어올 때이든’ 이라고 명시할 수 있습니다.)

    그리고 thenAnswer 메서드를 통해 해당 메서드가 실행되었을 때 응답을 지정합니다. mockTest 메서드가 실행되었을 때, 만약 파라미터로 입력된 값이 제가 원하는 값과 동일할 경우에는 지정한 String 값(”expectedResult")이 반환되도록, 그렇지 않을 경우에는 (테스트가 실패했을 때) 다른 String 값(”failed”)이 반환되도록 설정해주었습니다. 여기서 thenAnswer가 아닌 다른 메서드를 통해서도 메서드 실행 시 실행될 상태를 지정해줄 수도 있습니다만, 가장 현재 테스트에 적합하고 명시적인 thenAnswer를 사용해주도록 하겠습니다.

  • when 단계

    given으로 정해진 상태를 테스트하기 위해 상태 변화를 가합니다. 즉, 메서드를 실행시켜줍니다.

  • then 단계

    when 단계에서 얻은 결과값이 제가 원하는 결과값과 같은지 확인합니다. 결과적으로 위 코드는 result“failed”도, “actualResult”도 아닌 “expectedResult”와 같다고 출력하게 됩니다.

이로써 직접 서버를 구동하여 통신하지 않고서도 정상적으로 요청이 보내지고, 응답값을 받아오는지 확인할 수 있습니다.

@MockBean을 활용한 예제 코드

그렇다면 @MockBean 애노테이션은 언제 사용하면 좋을까요? 모방 객체를 주입해준다는 점에서 @Mock과는 동일합니다. 다만, @MockBean은 이름에서 알 수 있듯이 모방 객체를 Bean으로써 관리할 수 있도록 만들어준다는 점이 다릅니다. 다음 예제 코드와 함께 하나의 상황을 상상해봅시다.

여기 우리가 테스트하고 싶은 대상인 findById라는 메서드가 있습니다. 이 메서드는 다음과 같이 MockitoClient라는 외부 서비스의 API client service를 포함한 로직을 가지고 있습니다. 여기서 중요한 점은, 우리의 테스트 목적은 findById 메서드의 단위 테스트이지, 외부 서비스인 MockitoClientfindByClient 메서드에 대한 테스트가 아니라는 점입니다.

하지만 findbyId 메서드를 실행시킨다면 MockitoClientfindByClient 또한 실행될 것이며, 이 client의 통신이 정상적으로 구동하지 않아 에러를 발생시킨다면 findbyId 메서드의 실행 또한 중단될 것입니다. findByClient와 관계 없이 findbyId 메서드가 전부 정상 구동이 가능한 상태이더라도 말입니다.

@Service
@RequiredArgsConstructor
public class MockitoLogic {
    //
    private final MockitoClient mockitoClient;

    public Map<String, String> findById(String id) {
        //
        Map<String, String> result = new HashMap<>();
        result.put(id, mockitoClient.findByClient(id));
        return result;
    }
}

@Component
@RequiredArgsConstructor
public class MockitoClient {
    //
    public String findByClient(String id) {
      return "actualResult";
    }
}

이때가 바로 Mockito를 사용해줄 때입니다. 그런데 앞서 본 예제와 같이 MockitoClient를 모방해주기만 해서는 안 됩니다. 아래 코드에서 @MockBean@Mock 으로 바꾸면 어떻게 될까요? 아마 주입된 MockitoClient Bean이 없다는 에러가 발생하거나 통신 에러가 발생하는 등, given으로 주어진 상태와는 다르게 동작할 것입니다.

@Autowired로 필드 주입된 MockitoLogic은 스프링 컨텍스트에 bean으로써 관리되고 있으며, 스프링 컨텍스트는 다시 MockitoClient를 이곳에 주입하기 위해 MockitoClient bean을 탐색할 것입니다. 하지만 @Mock으로 모방된 객체는 해당 테스터에서 모방된, 말 그대로 텅 빈 가짜 객체이므로 bean으로 관리되지 않습니다. 따라서 MockitoLogic을 구동했을 때 이용되는 MockitoClient bean은 모방 객체와 별개인 것입니다.

반면, @MockBean을 사용한다면 모방 객체가 bean으로 관리되며, 스프링 컨텍스트에 동일한 이름의 bean이 이미 존재한다면 이를 MockBean 객체로 바꿔줍니다. 즉, 우리가 만든 모방 객체를 필요한 자리에 bean으로 주입하여 사용할 수 있다는 뜻입니다.

@Slf4j
@SpringBootTest(classes = TestApplication.class)
// @AutoConfigureMockMvc(print = MockMvcPrint.NONE, printOnlyOnFailure = false)
@RequiredArgsConstructor
public class LogicTester {
    //
    @Autowired
    private MockitoLogic mockitoLogic;
    @MockBean
    private MockitoClient mockitoClient;

    @Test
    void mockitoTest() {
        //
        String id = "INPUT-ID";
        String expectResult = "mockedResult";
				**// given**
        Mockito.when(mockitoClient.findByClient(ArgumentMatchers.anyString())).thenReturn(expectResult);
				**// when**
        Map<String, String> result = mockitoLogic.findById(id);
				**// then**
        Assertions.assertEquals(expectResult, result.get(id));
        System.out.println("result" + result.get(id));
    }
}

따라서 when 단계에서 findById를 실행시켰을 때, findById 메서드 내의 findByClinet 메서드는 given 단계에서 설정한 바와 같이 구동하게 될 것이고, 결국 then 단계에서 우리가 원하는 결과를 보여주게 됩니다.

두 예제를 통해 Mockito를 사용하는 방법에 대해 간단히 알아보았습니다. 이 글이 Mockito를 이해하는 데 도움이 되었길 바랍니다.

Marc

참고자료