Dev/TDD

Junit In Action - TDD를 위한 테스트 원칙, 도구 및 활용 Review - 모의 객체로 테스트 하기 (Mock) (2/2)

린네의 2024. 9. 24. 18:02

개발환경

IDE :  intelliJ 
FrameWork : springboot 3.x
Launguage : java 17
TestTool : Junit5 

 

 

 

예제 소스 링크

 

모의 객체 프레임워크 사용해 보기

이전 게시글에서 모의 객체를 밑바닥부터 구현했다. 프레임워크를 사용하면 프로젝트에서 이렇게 모의 객체를 하나하나 새로 생성할 필요 없이 더 쉽게 만들 수 있다. 일반적으로 많이 사용하는 테스트를 위한 프레임워크는 EasyMock, JMock, Mockito 에 대해 간략히 정리하려고 한다.

 

 

 

EasyMock

EasyMock은 모의 객체를 사용하기 위한 유용한 클래스를 제공하는 오픈 소스 프레임워크다. 이전 게시글에서 작성한 예제를 EasyMock 프레임워크를 사용해서 리팩터링 해보자.

 

책에서는 maven 에 대한 설정 예제가 기재되어 있지만, 나는 gradle을 사용하고 있기 때문에 gradle를 기준으로 의존성을 추가했다.

 

  • build.gradle 의존성 추가
// https://mvnrepository.com/artifact/org.easymock/easymock
testImplementation 'org.easymock:easymock:5.4.0'

 

다음은 리팩터링 한 소스이다.

    private AccountManager mockAccountManager;

    @BeforeEach
    public void setUp() {
        mockAccountManager = createMock("mockAccountManager", AccountManager.class);
    }

    @Test
    public void testTransferOK() {
        Account senderAccount = new Account("1", 200);
        Account beneficiaryAccount = new Account("2", 100);

        // 기대를 정의 한다
        mockAccountManager.updateAccount(senderAccount);
        mockAccountManager.updateAccount(beneficiaryAccount);

        expect(mockAccountManager.findAccountForUser("1")).andReturn(senderAccount);
        expect(mockAccountManager.findAccountForUser("2")).andReturn(beneficiaryAccount);

        // 기대 정의가 끝나면 replay를 호출한다
        replay(mockAccountManager);

        AccountService accountService = new AccountService();
        accountService.setAccountManager(mockAccountManager);

        // 테스트 대상 메서드
        accountService.transfer("1", "2", 50);

        Assertions.assertEquals(150, senderAccount.getBalance());
        Assertions.assertEquals(150, beneficiaryAccount.getBalance());
    }

    @AfterEach
    public void tearDown() {
        verify(mockAccountManager); // 기대가 충족 되었는지 검증
    }

 

위 소스에서 주의 깊게 봐야 할 부분에 대해 간단하게 정리했다.

  • createMock("mockAccountManager", AccountManager.class) 

이전 예제에서는 mockAccountManager로 선언하기 위한 Mock 객체를 직접 만들어야 했지만, createMock을 사용해서 간단하게 모의 객체를 선언할 수 있다. 

 

 

  •  expect(mockAccountManager.findAccountForUser("1")).andReturn(senderAccount);
     expect(mockAccountManager.findAccountForUser("2")).andReturn(beneficiaryAccount);

모의 객체의 기대를 정의한다. 즉 findAccountForUser("1")는 senderAccount를 리턴하고, findAccountForUser("2")는 beneficiarAccount를 리턴해야 함을 정의한다. 이전 글에서도 강조했지만, 우리가 테스트할 대상의 행위는 transfer 이기 때문에 transfer가 정상적으로 동작하기 위해 필요한 다른 객체들의 행위에 대해서는 이렇게 어떤 행동을 할지 미리 정의하는 것이다.

 

  • replay(mockAccountManager)

expect 메서드를 통해 기대를 선언한 다음 replay 메서드 호출을 통해 모의 객체의 동작을 활성화 할 수 있다. 즉, 기대한 동작을 수행하게 하는 역할을 한다. 기대한 동작을 수행하게 하기 위해서는 EasyMock에서는 expect와 replay를 같이 사용해야 한다.

 

만약 인터페이스가 아닌 클래스에 대해서도 모의 객체가 필요하다면 EasyMock의 클래스 확장을 사용해야 한다. 다음은 easymockclassextension를 사용하여 클래스를 모의하는 방법이다.

 

책에는 취소선에 작성한 내용대로 기재되어 있지만 인터페이스가 아닌 클래스 모킹시에도 easymockclassextension이 아니라 org.easymock.EasyMock패키지가 import 되는 것을 확인할 수 있었다.  자세한 내용은 아래 접은 글을 참고 바란다.

 

더보기

cf. easymockclassExtension 지원 중단 안내 

EasyMock 3.0부터는 EasyMock.ClassExtension 대신 PowerMock과 같은 다른 라이브러리를 사용할 필요 없이 기본적으로 클래스 모킹 기능을 제공하게 되었습니다. 이전에는 EasyMock.ClassExtension을 사용해 final 클래스나 메서드의 모킹을 할 수 있었지만, EasyMock 3.0부터는 이 기능이 통합되었습니다. 따라서 easymock.classextension.EasyMock는 더 이상 필요하지 않고, 최신 버전에서는 사용되지 않습니다.

 

아래는 책에는 easymockclassextension 사용 예제로 나왔는데, 사실상 첫 번째 예제와 크게 다를 게 없는 예제이다. 여기서 모의해야 할 객체는 총두 개로, 인터페이스인 ConnectionFactory와 클래스인 InputStream에 대해 모의 객체가 필요하다. 각각 인터페이스와 클래스인 점에서 확장된 패키지 사용에서 오는 차이점을 기술하고 싶었던 것 같은데,  2024년 09월을 기준으로 릴리즈 된  5.4.0 버전에서는 모의 대상 객체가 인터페이스냐 클래스냐는 관계없이 동일하게 사용할 수 있다. 

 

public class TestWebClientEasyMock {
    private ConnectionFactory factory;
    private InputStream stream;


    @BeforeEach
    public void setUp() {
        factory = createMock("factory", ConnectionFactory.class);
        stream = createMock("stream", InputStream.class);
    }

    @Test
    public void testGetContentOK() throws Exception{
        expect(factory.getData()).andReturn(stream);
        expect(stream.read()).andReturn(Integer.valueOf((byte) 'W'));
        expect(stream.read()).andReturn(Integer.valueOf((byte) 'o'));
        expect(stream.read()).andReturn(Integer.valueOf((byte) 'r'));
        expect(stream.read()).andReturn(Integer.valueOf((byte) 'k'));
        expect(stream.read()).andReturn(Integer.valueOf((byte) 'd'));
        expect(stream.read()).andReturn(Integer.valueOf((byte) '!'));
        expect(stream.read()).andReturn(-1);

        stream.close();

        replay(factory);
        replay(stream);

        WebClient2 client2 = new WebClient2();
        String workingContent = client2.getContent(factory);

        Assertions.assertEquals("Workd!", workingContent);
        
    }
}

 

 

 

 

JMock

이번에는 JMock을 알아보자. 모의 객체 프레임워크의 기능을 평가하고 다른 프레임워크와 비교하기 위해 같은 시나리오를 다른 프레임워크로 재작업할 것이다. 

 

  • build.gradle 의존성 추가
// https://mvnrepository.com/artifact/org.jmock/jmock-junit5
testImplementation 'org.jmock:jmock-junit5:2.13.1'

 

다음은 JMock을 통한 테스트 코드 예제이다.

public class TestAccountServiceJMock {
    
    @RegisterExtension // 확장 등록 
    Mockery context = new JUnit5Mockery();
    
    private AccountManager mockAccountManager;
    
    @BeforeEach
    public void setUp() {
        mockAccountManager = context.mock(AccountManager.class);
    }
    
    @Test
    public void testTransferOk() {
        Account senderAccount = new Account("1", 200);
        Account beneficiaryAccount = new Account("2", 100);
        
        context.checking(new Expectations() {
            {
                /**
                 *  아래는 기대를 선언하는데, 다음과 같은 문법으로 사용할 수 있다. 
                 *  invocation-count(mock-object) 까지만 필수로 작성해야하고 그 뒤에는 선택적 요소다
                 *  
                 *  //////////////////////////////////////////////////////////////
                 *  invocation-count(mock-object).method(argument-constraints);
                 *  inSequence(sequence-name);
                 *  when(state-machine.is(state-name));
                 *  will(action);
                 *  then(state-machine.is(new-state-name));
                 */
                oneOf(mockAccountManager).findAccountForUser("1");
                will(returnValue(senderAccount));
                oneOf(mockAccountManager).findAccountForUser("2");
                will(returnValue(beneficiaryAccount));
                
                oneOf(mockAccountManager).updateAccount(senderAccount);
                oneOf(mockAccountManager).updateAccount(beneficiaryAccount);
            }
        });

        // 모의 객체 세팅
        AccountService accountService = new AccountService();
        accountService.setAccountManager(mockAccountManager);
        
        // 테스트 대상 
        accountService.transfer("1", "2", 50);

        
        // 예상 결과 단언문
        Assertions.assertEquals(150, senderAccount.getBalance());
        Assertions.assertEquals(150, beneficiaryAccount.getBalance());
    }
     
}

 

 

  •   context.checking(new Expectations() {
                {
                    /**
                     *  아래는 기대를 선언하는데, 다음과 같은 문법으로 사용할 수 있다. 
                     *  invocation-count(mock-object) 까지만 필수로 작성해야 하고 그 뒤에는 선택적 요소다
                     *  
                     *  //////////////////////////////////////////////////////////////
                     *  invocation-count(mock-object).method(argument-constraints);
                     *  inSequence(sequence-name);
                     *  when(state-machine.is(state-name));
                     *  will(action);
                     *  then(state-machine.is(new-state-name));
                     */
                    oneOf(mockAccountManager).findAccountForUser("1");
                    will(returnValue(senderAccount));
                    oneOf(mockAccountManager).findAccountForUser("2");
                    will(returnValue(beneficiaryAccount));
                    
                    oneOf(mockAccountManager).updateAccount(senderAccount);
                    oneOf(mockAccountManager).updateAccount(beneficiaryAccount);
                }
            });

 

JMock은 기대를 위와 같이 선언한다. EasyMock과 다르게 별도로 replay를 수행하는 부분은 없다. 또한 특정 메서드가 기대하는 횟수만큼 호출하는지 검사하는 verify도 사용하지 않아도 된다. JMock 확장이 이 작업을 대신 처리하며 만약 메서드를 기대한 만큼 호출하지 않으면 테스트가 실패하기 때문이다.

 

JMock은 인터페이스에 대해서만 모의 객체를 제공하기 때문에, 클래스 객체에 대해 사용하고자 하면 별도의 설정이 필요하다. 다음 예제를 보자.

 

package com.test.junit.ch08.sample04;

import com.test.junit.ch08.sample2.ClassFactory.ConnectionFactory;
import com.test.junit.ch08.sample2.WebClient2;
import org.jmock.Expectations;
import org.jmock.Mockery;
import org.jmock.junit5.JUnit5Mockery;
import org.jmock.lib.JavaReflectionImposteriser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import java.io.IOException;
import java.io.InputStream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

public class TestWebClientJMock {

    @RegisterExtension
    Mockery context = new JUnit5Mockery() {
        {
            // 인터페이스가 아니라 클래스에 대한 모의 객체를 생성해야 할 경우 
            setImposteriser(JavaReflectionImposteriser.INSTANCE);
        }
    };

    @Test
    public void testGetContentOK() throws Exception {
        ConnectionFactory factory = context.mock(ConnectionFactory.class);
        InputStream mockStream = context.mock(InputStream.class);

        context.checking(new Expectations() {
            {
                //모의 객체가 수행하기를 원하는 동작을 기대
                oneOf(factory).getData();
                //반환하고자 하는 값을 설정한다
                will(returnValue(mockStream));

                atLeast(1).of(mockStream).read();

                // 모의객체가 반환하고자 하는 값을 연속적으로 선언
                will(onConsecutiveCalls(
                        returnValue(Integer.valueOf( (byte)'w')),
                        returnValue(Integer.valueOf( (byte)'o')),
                        returnValue(Integer.valueOf( (byte)'r')),
                        returnValue(Integer.valueOf( (byte)'k')),
                        returnValue(Integer.valueOf( (byte)'s')),
                        returnValue(Integer.valueOf( (byte)'!')),
                        returnValue(-1)));

                oneOf(mockStream).close();

            }
        });

        WebClient2 client2 = new WebClient2();
        String workingContent = client2.getContent(factory);

        assertEquals("Works!", workingContent);
    }

    @Test
    public void testGetContentCannotCloseInputStream() throws Exception {
        ConnectionFactory factory = context.mock(ConnectionFactory.class);
        InputStream mockStream = context.mock(InputStream.class);

        context.checking(new Expectations() {
            {
                oneOf(factory).getData();
                will(returnValue(mockStream));
                oneOf(mockStream).read();
                will(returnValue(-1));
                //close 메서드가 호출될거라는 기대 선언
                oneOf(mockStream).close();
                //예외 조건 테스트를 위한 throwException
                will(throwException(new IOException("cannot close")));
            }
        });

        WebClient2 client2 = new WebClient2();

        String workingContent = client2.getContent(factory);

        assertNull(workingContent);
    }
}

 

위 코드에서  setImposteriser(JavaReflectionImposteriser.INSTANCE); 를 추가함으로써 인터페이스가 아니라 클래스에 대한 모의 객체를 사용할 수 있다.  ( 교재에서는 setImposteriser(ClassImposteriser.INSTANCE)로 기재되어 있지만 ClassImposteriser를 못 찾길래 setImposteriser에서 세팅하고 있는 Imposteriser Interface의 구현체중 하나를 주입했다. ) 

 

 

Mockito

 

마지막으로 정리할 프레임워크는 Mockito다. Mockito는 현재 가장 인기 있는 모의 객체 프레임워크로, Junit5 @Extend With 애노테이션이나 @Mock 애노테이션을 사용하여 Junit5 확장 모델과 통합해 사용할 수 있다. Junit5와 통합이 잘 되어 있기 때문에, 다른 프레임워크보다 더 많이 쓰인다. 

 

이제 지금까지 진행했던 예제를 Mockito를 사용해서 구현해 보자.

 

package com.test.junit.ch08.sample05;

import com.test.junit.ch08.sample1.Account;
import com.test.junit.ch08.sample1.AccountManager;
import com.test.junit.ch08.sample1.AccountService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;

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

@ExtendWith(MockitoExtension.class) // Junit5 테스트 확장
public class TestAccountServiceMockito {

    @Mock // MockitoExtension을 통해 확장되었기 때문에 @Mock을 사용할 수 있다.
    private AccountManager mockAccountManager;

    @Test
    public void testTransferOk() {
        Account senderAccount = new Account("1", 200);
        Account beneficiaryAccount = new Account("2", 100);

//        Mockito.lenient() // lenient() 메서드가 있어야 동일한 findAccountForUser 메서드에 대해 기대를 여러개 선언할 수 있다.
//                .when(mockAccountManager.findAccountForUser("1"))
//                .thenReturn(senderAccount);
//
//        Mockito.lenient().when(mockAccountManager.findAccountForUser("2"))
//                .thenReturn(beneficiaryAccount);


        when(mockAccountManager.findAccountForUser("1"))
                .thenReturn(senderAccount);

        when(mockAccountManager.findAccountForUser("2"))
                .thenReturn(beneficiaryAccount);


        AccountService accountService = new AccountService();
        accountService.setAccountManager(mockAccountManager);
        accountService.transfer("1", "2", 50);

        assertEquals(150, senderAccount.getBalance());
        assertEquals(150, beneficiaryAccount.getBalance());

    }
}

 

 

책에서 lanient() 메서드가 없으면 동일한 findAccountForUser 메서드에 대해 기대를 하나밖에 선언할 수 없기 때문에, 모의 객체 메서드를 엄격(strict)하게 호출하지 않게 하기 위해 사용한다고 적혀있는데, 그냥 when만 사용해도 큰 문제는 없다. ( 주석처리된 구문 참고 )

 

lanient()는정의한 모의 객체가 실제로 호출되지 않아 문제가 발생할 때 사용하는 게 더 적합할 것 같다.

 

 // 오류 발생 x
 Mockito.lenient().when(mockAccountManager.findAccountForUser("3"))
                .thenReturn(null);
                
 // 오류 발생 o 
 /**
 
- 아래 오류가 발생한다.
 ㄴ org.mockito.exceptions.misusing.UnnecessaryStubbingException: 
Unnecessary stubbings detected.
 
 
 */
 when(mockAccountManager.findAccountForUser("3"))
                .thenReturn(null);

 

 

아래는 WebClient 예제를 Mockito 변경한 예제이다.

 

package com.test.junit.ch08.sample05;


import com.test.junit.ch08.sample2.ClassFactory.ConnectionFactory;
import com.test.junit.ch08.sample2.WebClient2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.io.IOException;
import java.io.InputStream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class TestWebClientMockito {
    @Mock
    private ConnectionFactory connectionFactory;
    
    @Mock
    private InputStream inputStream;
    
    @Test
    public void testGetContent() throws  Exception {
        when(connectionFactory.getData()).thenReturn(inputStream);
        when(inputStream.read())
                .thenReturn((int)'W')
                .thenReturn((int)'o')
                .thenReturn((int)'r')
                .thenReturn((int)'k')
                .thenReturn((int)'s')
                .thenReturn((int)'!')
                .thenReturn(-1);

        WebClient2 client2 = new WebClient2();
        
        String workingContent = client2.getContent(connectionFactory);
        
        assertEquals("Works!", workingContent);
    }
    
    @Test
    public void testGetContentCannotCloseInputStream() throws  Exception {
        when(connectionFactory.getData()).thenReturn(inputStream);
        when(inputStream.read()).thenReturn(-1);
        doThrow(new IOException("cannot close")).when(inputStream).close();
        
        WebClient2 webClient2 = new WebClient2();
        
        String workingContent = webClient2.getContent(connectionFactory);
        
        assertNull(workingContent);
    }
}

 

모의객체를 사용하면 코드를 달리 생각해 볼 수 있고 인터페이스나 제어의 역전과 같은 더 나은 디자인 패턴을 적용할 수 있다.

더보기

cf. Mockito 클래스를 상속한 BDDMockito 클래스를 사용하면 given과 willReturn 메서드로 모의 객체의 행동을 기대할 수 있다. 따라서 given-when-then 방식으로 테스트하기 간편해진다.