Mockito Test Framework 알아보기

AUSG·2020년 7월 7일
20
post-custom-banner

크롤링 서버 프로그램을 작성하는 인턴십을 수행하며 Test 코드를 처음 작성해보게 되었습니다. Test Code는 코드에 대한 Document역할이자, Refactoring시 자신감을 얻을 수 있는 점에서 중요하다 생각합니다. 이때 Mock object는 일종의 가짜 객체로, 함수의 행위(behavior)를 테스트하는데 사용됩니다. 외부 라이브러리나 아직 작성되지 않은 다른 팀원의 코드 등 실제 함수를 테스트하는데 필요한 의존성 객체들을 Mocking하는 것이죠. 제가 궁금해서 찾아본 여러 개념들과 약간의 사용법을 작성해보았습니다. 혹시 틀린 내용이나 수정할 사항은 언제든지 알려주세요!

개발 환경 Setting

Gradle Dependency


//JUnit4
repositories { jcenter() }
dependencies { testImplementation 'org.mockito:mockito-core:2.7.22'}

//JUnit5 
dependencies{ 
    testImplementation('org.junit.jupiter:junit-jupiter-api:5.2.0')
    testCompile('org.junit.jupiter:junit-jupiter-params:5.2.0')
    testRuntime('org.junit.jupiter:junit-jupiter-engine:5.2.0')
}

Mockito를 사용하기 위해 build.gradle에 위의 라인을 추가합니다.

Make Test Class With Mockito


@RunWith(MockitoJUnitRunner.class)
public class StrategyTest(){
...
}

MockitoJUnitRunner.class VS SpringRunner.class

  • [JUnit4] SpringRunner.class : SpringRunner는 SpringJunit4ClassRunnner의 별칭입니다. JUnit 테스팅 라이브러리와 Spring TestContext Framework를 결합해 @RunWith(SpringRunner.class)로 사용합니다. SpringRunner 클래스는 JUnit Test에서 Spring ApplicationContext을 로드하고 테스트 인스턴스에 bean @Autowired를 갖도록 지원합니다. 또한, @MockBean, @SpyBean을 지원합니다. H2 인메모리 데이터베이스와 같은 내장 데이터베이스에 데이터를 유지하려면 SpringRunner.class를 사용해야 합니다. JUnit4에서는 한 Runner만 사용할 수 있어 만약 Spring과 Mockito를 동시에 사용하는 방법이 필요합니다. 이 방법은 아래에 기술하겠습니다. JUnit5 부터는 @RunWith(SpringRunner.class)대신 @ExtendWith(SpringExtension.class)로 사용합니다.
  • Spring TestContext Framework : Spring TestContext Framework는 사용 중인 테스트 프레임워크(JUNit, TestNG)에 구애받지 않는 일반적인 annotation 및 통합 테스트 지원을 제공합니다.
  • MockitoJUnitRunner.class : JUnit4에서 Mockito를 사용하여 mock 및spy를 생성할 수 있도록 지원합니다.
  • MockitoExtension.class: JUnit5부터 @ExtendWith(MockitoExtension.class)를 사용합니다. Rule과 Runner는 사용하지 않습니다.

JUnit4로 테스트를 작성하고 있는데, 단위테스트를 작성하는 데 있어서 ApplicationContext loading이 필요하지 않아 MockitoJUnitRunner.class을 사용하고 있습니다. SpringRunner는 SpringApplicationContext에 빈을 등록하고 @Autowired가능합니다.


그래서 어떻게 쓰지?

//JUnit4
@RunWith(MockitoJUnitRunner.class)
public class TestSpring{

  @Mock
  SdsService sdsService;
  
}

SpringRunner.class는 각 객체 간 연관작용을 test하기에 적합합니다. ApplicationContext을 loading하기 때문에 MockitoJUnitRunner보다는 느린 단점이 있습니다. 이 글에서는 단위테스트에 초점을 맞추기 때문에 MockitoJUnit으로 Test를 진행합니다.

JUnit5로는 아래와 같이 사용할 수 있습니다.

//JUnit5
@ExtendWith(MockitoExtension.class)
class SpringMockTest{
  
  @Mock
  SdsRepository sdsRepository;
  
  @BeforeEach
  void setup(){
    this.sdsService = new SdsService(sdsRepository);
  }
}

Spring에서 @MockBean vs @Mock

글을 읽다가 @MockBean과 @Mock이 어떻게 다른지 궁금해집니다. 처음에 SpringRunner.class를 쓰면 @MockBean, Mockito를 사용하면 @Mock인줄로만 알았지만, 동작에서의 차이를 살펴봅시다.

Spring에서 Servlet Contianer는 개발자가 직접 소켓생성과 특정포트 리스닝을 하는 등의 과정을 없애고 Servlet들의 생성부터 소멸까지 LifeCycle과 MultiThread지원 등을 담당합니다. 'Spring MVC라는 Servlet을 Servlet Container인 Tomcat이 관리한다'라는 말로 설명하면 조금 더 이해가 됩니다. Spring이 제공하는 DI Container 에는 대표적으로 BeanFactory와 이를 상속한 ApplicationContext가 있습니다. Spring은 Bean들을 대부분 ApplicationContext를 통해 관리합니다.

Test도중 Spring에서 어느 의존성도 필요하지 않다면, Mockito의 @Mock을 사용하는 방법이 좋습니다. 왜냐하면, 우리가 바라는 빠르고 의존성없는 unit test로 가는 방향이기 때문입니다. 하지만, test 도중 spring container가 관리하는 bean들 중 하나라도 추가하거나 Mocking하고 싶다면 @MockBean을 선택할 수 있습니다. @MockBean은 ApplicationContext에 mock객체를 추가합니다. 그리고 mock객체는 같은 type의 이미 존재하는 Bean들을 대신할 것입니다. 하지만, @MockBean으로 대신된 context는 다른 context이므로 Spring Boot는 ApplicationContext를 test 초기화 중에 다시 로딩해야합니다.

따라서, 이 @MockBean으로 단위테스트를 작성하기 보다는 Mockito로 빠른 단위 테스트를 작성을 추천합니다.

Mock 객체를 언제 어떻게 사용하나?


다음은 생성자 코드입니다. Spring에서는 여러 클래스가 생성자를 통해 주입을 받는 경우가 많은데요, 이렇게의존관계 주입(DI)이 생기는 클래스를 테스트하는 방법을 알아보려 합니다. 우리의 목적은 Unit Test를 통해 하나의 메서드의 특정 루틴을 검사하는 것입니다. 클래스의 메서드들은 생성자를 통해 주입된 다른 객체의 메서드를 사용하기도 합니다. 이러한 경우에 Unit Test에서 의존관계가 생기게 되어 그 본질을 잃어버리게 됩니다. 따라서, 이를 해결하고 의도한 Unit Test를 수행할 수 있도록 도와주는 Mock객체를 소개해 보겠습니다.

@Autowired
public CrawlerFactory(WebDriverConfiguration webDriverConfiguration,
      ExchangeAddressMapConfiguration exchangeAddressMapConfiguration,
      CalendarService calendarServiceImpl,
      NoticeCrawlingResultService noticeCrawlingResultServiceImpl,
      TimestampConverter timestampConverter) {
    this.webDriverConfiguration = webDriverConfiguration;
    this.exchangeAddressMapConfiguration = exchangeAddressMapConfiguration;
    this.calendarServiceImpl = calendarServiceImpl;
    this.noticeCrawlingResultServiceImpl = noticeCrawlingResultServiceImpl;
    this.timestampConverter = timestampConverter;
  }
  • Mock: 테스트 더블(Mock 상위개념임) 중 하나이며, 행위를 테스트하기 위해 Mock객체를 생성하여 테스트한다.
  • Mockito: Mock 객체를 직접 만들어 테스트코드를 작성하기엔 관리 및 생성부담이 커 Java진영에서 나온 Mock을 지원하는 오픈소스 테스트 프레임워크이다. - 행위검증을 추구 함.
  • Stub: 호출이되면 미리 준비된 답변으로 응답하는 것 - 상태검증을 추구 함.

구현에 의존적인 테스트를 만들어야 한다면 최대한 의존하는 부분을 줄이며 테스트를 만들어야 합니다!!

<느슨한 검증>

  • 호출하는 메소드가 많다면 -> 특정 코드 블럭이 실행되는지(조건문 안에 들어가는지) 정도 확인하는 것으로 충분할 수 있습니다.
  • 특정 조건문에 의해 실행 여부만 확인하는 것 -> 조건문만 통과하는 지를 확인하면 됩니다.

외부 라이브러리를 사용하는 코드 Test Example


<테스트할 코드>

public CrawlingStrategy(String baseURL, NoticeCrawlingResultService noticeCrawlingResultService,
      TimestampConverter timestampConverter, ChromeConnector connector) {
    super(baseURL, noticeCrawlingResultService, timestampConverter, "Crawling");
    this.connector = connector;
  }

<테스트코드>

public class CrawlingStrategyTest{

  @Rule
  public MockitoRule mockitoRule = MockitoJUnit.rule();

  @Mock
  NoticeCrawlingResultService noticeCrawlingResultService;

  @Mock
  TimestampConverter timestampConverter;

  @Mock
  ChromeConnector connector;

  @InjectMocks
  CrawlingStrategy crawlingStrategy;

  @Rule
  public ExpectedException thrown = ExpectedException.none();
  • @InjectMocks

    @Mock이 붙은 목객체를 @InjectMocks이 붙은 객체에 주입시킬 수 있다.

    ex) @InjectMocks(Service) @Mock(DAO) -> Service테스트객체에 DAO 목객체를 주입시켜 사용한다.


public void crawlCurrentPageNotice(String pageHref) {
  connector.get(pageHref);
  WebElement ulListElement = getWebElement("/html/body/main/div[2]/div/section/ul");
  List<String> noticeHrefList = getNoticeHrefList(ulListElement);
  for (String noticeHref : noticeHrefList) {
    connector.get(noticeHref);
    NoticeCrawlingResult noticeCrawlingResult = getNoticeCrawlingResult();
    if (checkConstraintViolation(noticeCrawlingResult)) {
      noticeCrawlingResultService.insertNotice(noticeCrawlingResult);
    }
  }
}

CrawlingStrategy에서 crawlCurrentPageNotice(String pageHref)라는 메서드를 테스트 해봅시다. getWebElement(~)함수는 ChromeDriver가 해당 xpath를 기다리고(wait), 찾는(findElement(By.xpath(~))) 함수입니다. 즉, ChromeDriver라는 외부tool을 사용합니다.

성공하는 테스트라면 noticeCrawlingResultService.insertNotice(noticeCrawlingResult)를 noticeHrefList 사이즈만큼 수행하는 것을 확인하면 될 것입니다. 그 외 부수적인 코드는 mock객체를 통해 외부라이브러리와의 의존성을 끊어줄 것입니다.

<Test 코드>

@Test
public void crawlCurrentPageNotice_Perform_InsertNotice_when_NewNotice_Test() {
    //given
    setUpBeforeCrawlCurrentPageNotice();
    when(noticeCrawlingResultService.findByNoticeId(anyLong()))
        .thenReturn(Collections.emptyList());

    //when
    crawlingStrategy.crawlCurrentPageNotice("pageHref");

    //then
    verify(noticeCrawlingResultService, times(1))
        .findByNoticeId(anyLong());
    verify(noticeCrawlingResultService, times(1))
        .insertNotice(any(NoticeCrawlingResult.class));
  }

given → when → then에 맞춰 테스트 코드를 작성하는 게 가독성과 명확성 측면에서 좋습니다. setUpBeforeCrawlCurrentPageNotice()는 외부 라이브러리 객체를 Mock객체로 만들어 그 라이브러리가 지닌 메서드의 행동을 stubbing합니다. 여기에서 noticeHrefList, 즉 for문 반복횟수를 결정하는 리스트의 사이즈를 1로 만들어줬습니다.

실제 중요한 코드는 noticeCrawlingResultService의 findByNoticeId 메서드가 emptyList를 반환하면 실제 함수는 insertNotice 를 한번(리스트 사이즈 만큼) 수행해야 하는 것입니다. 따라서, 행위기반 검증(verify)을 통해 실제 함수가 의도한 행동대로 동작하는지를 확인해 줍니다.


내가 mocking한 함수가 순서대로 호출되는게 중요한 point라면…

MyClassA mock1= mock(MyClassA.class);
MyClassB mock2= mock(MyClassB.class);

Mockito.doNothing().when(mock1).hiFirst();   
Mockito.doNothing().when(mock2).hiSecond(); 

//create inOrder object passing any mocks that need to be verified in order
InOrder inOrder = inOrder(mock1, mock2);

//following will make sure that mock1was called before mock2
inOrder.verify(mock1).hiFirst();
inOrder.verify(mock2).hiSecond();

InOrder를 사용하면 순서대로 호출되었는지 확인해줍니다.

Exception을 던지는 함수를 test할 때

public List<Cookie> getCookieList(String cookieName) throws IllegalArgumentException(){
  List<Cookie> cookieList = new ArrayList<>(30);
  
  if(cookieName.equals("꼬깔콘"))
    cookieList.add(getCookie(cookieName));
  else
    throw new IllegalArgumentException("우린 이런 과자안팝니다");
}

위의 코드를 test해본다면 어떤 게 필요할까요?

  1. 매개변수가 “꼬깔콘”으로 cookieList에 꼬깔콘cookie객체가 들어감.
  2. 매개변수가 “꼬깔콘”이 아니여서 IllegalArgumentException이 던져지는지 확인해야 함.

<Test - 2번>

@Rule
public ExpectedException thrown = ExpectedException.none();

@Test
public void getCookieList_Throws_IllegalArgumentException_Test(){
  //given
  thrown.expect(IllegalArgumentException.class);
  String cookieName = "sds과자";
  
  //when
  쿠키슈퍼.getCookieList(cookieName);
}

이 방법 뿐 아니라 Exception Test에 있어서 @Test(expected= ~~Exception.class)를 통해 exception을 잡아줄 수 있습니다. 하지만, 이 방법은 Exception이 발생하는지만 확인하는 test이고, Exception 내부에 들어있는 message등 세세한 값들은 검증하지 못합니다. 따라서, 위와 같이 JUnit 4.7 이후 버전부터 @Rule이라는 기능을 지원해 errorCode나 errorMessage들을 확인하는 test를 진행할 수 있습니다.

Summary


Tip

행위검증의 경우 특정 메서드의 호출 여부를 test하기 때문에 사실 실제 구현한 함수와 매우 밀접하게 연관되어있습니다. 즉 테스트와 실제 함수의 의존성이 높아진 것이죠. 즉, 실제 작성한 코드가 변경된다면 테스트코드가 함께 변경될 확률이 매우 높습니다.

테스트는 작성하면서 설계에 있어서 복잡한 설계나 함수가 여러 개의 일을 하는 등의 문제점을 찾아내거나 피드백을 받을 수 있다는 데 장점이 큽니다. 사실, 행위검증의 경우 이러한 문제를 찾아내거나 피드백을 받는 데에 있어 시야가 흐려질 수 있습니다.

Test 코드 작성 중 적절한 때에 좋은 라이브러리나 프레임워크를 찾아 쓰는 것은 매우 효과적일 수 있으나, 이 점을 남용하게 되면 오히려 복잡한 설계를 정당화하는 Test를 만들 수 있음을 주의해야겠다고 생각했습니다.

물론, 상태검증 또한 상태를 검증하기 위해 노출시키는 메서드가 많이 추가 될 수 있다는 점에서 단점을 가집니다.

이처럼, test에 있어 어떤 test를 작성할것인지, 어떤 라이브러리나 프레임워크가 적합한지 고민하고 신중하게 작성해야할 것 같습니다.

🐱‍🐉 책에서 보고 띵문이라고 생각된 문장을 가져와보며 마치겠습니다. 'CORRECT'를 자문자답해봅시다!

글쓴이 👩‍💻

신동선- 👨‍👩‍👧‍👧AUSG (AWS University Student Group) 3기로 활동 중

관심사

  • Spring Boot, 웹 서버 개발 및 배포
  • Docker, AWS

Email

shindongsun0@naver.com

GitHub
shindongsun0

참고 (References) 📃📙📚

profile
AWSKRUG University Student Group의 공식 벨로그 계정입니다. 멤버들이 돌아가며 글을 쓰고 있습니다.
post-custom-banner

4개의 댓글

comment-user-thumbnail
2020년 7월 8일

예외를 던지는지도 테스트한다니..! 생각지도 못했네요 ㅋㅋㅋㅋ

답글 달기
comment-user-thumbnail
2020년 7월 10일

와우 도움이 많이 되겠네요ㅎㅎ 테스트 코드를 작성해야 하는 상황인데 마침 이런 글이

답글 달기
comment-user-thumbnail
2020년 7월 10일

좋은 정보 고맙습니다!!!

답글 달기
comment-user-thumbnail
2020년 7월 10일

좋은 정보 감사해요!

답글 달기