tools & libs/단위테스트(junit, spock)

[junit] jupiter 4. 가짜 객체를 활용한 테스트

  • -

가짜 객체를 이용한 테스팅

프로젝트를 진행하다 보면 진짜 객체를 사용하지 못하거나 사용하지 말아야 할 경우가 왕왕 발생한다.

Service가 Repository를 호출하는 구조의 애플리케이션에서 Service를 테스트한다고 생각해보자.

만약 Service는 만들어졌는데 Repository는 아직 개발 중인 상황이라면 Repository가 개발이 완료될 때까지 Service에 대한 테스트를 미뤄야 할까? 또는 Service에서 Repository를 호출하는 과정에서 오류가 발생했다면 누구의 문제일까? 이처럼 Service의 동작이 Repository에 영향을 받는 게 맞을까? 

Service 만 테스트 할 수는 없을까?

이런 경우 Repository는 차라리 없다고 생각하고 가짜를 사용하는것이 유리하다.

JUnit은 이런 상황에서 Mockito framework를 이용해서 Stub, Spy 같은 가짜 객체를 만들어 테스트 할 수 있다.("모히또 가서 몰디브 한잔 해야지" 그 모히또 맞다 ㅎ)

Mockito framework site

 

Mockito framework site

Intro Why How More Who Links Training Why drink it? Mockito is a mocking framework that tastes really good. It lets you write beautiful tests with a clean & simple API. Mockito doesn’t give you hangover because the tests are very readable and they produc

site.mockito.org

 

테스트 상황

우리는 학생의 점수를 등록하고 조회하는 시스템을 구축중이다.  이 시스템은 ScoreServiceImpl이 Has a 관계로 ScoreRepository를 사용한다. 문제는 ScoreRepository는 interface로 기능이 선언된 상태이며 아직 미구현 단계이다. 어떻게 하면 효율적으로 ScoreServiceImpl을 테스트할 수 있을까?

 

ScoreRepository

package com.quietjun.service;

import java.util.List;

public interface ScoreRepository {
    /**
     * name 학생의 점수를 반환한다.
     * @param name
     * @return
     * @throws IllegalStateException name에 해당하는 학생이 없을 경우
     */
    Integer getScore(String name) throws IllegalStateException;

    /**
     * 모든 시험 점수를 반환한다.
     * @return
     * @throws IllegalStateException 아직 점수가 하나도 없을 때
     */
    List<Integer> scores() throws IllegalStateException;
}

 

ScoreServiceImpl

package com.quietjun.service;

import java.util.List;
import java.util.stream.Collectors;

public class ScoreServiceImpl {
    private final ScoreRepository repo;

    public ScoreServiceImpl(ScoreRepository repo) {
        this.repo = repo;
    }

    public Integer sumScores() {
        List<Integer> scores = repo.scores();
        return scores.stream().collect(Collectors.summingInt(Integer::intValue));
    }

    public void printScore(String name) {
        System.out.println(name + "의 점수는 " + repo.getScore(name));
    }
}

 

Mockito 주요 설정

 

의존성

jupiter에서 mockito를 사용하기 위해서는 다음의 의존성을 추가한다.

<dependency>
	<groupId>org.mockito</groupId>
	<artifactId>mockito-junit-jupiter</artifactId>
	<version>2.23.0</version>
	<scope>test</scope>
</dependency>

 

기본 사용법

  • jupiter와 Mockito를 연결하기 위해서는 @ExtendWith(MockitoExtension.class)를 사용한다.
  • @Mock : 가짜 객체를 만들기 위한 빈 타입에 선언한다.
  • @Spy : 이미 만들어진 객체를 wrapping 해서 사용할 때 선언한다.
  • @InjectMocks : @Mock이나 @Spy로 만들어진 가짜 객체를 주입받을 빈 타입에 선언한다.

 

가짜 객체의 활용

 

Mock

Mock은 미리 정의된 값을 반환하거나 예외를 던지도록 조작된 가짜 객체로 테스트 과정에서 호출에 응답할 수 있다.

Mock이 주로 사용되는 경우는 위의 ServiceRepository처럼 아직 안만들어져서 포함할 수 없거나 포함되기를 원치 않는 객체에 사용할 수 있다. 

 

예를 들어 sumScore라는 요청이 들어오는 상황에서 service는 repo의 scores를 호출해 학생들을 점수를 가져와서 더하려고 한다.

public Integer sumScores() {
    // repo가 값을 전달해주면..
    List<Integer> scores = repo.scores();
    // 그 값들의 합을 반환한다.
    return scores.stream().collect(Collectors.summingInt(Integer::intValue));
}

 

repo가 정상적으로 동작하려면 DB에 select 쿼리를 던져야 한다. 하지만 여기서 테스트 하려는 상황은 "repo가 정확한 값을 주는가?"가 아니라 "어떤 값을 받을 수 있고  repo에서 반환한 어떤 값을 반납할 수 있는가?"이다.  repo가 1,2,3과 같은 dummy 데이터를 반환하건 실제 값을 반환하건 service를 테스트하는 데는 상관이 없는 것이다. 결국 굳이 DB를 연동할 필요는 없이 대충 지정된 값을 반환해주는 녀석이 있으면 된다. 이런 역할을 하는 것이 Mock이다.

 

Mock을 이용한 테스트 1

다음은 Mock 객체의 활용 예이다. 사용된 코드에 대한 설명은 주석으로 대체한다.

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.util.Arrays;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import com.quietjun.service.ScoreRepository;
import com.quietjun.service.ScoreServiceImpl;

@ExtendWith(MockitoExtension.class)
public class UseMock {
	@Mock                 // 아직 구현체가 없으므로 가짜 객체를 생성한다.
	ScoreRepository repo;
	
	@InjectMocks		  // 가짜 객체를 주입 받는다.
	ScoreServiceImpl impl;
	
	@Test
	@DisplayName("repo가 아직 만들어지지 않았지만 service 테스트")
	public void testsumscore() {
		// given - 가짜 객체의 동작을 정의한다.
		when(repo.scores()).thenReturn(Arrays.asList(1, 2, 3));
		// when
		Integer result = impl.sumScores();
		// then
		assertEquals(result, 6);
	}
}

가짜 객체를 사용하는 부분이 생소한데 when은 가짜 객체의 어떤 메서드가 호출되었을 때를 의미하고 thenXXX는 메서드의 호출 결과인데 thenReturn, thrnThrow 등이 있다. 위의 예에서는 repo의 scores가 호출되면 그냥 1, 2, 3을 가지는 List를 반환하도록 작성되었다. 따라서 이를 사용하는 service는 6을 반환하면 성공이다.

 

Mock을 이용한 테스트 2

다음은 예외 상황에 대한 테스트 이다.

@Test
@DisplayName("repo에서 예외가 발생한다면?")
public void testsumscoreException() {
	// given
	when(repo.getScore("hong")).thenThrow(IllegalStateException.class);
	// when
	Exception e = assertThrows(IllegalStateException.class, ()-> impl.printScore("hong"));
	// then
	assertEquals(e.getClass().getName(), "java.lang.IllegalStateException");
	verify(repo, times(1)).getScore("hong"); // 해당 메서드가 1번 호출되었고 파라미터는 hong
}

mock인 repo의 getScore를 hong을 전달해서 호출하면 IllegalStateException이 발생하도록 작성되었다.

따라서 이 메서드를 service에서 호출하면 예외가 발생해야 한다. 추가로 Mockito.verify는 mock 객체인 repo의 getScore가 몇 번 호출되었는지(times) 검증할 수 있다.

메서드 사용 예 설명
verify(mock).methodName(); 최소 1회 호출되어야 함.
verify(mock, times(n)).methodName(); 정확히 n회 호출되었는지 확인.
verify(mock, never()).methodName(); 호출되지 않았는지 확인.
verify(mock, atLeast(n)).methodName(); 최소 n회 호출되었는지 확인.
verify(mock, atMost(n)).methodName(); 최대 n회 호출되었는지 확인.

또한 전달된 파라미터가 무엇인지를 검증할 수도 있는데 특별한 값을 사용하거나 ArgumentMatcher를 사용할 수도 있다.

verify(repo).getScore("hong");                              // 파라미터는 hong
verify(repo).getScore(ArgumentMatchers.any(String.class));  // 파라미터는 String 타입
verify(repo).getScore(ArgumentMatchers.anyString());        // 위 테스트의 간단한 표현

 

@Spy vs @Mock

앞서 살펴봤던 @Mock이 아직 존재하지 않는 객체에 대한 처리였다면 @Spy는 현재 만들어져 있는 객체에 대한 wrapping 작업을 수행한다.

@Mock // 구현체를 지정하지 않는다. - 그냥 깡통
private List<String> mockList;

@Spy // 구현체가 있다. 필요한 부분만 재정의
private List<String> spyList = new ArrayList<>();

 

따라서 @Mock을 이용하면 재정의하지 않은 메서드를 호출하면 반환형의 default 값들이 반환되지만 @Spy를 이용하면 재정의하지 않는 메서드를 호출하면 객체가 반환하는 값을 그대로 반환한다.

@Test
@DisplayName("Mock 객체의 재정의 하지 않은 메서드 호출: default 반환")
public void testMock() {
  // given - 재정의하지 않은 메서드 호출
  mockList.add("test");
  // when - 재정의하지 않은 메서드 호출
  String str = mockList.get(0);
  // then
  assertNull(str);
}

@Test
@DisplayName("Spy 객체의 재정의하지 않은 메서드 호출:  정상 값 반환")
public void testSpyList() {
  // given
  spyList.add("test");
  // when
  String str = spyList.get(0);
  // then
  assertEquals("test", str);
}

 

하지만 일반적으로 필요한 메서드를 재정의하고 사용하는 과정은 동일하다(아니 유사하다 ㅎ).

@Test                                                             
@DisplayName("Mock 객체의 재정의한 메서드 호출: 지정한 값 반환")                    
public void testMockWithStub() {                                  
  // given                                                      
  String expected = "Mock";                                     
  when(mockList.get(100)).thenReturn(expected);                       
  // when                                                       
  String val = mockList.get(100);                                     
  // then                                                       
  assertEquals(expected, val);                                  
}                                                                 

@Test                                                             
@DisplayName("Spy 객체의 재정의한 메서드 호출: 지정한 값 반환")                     
public void testSpyWithStub() {                                   
  // given                                                      
  String expected = "Spy";                                      
  //when: when(spy.get(100)).thenReturn(expected)이 아닌 점을 주의한다.  
  doReturn(expected).when(spyList).get(100);                        
  String val = spyList.get(100);                                    
  // then                                                       
  assertEquals(expected, val);                                  
}

 

@Mock과 @Spy를 이용한 List 타입의 객체에 각각 get 메서드를 재정의하고 100번째 요소를 요청하면 각각 Mock과 Spy를 반환하도록 했다. 단위 테스트를 통과하는데 자세히 보면 약간의 차이점이 존재한다.

@Mock에서는 when --> thenReturn을 사용했고 @Spy에서는 doReturn --> when을 사용했다. 이 차이는 무엇일까?

 

when-thenReturn vs doReturn-when

 

언제나 기본적으로 권장되는 메서드는 when - thenReturn 형태이다. doReturn은 어쩔 수 없이 when - thenReturn을 사용할 수 없을 때 사용한다. when-thenReturn이 권장되는 이유는 파라미터에 대해 type safe 하고 특히 연속적으로 stub을 호출할 때 가독성이 좋기 때문이다. 

항목 when(...).thenReturn(...) doReturn(...).when(...)
사용예 when(mock.method()).thenReturn("Str"); doReturn("Str").when(mock).method();
타입 안전성 반환 타입 체크 (일치해야 함): method 반환 타입과 thenReturn의 파라미터 불일치시 컴파일 오류 반환 타입 체크 없음:  method return 타입과 thenReturn의 파라미터 불일치시 오류 발생 없음
호출 방식 실제 메서드를 호출하므로 side effect가 발생 실제 메서드를 호출하지 않으며 side effect가 발생하지 않음
예외 처리 호출 시 예외 처리 가능 부작용 발생 방지
사용 예 일반 단위 테스트에서 사용 void 메서드나 스파이 객체에서 원본 메서드 호출 피하기

 

다음의 호출 상황을 생각해 보자.

@Test
@DisplayName("thenReturn: 실제 메서드를 호출, doReturn: 실제 메서드를 호출하지 않음")
public void spyVsMock1() {
  // given                                                                                      
  String expected = "Spy";
  // when                                                                                       
  when(spy.get(0)).thenReturn(expected); // 실제 메서드 호출-> ArrayIndexOutofBoundsException 발생   
  //doReturn(expected).when(spyList).get(0); // 실제 메서드 호출X
  String val = spyList.get(0);
  // then                                                                                       
  assertEquals(expected, val);
}

List의 get 메서드를 stub 해서 0번째를 요청하면 expected를 반환하게 하고 싶었으나 실제로 get을 호출하면 데이터가 없기 때문에 when-thenReturn에서는 ArrayIndexOutofBoundsException이 발생한다. 이런 경우는 doReturn-when을 사용해야 한다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.