Post

[Book - JUnit IN ACTION 3판] 8. 모의 객체로 테스트하기

개별 메서드를 단위 테스트할 때 환경이나 다른 메서드와 격리하는 것은 확실히 좋은 목표다.

스텁을 활용하면 소스 코드를 웹 서버, 파일 시스템, 데이터베이스 등의 환경에서 격리하여 단위 테스트를 할 수 있다.

그렇다면 메서드 호출을 다른 클래스로부터 격리하는 것과 같이 조금 더 세밀한 격리를 해야 할 때는?
모의 객체를 사용하면 된다.
모의 객체를 사용하면 가능한 한 가장 세밀한 수준에서 테스트를 실행 할 수 있다.
즉, 각 메서드별로 개별적인 단위 테스트를 만들어 개발하는 것이 가능하다.

모의 객체란 무엇인가

다른 클래스나 메서드와 격리된 상태에서 테스트를 하면 큰 이점이 있다.
작업할 인터페이스가 구성되어 있기만 하다면 아직 완성되지 않은 코드도 테스트할 수 있다는 것이다.
격리된 상태에서의 테스트는 다른 부분의 코드가 완성되는 것을 기다리지 않고 한 부분의 코드를 단위 테스트하는 데 큰 도움이 된다.

모의 개체를 사용할 때의 가장 큰 장점은 메서드에 집중하는 테스트를 만들 수 있다는 것이다.
모의 객체를 사용하면 테스트 대상 메서드가 다른 객체를 호출해서 발생하는 부수 효과가 생길 일이 없다.

단위 테스트의 장점이 리팩터링할 수 있도록 확신을 준다는 것임을 기억하자.
단위 테스트는 회귀를 막기 위한 안전장치다.

모의 객체를 활용해 세밀한 테스트를 하면 더 적은 테스트가 영향을 받게 되고, 실패의 원인을 정확히 찾아 주는 간명한 에러 메시지를 받을 수 있다.

모의 객체는 비즈니스 로직의 일부만을 다른 부분과 격리해 테스트하는 데 적합하다.
모의 객체는 테스트 대상인 메서드가 사용하는 객체를 대체하므로, 테스트 대상 메서드는 다른 객체와 격리되는 효과가 생긴다.

그런 의미에서 모의 객체는 스텁과 비슷하기도 하다.
그러나 모의 객체는 비즈니스 로직을 새로 구현할 필요가 없다.
모의 객체는 테스트가 가짜로 만든 클래스의 모든 비즈니스 로직을 제어하도록 하는 껍데기일 뿐이다.

스텁은 사전에 정의된 동작만 수행한다. 간단한 동작이라도 스텁의 행동은 런타임에 따라 달라지지 않는다.
반면 모의 객체에서 사전에 정의된 동작이 없으며, 대신 테스트를 실행하는 중에 모의 객체가 수행할 행동을 기대할 수 있다.
다른 테스트를 수행할 때는 모의 객체를 새로 초기화한 다음 새로운 행동을 기대하면 된다.

모의 객체를 활용한 테스트는 ‘모의 객체 초기화 > 기대 설정 > 테스트 실행 > 단언문 검증’ 순서로 진행된다.


모의 객체를 활용해 단위 테스트하기

예를 들어 AccountService의 transfer 메서드에서 사용해야 하는 AccountManager 인터페이스가 있을 때
구현체가 준비될 떄까지 AccountManager에 대한 모의 객체를 구현하여 테스트를 별도로 해보자.
(transfer 메서드가 해당 인터페이스를 사용하며, 격리된 상태로 테스트를 해야 하기 때문이다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.HashMap;
import java.util.Map;

public class MockAccountManager implements AccountManager {

    private Map<String, Account> accounts = new HashMap<>();

    public void addAccount(String userId, Account account) {
        this.accounts.put(userId, account);
    }

    @Override
    public Account findAccountForUser(String userId) {
        return this.accounts.get(userId);
    }

    @Override
    public void updateAccount(Account account) {
        // 아무것도 하지 않는다.
    }
}
  • addAccount 메서드는 accountsuserId를 갖고 Account 객체를 값으로 갖는 쌍을 추가한다.
    • userId별로 Account 객체를 가지고 있어야 하므로 위 코드에서는 HashMap을 사용한다.
    • 이렇게 모의 객체를 일반화하고 여러 테스트에서 모의 객체를 사용할 수 있게 되었다.
    • 예를 들어 어떤 테스트에서는 계좌 하나를 가지고 모의 객체를 설정할 수 있고, 다른 테스트에서는 계좌 두 개 이상을 가지고 모의 객체를 설정할 수도 있다.
  • findAccountForUser 메서드는 accounts에서 userId를 가지고 Account 객체를 조회한다.
    • 사전에 등록된 계좌 정보만 조회할 수 있다.
  • updateAccount 메서드는 현재 아무 작업도 수행하지 않으며 값을 반환하지도 않는다.
    • 즉, 아무런 비즈니스 로직이 없다.

JUnit 모범 사례: 모의 객체에 비즈니스 로직을 작성하지 않는다.

모의 객체를 작성할 때 가장 중요한 규칙은 모의 객체가 비즈니스 로직을 가져서는 안 된다는 것이다.
모의 객체는 테스트가 시키는 대로만 해야 한다.
다시 말해 순전히 테스트에 의해서만 구동되는 객체가 모의 객체다.
이러한 특성은 모든 로직을 가지고 있는 스텁과 반대된다.

모의 객체에 비즈니스 로직을 넣지 않으면 좋은 점이 두 가지 있다.

  1. 모의 객체를 만들기가 쉬워진다.
  2. 모의 객체는 빈 껍데기이므로 모의 객체를 테스트할 필요가 없다.

이제 모의 객체를 사용해서 AccountService 클래스의 transfer 메서드에 대한 단위 테스트를 만들어 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.junit.jupiter.api.Test;

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

public class TestAccountService {

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

        MockAccountManager mockAccountManager = new MockAccountManager();
        mockAccountManager.addAccount("1", senderAccount);
        mockAccountManager.addAccount("2", beneficiaryAccount);

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

        accountService.transfer("1", "2", 50);

        assertEquals(150, senderAccount.getBalance());
        assertEquals(150, beneficiaryAccount.getBalance());
    }
}
  • 테스트를 설정하는 단계에서 mockAccountManager를 만든다.
  • mockAccountManagertransfer 메서드가 호출될 때 반환할 계좌 정보 두 개를 추가한다.
  • mockAccountManager가 수행할 동작을 기대한다.
  • 즉, 두 계좌 정보를 추가함으로써 진정한 모의 객체가 되는 것이다.

JUnit 모범 사례: 문제될 만한 것만 테스트한다.

Account 클래스를 모의 객체로 만들지 않는다.
굳이 데이터 접근을 위한 객체까지 모의 객체로 만들 필요가 없기 때문이다.
이런 객체는 환경에 크게 영향을 받지도 않고, 기본적으로 단순하다.
그리고 Account 객체를 사용하는 다른 클래스에 대한 테스트가 있다면 Account 객체를 간접적으로 테스트한 것으로 간주할 수 있다.

즉, Account 클래스가 올바르게 동작하지 않는다면 Account 객체를 사용하는 다른 테스트가 실패하고 거기서 문제가 무엇인지 알려 줄 것이다.


모의 객체를 활용해 리팩터링하기

단위 테스트는 런타임 코드의 가장 중요한 클라이언트이며, 다른 클라이언트와 거의 비슷한 수준의 취급을 받아야 한다.
코드가 테스트하기에 충분히 유연하지 않다면 코드를 수정하는 것은 당연하다.

아래 코드는 유연하지 못한 코드의 예시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;

public class DefaultAccountManager1 implements AccountManager {

    private static final Log logger = LogFactory.getLog(DefaultAccountManager1.class);

    @Override
    public Account findAccountForUser(String userId) {
        logger.debug("Getting account for user [" + userId + "]");
        ResourceBundle bundle = PropertyResourceBundle.getBundle("technical");
        String sql = bundle.getString("FIND_ACCOUNT_FOR_USER");

        return null;
    }

    @Override
    public void updateAccount(Account account) {
    }
}
  • logger를 사용하기 위해 Log 객체를 생성한다.
    • Log 객체를 클래스 내부에서 생성했기 때문에 Log 객체를 바꿔서 쓸 수 없다.
    • 예를 들어 테스트를 위해 아무 일도 하지 않는 Log 클래스를 모의하고 싶지만 그럴 수 없다.
  • 적절한 SQL을 가져온다.
    • 만약 SQL이 아닌 XML을 사용하기로 한다면 PropertyResourceBundle 클래스도 마찬가지로 모의할 수 없다.

훌륭한 설계 전략은 클래스 안에서 객체를 직접 생성하는 것이 아니라 비즈니스 로직과 직접 관계없는 객체를 파라미터로 전달하는 것이다.
궁극적으로 로거나 구성 관련 컴포넌트는 여러 곳에서 사용할 수 있도록 최상위 수준으로 올라가야 한다.
이런 전략은 코드를 유연하게 만들고 변화에 잘 적응할 수 있게 한다.

따라서 이러한 문제를 고려하여 소스 코드를 리팩터링해야 한다.

리팩터링 예제

간단한 리팩터링 몇 가지만 적용하면 인터페이스를 동일하게 유지하면서도 내부에서 생성하면 안 되는 도메인 객체를 외부에서 전달하게 수정할 수 있다.

아래 코드는 위 코드를 리팩터링 한 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.example.junitinaction3.chapter08.configurations.Configuration;
import org.example.junitinaction3.chapter08.configurations.DefaultConfiguration;

public class DefaultAccountManager2 implements AccountManager {

    private Log logger;

    private Configuration configuration;

    public DefaultAccountManager2() {
        this(LogFactory.getLog(DefaultAccountManager2.class), new DefaultConfiguration("technical"));
    }

    public DefaultAccountManager2(Log logger, Configuration configuration) {
        this.logger = logger;
        this.configuration = configuration;
    }

    public Account findAccountForUser(String userId) {
        this.logger.debug("Getting account for user [" + userId + "]");
        this.configuration.getSQL("FIND_ACCOUNT_FOR_USER");

        // JDBC를 사용하여 유저의 계좌 정보를 가져오는 비즈니스 로직
        return null;
    }

    public void updateAccount(Account account) {
    }
}
  • 기존의 PropertyResourceBundle을 사용하지 않기 위해 새로운 configuration 필드를 정의했다.
    • 이렇게 바꾸면 상대적으로 모의하기 쉬운 인터페이스를 사용할 수 있으므로 코드를 더 유연하게 만들 수 있고,
    • Configuration 객체를 구현하는 것으로 원하는 작업을 수행할 수 있다.
  • Log, Configuration을 구현한 객체를 파라미터로 받는 생성자로 사용한다고 하면
    • DefaultAccountManager2 클래스를 재사용할 수 있으므로 설계가 더 좋아진다.
  • 따라서 DefaultAccountManager2 클래스는 호출자가 외부에서 제어할 수 있게 된 것이다.

리팩터링 시 고려 사항

리팩터링을 하면 테스트에서 도메인 객체를 제어할 수 있는 비밀 통로를 열어준 격이 된다.
하위 호환성을 유지하면서도 향후 리팩터링할 때도 도움이 되도록 길을 연 것이다.
따라서 호출 클래스가 원하는 시점에 새로운 생성자를 사용할 수도 있다.

단지 클래스에 대해 알고 있는 만큼을 제공하고, 그런 다음 클래스가 원하는 것이 무엇인지 알기 위해 클래스의 행동을 주시해 보자.

실용적인 디자인 패턴: 제어의 역전(inversion of control, IoC)

제어의 역전을 적용하는 것은 클래스가 직접 책임지지 않는 객체를 내부에서 생성하는 것이 아니라 외부에서 의존성을 통해 주입하는 것을 의미한다.
이때 의존성은 생성자나 setter 메서드로 전달할 수 있고, 또 다른 메서드의 파라미터로 전달할 수 있다.

의존성을 올바르게 구성하는 것은 메서드를 호출한 곳의 책임이지 호출을 받은 곳의 책임이 아니다.


IoC를 활용하면 단위 테스트를 쉽게 작성할 수 있다.
IoC를 활용해서 findAccountByUser 메서드에 대한 테스트를 작성해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import org.example.junitinaction3.chapter08.configurations.MockConfiguration;
import org.junit.jupiter.api.Test;

public class TestDefaultAccountManager {

    @Test
    public void testFindAccountByUser() {
        MockLog logger = new MockLog();
        MockConfiguration configuration = new MockConfiguration();
        configuration.setSQL("SELECT * FROM TEST");

        DefaultAccountManager2 am = new DefaultAccountManager2(logger, configuration);
        Account account = am.findAccountForUser("1234");
    }
}
  • Log 인터페이스를 구현하지만 실제로는 아무 일도 하지 않는 logger 필드를 모의한다.
  • MockConfiguration 객체를 생성하고, configuration.getSQL 메서드를 호출할 때 SQL 쿼리를 반환하도록 설정한다.
  • Log 객체와 Configuration 객체를 생성자에 전달하여 테스트할 DefaultAccountManager2 객체를 생성한다.

이렇게 테스트 코드를 작성하면 테스트 대상 코드의 로깅이나 설정과 관련한 동작을 완벽하게 제어할 수 있다.
결과적으로 코드가 유연해졌으며 다양한 로깅이나 설정을 사용할 수 있게 되었다.
즉, 리팩터링으로 내부 인스턴스를 제어할 수 없었던 문제를 해결하게 된 것이다.

테스트를 소스 코드보다 먼저 작성했을 때 소스 코드가 더욱 유연하도록 설계했다는 점이 중요하다.
단위 테스트 작성의 핵심은 유연성이다.
테스트를 먼저 작성해야 나중에 코드를 유연하게 만들기 위해 리팩터링하는 데 비용이 많이 들지 않는다.


HTTP 연결 모의하기

아래 그림은 테스트를 만들기 전의 HTTP 애플리케이션 아키텍처다. HTTP Application Architecture

모의 객체 정의하기

HTTP Application Mock Architecture

위 그림을 보면 런타임에서 모의 객체로 바꿔친다.

예제 메서드 테스트하기

지정된 URL에 HTTP 연결을 맺고 해당 URL의 콘텐츠를 읽어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class WebClient {
    public String getContent(URL url) {
        StringBuffer content = new StringBuffer();
        try {
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setDoInput(true);
            InputStream is = connection.getInputStream();
            int count;
            while (-1 != (count = is.read())) {
                content.append(new String(Character.toChars(count)));
            }
        } catch (IOException e) {
            return null;
        }
        return content.toString();
    }

}

위 코드는 HTTP 연결을 맺고, 그 연결에서 가져온 모든 콘텐츠를 읽어 들인다.
만약 오류가 발생하면 null을 반환한다.

첫 번째 시도: 쉬운 리팩터링 기법

리팩터링 전인 testGetContentOk 메서드를 테스트해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TestWebClientMock {
    
    @Test
    public void testGetContentOk() throws Exception {
        MockHttpURLConnection mockConnection = new MockHttpURLConnection();
        mockConnection.setExpectedInputStream(new ByteArrayInputStream("It works".getBytes()));
    
        MockURL mockURL = new MockURL();
        mockURL.setupOpenConnection(mockConnection);
        WebClient client = new WebClient();
        
        String workingContent = client.getContent(mockURL);
        assertEquals("It works", workingContent);
    }
}
  • 모의 MockHttpURLConnection 객체를 만들고 반환할 스트림 객체를 설정한다.
  • 모의 MockURL 객체를 만들고 반환할 모의 연결을 설정한다.
  • getContent 메서드를 테스트한다.

불행하게도 이 방식은 효과가 없다.
JDK에서 기본으로 제공하는 URL 클래스는 final 클래스이고, 사용 가능한 다른 URL 인터페이스도 없다.
그리고 확장을 위해서는 너무 많은 노력이 필요해 보인다.

모의 객체를 활용해 getContent 메서드를 리팩터링해 보자.
생각해보면 getContent 메서드는 HttpURLConnection 객체를 가져온 다음, 이 객체에서 콘텐츠를 읽어 들이는 두 가지 작업을 수행한다.

아래 코드는 getContent에서 HttpURLConnection 객체를 가져오는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class WebClient1 {

    public String getContent(URL url) {
        StringBuffer content = new StringBuffer();

        try {
            HttpURLConnection connection = createHttpURLConnection(url);
            InputStream is = connection.getInputStream();

            int count;
            while (-1 != (count = is.read())) {
                content.append(new String(Character.toChars(count)));
            }
        } catch (IOException e) {
            return null;
        }

        return content.toString();
    }

    protected HttpURLConnection createHttpURLConnection(URL url) throws IOException {
        return (HttpURLConnection) url.openConnection();
    }
}
  • createHttpURLConnection 메서드를 호출하여 HTTP 연결을 생성한다.

getContent 메서드를 더욱 효과적으로 테스트하기 위해 WebClient1 클래스를 상속하고 createHttpURLConnectioin 메서드를 재정의하는 테스트 헬퍼 클래스를 작성해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
private static class TestableWebClient extends WebClient1 {

    private HttpURLConnection connection;

    public void setHttpURLConnection(HttpURLConnection connection) {
        this.connection = connection;
    }

    public HttpURLConnection createHttpURLConnection(URL url) throws IOException {
        return this.connection;
    }
}

Method Factory라는 일반벅인 리팩터링 방식은 모의할 클래스에 인터페이스가 없을 때 특히 유용하다.
Method Factory 기법은 먼저 대상 클래스를 상속하고, 이를 제어하기 위한 세터 메서드를 추가한다.
그리고 테스트를 위해 원하는 내용을 반환하기 위한 getter 메서드를 재정의한다.

이제 setHttpURLConnection 메서드를 사용하여 모의로 만든 HttpURLConnection 객체로 수정해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TestWebClientMock {

    @Test
    public void testGetContentOk() throws Exception {
        MockHttpURLConnection mockConnection = new MockHttpURLConnection();
        mockConnection.setExpectedInputStream(new ByteArrayInputStream("It works".getBytes()));

        TestableWebClient client = new TestableWebClient();
        client.setHttpURLConnection(mockConnection);

        String result = client.getContent(new URI("http://127.0.0.1").toURL());

        assertEquals("It works", result);
    }
}
  • createHttpURLConncetion 메서드가 모의로 만든 MockHttpURLConnection 객체를 반환하도록 TestableWebClient를 설정한다.

Method Factory는 괜찮은 기법이지만 완벽하지는 않다.
테스트 대상 클래스를 서브클래싱하면 로직이 바뀌기 때문이다.

두 번째 시도: 클래스 팩터리를 사용한 리팩터링

IoC를 적용하여 또 다른 리팩터링을 시도해 보자.

1
2
3
4
5
6
import java.io.InputStream;

public interface ConnectionFactory {

    InputStream getData() throws Exception;
}

ConnectionFactory 인터페이스를 구현한 클래스의 역할은 (HTTP, TCP/IP 등) 연결의 종류가 무엇이든 적절한 InputStream 객체를 반환하는 것이다.
이러한 기법을 Class Factory라고 한다.

아래 코드는 ConnectionFactory 인터페이스를 사용하여 WebClient를 리팩터링한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.InputStream;

public class WebClient2 {

    public String getContent(ConnectionFactory connectionFactory) {
        String workingContent;

        StringBuffer content = new StringBuffer();
        try (InputStream is = connectionFactory.getData()) {
            int count;
            while (-1 != (count = is.read())) {
                content.append(new String(Character.toChars(count)));
            }

            workingContent = content.toString();
        } catch (Exception e) {
            workingContent = null;
        }

        return workingContent;
    }
}

리팩터링하기 전에는 HTTP 프로토콜을 사용하는 URL에서만 작동하는 반면에
리팩터링한 후에는 어떠한 표준 프로토콜(file://, http://, ftp://, jar:// 등) 또는 사용자 정의 프로토콜에서도 잘 작동한다.

이제 HTTP 프로토콜에 대한 ConnectionFactoryCustom 인터페이스를 구현해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;

public class HttpURLConnectionFactory implements ConnectionFactoryCustom {

    private final URL url;

    public HttpURLConnectionFactory(URL url) {
        this.url = url;
    }

    @Override
    public InputStream getData() throws Exception {
        HttpURLConnection connection = (HttpURLConnection) this.url.openConnection();
        return connection.getInputStream();
    }
}

이제 ConnectionFactoryCustom 클래스에 대한 모의 객체를 만들어 getContent 메서드를 쉽게 테스트해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.io.InputStream;

public class MockConnectionFactory implements ConnectionFactoryCustom {

    private InputStream inputStream;

    @Override
    public InputStream getData() throws Exception {
        return inputStream;
    }

    public void setData(InputStream stream) {
        this.inputStream = stream;
    }
}

모의 객체에는 어떠한 비즈니스 로직도 들어 있지 않으며 외부에서 setData 메서드를 통해 완벽하게 제어할 수 있다.
이제 MockConnectionFactory를 사용하도록 테스트를 리팩터링해 보자.

아래 코드는 최종적으로 MockConnectionFactory를 사용해 리팩터링된 테스트다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.junit.jupiter.api.Test;

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

public class TestWebClient {
    @Test
    public void testGetContentOk() throws Exception {
        MockConnectionFactory mockConnectionFactory = new MockConnectionFactory();
        MockInputStream mockStream = new MockInputStream();
        mockStream.setBuffer("It works");

        mockConnectionFactory.setData(mockStream);

        WebClient2 client = new WebClient2();

        String workingContent = client.getContent(mockConnectionFactory);

        assertEquals("It works", workingContent);
        mockStream.verify();
    }
}

이렇게 메서드를 리팽터링하면, 변화에 더 잘 대응할 수 있는 확장 가능한 구현으로 개선할 수 있다.


모의 객체를 트로이 목마로 사용하기

기대(expectation)

모의 객체를 외부에서 호출하는 클래스가 정확하게 행동했는지 검증하기 위해 모의 객체에 내장된 기능이다.
예를 들어 데이터베이스 커넥션을 모의한 객체는 close 메서드가 모의 객체와 관련한 테스트 중에 정확히 한 번 호출되는지 검증할 수 있다.


자원 누수를 방지하기 위해 쓰고 난 자원은 반드시 반납해야 한다는 기대를 검증해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import java.io.IOException;
import java.io.InputStream;

public class MockInputStream extends InputStream {

    private String buffer;

    private int position = 0;

    private int closeCount = 0;

    public void setBuffer(String buffer) {
        this.buffer = buffer;
    }

    public int read() throws IOException {
        if (position == this.buffer.length()) {
            return -1;
        }

        return buffer.charAt(this.position++);
    }

    public void close()
            throws IOException {
        closeCount++;
        super.close();
    }

    public void verify()
            throws java.lang.AssertionError {
        if (closeCount != 1) {
            throw new AssertionError("close() should " + "have been called once and once only");
        }
    }
}
  • read() 메서드가 반환해야 하는 내용을 모의했다.
  • close() 메서드에서 호출된 반환 횟수를 센다.
  • verify() 메서드에서 기대가 충족되었는지를 검증한다.

새로운 MockInputStream 객체를 사용하기 위해서 testGetContentOk 테스트를 아래와 같이 수정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.junit.jupiter.api.Test;

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

public class TestWebClientFail {

    @Test
    public void testGetContentOk() {
        MockConnectionFactory mockConnectionFactory = new MockConnectionFactory();
        MockInputStream mockStream = new MockInputStream();
        mockStream.setBuffer("It works");

        mockConnectionFactory.setData(mockStream);

        WebClient2 client = new WebClient2();

        String workingContent = client.getContent(mockConnectionFactory);

        assertEquals("It works", workingContent);
        mockStream.verify();
    }
}

이전에는 ByteArrayInputStream을 사용했다면 위 코드에서는 MockInputStream을 사용한다.
모든 기대가 충족되었는지를 검증하기 위해 테스트의 끝부분에 MockInputStream의 verify 메서드를 호출한다.

기대는 아래와 같은 용도로도 많이 사용한다.

  • 컴포넌트 매니저가 컴포넌트 생애주기 메서드를 호출하는 경우 해당 메서드가 지정된 순서대로 호출될 것을 기대
  • 모의 객체에 적절한 값이 파라미터로 전달됐는지를 기대

일반적으로 기대는 테스트 중에 모의 객체가 우리가 원하는 방식으로 행동하는지 검증하는 것과 동시에 모의 객체를 사용한 내역에 대해 피드백을 받고자 할 때 유용하다.
테스트는 메서드 호출 수, 메서드에 전달되는 파라미터, 메서드가 호출되는 순서에 관한 정보를 제공할 수 있다.


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

지금까지 모의 객체를 밑바닥부터 구현했다.
하지만 프레임워크를 잘 사용하면 프로젝트에서 모의 객체를 더 쉽게 만들 수 있다.

앞서 개발한 HTTP 연결 예제를 Mockito를 사용하여 재작업해 보자.

모의 객체 프레임워크는 EasyMock, JMock, Mockito가 있는데 가장 인기있고 JUnit 5에 잘 녹아들어 있는 Mockito를 정리하고, 나머지는 책에서 읽어보고 넘어간다.

Mockito

모의 AccountManager 객체를 만들어 계좌 이체를 테스트해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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;

@ExtendWith(MockitoExtension.class)
public class TestAccountServiceMockito {

    @Mock
    private AccountManager mockAccountManager;

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

        Mockito.lenient()
                .when(mockAccountManager.findAccountForUser("1"))
                .thenReturn(senderAccount);
        Mockito.lenient()
                .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());
    }
}
  • @ExtendWith(MockitoExtension.class)로 JUnit 5 테스트를 확장한다.
  • @Mock으로 모의 AccountManager 객체를 생성한다.
  • 계좌 이체할 계좌 (senderAccount, beneficiaryAccount)를 선언한다.
  • when() 메서드를 사용하여 모의 객체가 수행할 동작을 기대한다.
    • 추가적으로 테스트에서 모의 객체 메서드를 엄격하게(strict) 호출하지 못하도록 lenient() 메서드를 사용한다.
      • lenient() 메서드가 없으면 동일한 findAccountForUser() 메서드에 대해 기대를 하나밖에 선언할 수 없다.
      • 현재는 두 개의 기대가 필요한 상황이다.
  • transfer() 메서드로 한 계좌에서 다른 계좌로 계좌 이체를 시작한다.
  • assertEquals() 메서드로 예상 결과를 단언한다.


이번에는 WebClient를 재작업해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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 ConnectionFactoryCustom mockFactory;

    @Mock
    private InputStream mockStream;

    @Test
    public void testGetContentOk() throws Exception {
        when(mockFactory.getData()).thenReturn(mockStream);
        when(mockStream.read())
                .thenReturn((int) 'W')
                .thenReturn((int) 'o')
                .thenReturn((int) 'r')
                .thenReturn((int) 'k')
                .thenReturn((int) 's')
                .thenReturn((int) '!')
                .thenReturn(-1);
        WebClient2 client = new WebClient2();

        String workingContent = client.getContent(mockFactory);
        assertEquals("Works!", workingContent);
    }

    @Test
    public void setGetContentCannotCloseInputStream() throws Exception {
        when(mockFactory.getData()).thenReturn(mockStream);
        when(mockStream.read()).thenReturn(-1);
        doThrow(new IOException("cannot close"))
                .when(mockStream).close();

        WebClient2 client = new WebClient2();

        String workingContent = client.getContent(mockFactory);
        assertNull(workingContent);
    }
}
  • @ExtendWith(MockitoExtension.class)로 JUnit 5 테스트를 확장한다.
  • @Mock으로 ConnectionFactory, InputStream 타입의 모의 객체를 생성한다.
  • testGetContentOk() 테스트
    • when().thenReturn()으로 모의 객체가 수행할 것으로 기대하는 동작을 선언한다.
    • mockStreamread() 메서드가 반환하고자 하는 값을 연속적으로 선언한다.
    • client.getContent(mockFactory)으로 테스트 대상 메서드를 호출한다.
    • assertEquals() 메서드로 예상 결과를 단언한다.
  • setGetContentCannotCloseInputStream() 테스트
    • mockFactory.getData() 문장을 실행했을 때의 기대를 선언한다.
    • mockStream.read() 문장을 실행했을 때의 기대를 선언한다.
    • doThrow() 메서드로 스트림을 닫을 때 IOException을 던지도록 Mockito에 작성해 둔다.

정리

  • 모의 객체를 사용하면 다른 도메인 객체나 환경으로부터 격리된 단위 테스트 코드를 작성할 수 있다.
    • 세밀한 단위 테스트를 만들 때 실행 환경으로부터 자신을 추상화하는 데 도움이 된다.
  • 모의 객체는 테스트가 가짜로 만든 클래스의 모든 비즈니스 로직을 제어하도록 하는 껍데기일 뿐이다.
  • 모의 객체는 비즈니스 로직을 새로 구현할 필요가 없다.
  • 모의 객체를 활용해 세밀한 테스트를 하면 더 적은 테스트가 영향을 받게 되고, 실패의 원인을 정확히 찾아 주는 간명한 에러 메시지를 받을 수 있다.
    • 따라서 리팩터링할 수 있도록 확신을 준다.
  • 단위 테스트 작성의 핵심은 유연성이다.
    • IoC를 활용하면 단위 테스트를 쉽게 작성할 수 있다.
  • JUnit 5 확장 모델과 가장 잘 통합되어 있는 Mockito 모의 객체 프레임워크를 사용하자.
This post is licensed under CC BY 4.0 by the author.

© Yn3. Some rights reserved.