http://www.onjava.com/pub/a/onjava/2005/01/12/mocquer.html

mock1.gif 단위 테스트에서의 Mock 객체
by Lu Jian
01/12/2005

mock객체는 단위테스트전략에 넓은 범위에서 사용이 된다. 이것은 테스트로부터 외부의 필요없는 요소들을 감추고 테스트하기 위한 특별한 함수에 촛점을 맞추어 개발자를 도와준다.

EasyMock 는 실행시에 주어진 인터페이스를 위한 mock객체를 생성할수 있는 잘 알려진 mock툴이다. 이 mock객체의 행위는 테스트 케이스에서 테스트 코드에서 먼저 만나는 것으로 정의될수 있다. EasyMock는 주어진 인터페이스에 따라서 동적으로 프록시(proxy)클래스및 객체를 생성할수 있는 java.lang.reflect.Proxy에 기초로해서 생성된다. 하지만 이것은 프록시의 사용으로 상속의 제한을 가진다. 이것은 단지 인터페이스를 위한 mock객체만 생성할수 있다.

Mocquer 는 비슷한 mock툴이다. 하지만 이것은 인터페이스뿐 아니라 클래스를 위한 mock객체가 생성되는 것을 지원하기 위해 EasyMock의 기능을 확장하였다.

Mocquer소개#

Mocquer는 특정 인터페이스또는 클래스를 위해 동적으로 위임(delegation)하는 클래스및 객체를 생성하는데 사용되는Dunamis project에 기초를 두고 있다. 편의를 위해 EasyMock의 클래스및 메소드의 명명규칙을 따르지만 내부적으로는 다른 방식을 사용한다.

MockControl는 Mocquer객체의 메인 클래스이다. 이것은 mock객체의 생명주기와 기능정의(behavior definition)를 제어한다. 이 클래스내엔 4가지의 메소드가 존재한다.

  • 생명주기 제어 메소드
    public void replay();
    public void verify();
    public void reset();
mock객체는 그것의 생명주기내에 준비(preparing), 수행(working), 체크(checking)의 3가지의 상태를 가진다. 그림 1에서 그것을 볼수 있다.

mock2.gif

Figure 1. Mock object life cycle

초기에 mock객체는 준비(preparing)상태에 있게 된다. mock객체의 행위(behivior)는 이 상태내에서 정의될수 있다. replay()는 mock객체의 상태를 수행(working)로 바꾼다. 이 상태에서 mock객체내의 모든 메소드의 호출은 준비(preparing)상태에서 정의된 행위(behivior)를 따를것이다. verify()를 호출한 후에 mock객체는 체크(checking)상태에 놓이게 된다. MockControl은 그들이 같은지 보기 위해 mock객체의 먼저 정의된 행위(behivior)와 활성화된 행위(behivior)를 비교할것이다. 그 비교룰(match rule)은 이 시점에 논의될 MockControl의 종류에 의해 좌우된다. 개발자는 필요하다면 미리정의된 행위(behivior)를 재사용하기 위해 replay()를 사용할수 있다. 어떤 상태에서라도 reset()를 호출한다면 히스토리상태를 지워지고 초기 준비(preparing)상태로 변경된다.

  • 공장(Factory) 메소드
    public static MockControl createNiceControl(...);
    public static MockControl createControl(...);
    public static MockControl createStrictControl(...);
Mocquer는 Nice, Normal 그리고 Strict의 3가지의 MockControl를 제공한다. 개발자는 테스트케이스내의 테스트시점에 테스트되는것과 테스트 전략의 테스트 방식에 따라서 적합한 MockControl을 선택할수 있다. Nice MockControl은 가장 느슨하다(loosest). 이것은 기본값을 반환하는 mock객체의 메소드 또는 기대되지 않는 메소드 호출의 순서에 대해서 관리(care)하지 않는다. Normal MockControl은 mock객체의 기대되지 않은 메소드 호출은 AssertionFailedError을 발생시키는것처럼 Nice MockControl보다 엄격하다. Strict MockControl은 기본적으로 가장 엄격하다. 만약에 수행(working)상태내에서 mock객체의 메소드 호출의 순서가 준비(preparing)상태에서의 것과 다르다면 AssertionFailedError을 던지게 된다. 다음 테이블은 그 3가지의 MockControl의 차이점을 보여준다.

 NiceNormalStrict
Unexpected OrderDoesn't careDoesn't careAssertionFailedError
Unexpected MethodDefault valueAssertionFailedErrorAssertionFailedError

두가지 다른 버전의 공장(factory)메소드가 있다.

 public static MockControl createXXXControl(Class clazz);
 public static MockControl createXXXControl(Class clazz,
    Class[] argTypes, Object[] args);

만약에 mocked되기 위한 클래스가 인터페이스나 public/protected속성의 기본생성자를 가진다면 첫번째 버전으로 충분하다. 반면에 두번째 버전의 공장(factory)메소드는 시그너처(signature)열거하고 기대되는 생성자에 인자를 제공하기 위해 사용된다. 예를 들면 ClassWithNoDefaultConstructor는 기본 생성자가 없는 클래스이다.

    public class ClassWithNoDefaultConstructor {
      public ClassWithNoDefaultConstructor(int i) {
        ...
      }
      ...
    }

MockControl은 다음처럼 객체를 가져올수 있다.
    MockControl control = MockControl.createControl(
      ClassWithNoDefaultConstructor.class,
      new Class[]{Integer.TYPE},
      new Object[]{new Integer(0)});

Mock객체의 getter메소드
    public Object getMock();

각각의 MockControl은 생성된 mock객체에 참조를 포함한다. 개발자는 mock객체를 가지거나 그것을 실제타입으로 변환하기 위해서 이 메소드를 사용할수 있다.
    //get mock control
    MockControl control = MockControl.createControl(Foo.class);
    //Get the mock object from mock control
    Foo foo = (Foocontrol.getMock();

  • 행위(Behavior) 정의 메소드
    public void setReturnValue(... value);
    public void setThrowable(Throwable throwable);
    public void setVoidCallable();
    public void setDefaultReturnValue(... value);
    public void setDefaultThrowable(Throwable throwable);
    public void setDefaultVoidCallable();
    public void setMatcher(ArgumentsMatcher matcher);
    public void setDefaultMatcher(ArgumentsMatcher matcher);

MockControl은 개발자가 그것의 각각의 메소드 호출마다 mock객체의 행위(behavior)을 정의할수 있도록 한다. 준비(preparing)상태일때 개발자는 정의된 메소드 호출 행위를 명시하기 위해 처음에 mock객체의 메소드중에 하나를 호출할수 있다. 다음에 개발자는 행위를 명시하기 위한 행위정의메소드(behavior definition methods)중에 하나를 사용할수 있다. 예를 들면 다음의 Foo클래스에서 볼수 있다.

    //Foo.java
    public class Foo {
      public void dummy() throw ParseException {
        ...
      }
      public String bar(int i) {
        ...
      }
      public boolean isSame(String[] strs) {
        ...
      }
      public void add(StringBuffer sb, String s) {
        ...
      }
    }

mock객체의 행위는 다음처럼 정의될수 있다.
    //get mock control
    MockControl control = MockControl.createControl(Foo.class);
    //get mock object
    Foo foo = (Foo)control.getMock();
    //begin behavior definition

    //specify which method invocation's behavior
    //to be defined.
    foo.bar(10);
    //define the behavior -- return "ok" when the
    //argument is 10
    control.setReturnValue("ok");
    ...

    //end behavior definition
    control.replay();
    ...

MockControl의 50개 이상의 메소드는 행위정의메소드이다. 그들은 다음 Most of the more than 50 methods in MockControl are behavior definition methods. 그들은 다음 분류처럼 그룹화 될수 있다.

  • setReturnValue()
메소드는 인자처럼 값을 반환하는 가장 최근의 메소드 호출을 명시하곤 한다. setReturnValue()의 7가지 버전이 있다. setReturnValue(int i) 또는 setReturnValue(float f)처럼 각각은 인자처럼 원시타입을 가진다. setReturnValue(Object obj)는 원시타입 대신에 객체를 가지는 메소드를 사용한다. 만약에 주어진 값이 메소드의 반환타입과 다르다면 AssertionFailedError을 던질것이다.

이것은 행위정의로 기대되는 호출의 숫자를 더하는것또한 가능하다. 이것은 호출횟수제한을 보낸다.

      MockControl control = ...
      Foo foo = (Foo)control.getMock();
      ...
      foo.bar(10);
      //define the behavior -- return "ok" when the
      //argument is 10. And this method is expected
      //to be called just once.
      setReturnValue("ok"1);
      ...

위의 코드부분은 단지 한번만 발생할수 있는 bar(10)의 메소드 호출을 명시한다.
      ...
      foo.bar(10);
      //define the behavior -- return "ok" when the
      //argument is 10. And this method is expected
      //to be called at least once and at most 3
      //times.
      setReturnValue("ok"13);
      ...

지금 bar(10)는 적어도 한번 호출이 되었고 최대 3번 호출이 된다. 좀더 매력적으로 범위는 제한값으로 주어질수있다.
      ...
      foo.bar(10);
      //define the behavior -- return "ok" when the
      //argument is 10. And this method is expected
      //to be called at least once.
      setReturnValue("ok", Range.ONE_OR_MORE);
      ...

Range.ONE_OR_MORE는 미리정의된 범위 인스턴스(Range instance)이다. 이것은 적어도 한번은 호출되는것을 의미한다. 만약에 setReturnValue(), such as setReturnValue("Hello")내에 이것은 명시된 호출횟수 제한이 없다면 이것의 기본 호출횟수 제한처럼 Range.ONE_OR_MORE 을 사용하게 될것이다. 여기엔 Range.ONE(정확히 한번만)와 Range.ZERO_OR_MORE(당신이 얼마나 많은 횟수로 호출할수 있는지에 대한 제한이 없는) 다른 두개의 미리 정의된 범위 인스턴스가 있다.

setDefaultReturnValue() 처럼 값을 반환하는 메소드의 특별한 것들이 있다. 이것은 메소드 인자값에도 불구하고 메소드의 반환값을 정의한다. 호출 횟수의 제한은 Range.ONE_OR_MORE이다. 이것은 메소드 인자값에 영향을 받지않는 것으로 알려져 있다.

      ...
      foo.bar(10);
      //define the behavior -- return "ok" when calling
      //bar(int) despite the argument value.
      setDefaultReturnValue("ok");
      ...

  • setThrowable
setThrowable(Throwable throwable)는 메소드 호출의 익셉션(exception)을 던지는 행위로 정의되어 있다. 만약에 주어진 throwable이 메소드의 exception선언과 다르다면 AssertionFailedError을 던지게 될것이다. 호출 횟수 제한과 메소드 인자값에 영향을 받지않는 기능 또한 적용될수 있다.
      ...
      try {
        foo.dummy();
      catch (Exception e) {
        //skip
      }
      //define the behavior -- throw ParseException
      //when call dummy(). And this method is expected
      //to be called exactly once.
      control.setThrowable(new ParseException(""0)1);
      ...

  • setVoidCallable()
setVoidCallable()는 void반환타입을 가진 메소드를 위해 사용된다. 호출횟수제한과 메소드 인자값에 영향을 받지않는 기능또한 적용가능하다.
      ...
      try {
        foo.dummy();
      catch (Exception e) {
        //skip
      }
      //define the behavior -- no return value
      //when calling dummy(). And this method is expected
      //to be called at least once.
      control.setVoidCallable();
      ...

  • Set ArgumentsMatcher
수행(working)상태에서 MockControl은 어떤 메소드호출이 mock객체에서 발생할때 미리정의된 행위(behivior)을 찾을것이다. 검색기준엔 메소스 시그니처, 인자값, 호출횟수제한의 3가지 요소가 있다. 첫번째와 세번째 요소는 고정되어 있다. 두번째 요소는 위에서 서술된 인자값에 영향을 받지않는 기능에 의해 생략될수 있다. 좀더 유연성있게, 인자값비교룰(parameter value match rule)을 변경하는것이 가능하다. setMatcher()은 변경된 ArgumentsMatcher와 함께 준비(preparing)상태에서 사용될수 있다.

      public interface ArgumentsMatcher {
        public boolean matches(Object[] expected,
                               Object[] actual);
      }

ArgumentsMatcher에서 단 하나의 메소드인 matches()는 두개의 인자를 가진다. 하나는 기대되는 인자값 배열(인자값에 영향을 받지않는 기능이 적용이 된다면 null)이다. 다른 하나는 실질적인 인자값 배열이다. true를 반환하는것은 인자값이 일치함을 의미한다.
      ...
      foo.isSame(null);
      //set the argument match rule -- always match
      //no matter what parameter is given
      control.setMatcher(MockControl.ALWAYS_MATCHER);
      //define the behavior -- return true when call
      //isSame(). And this method is expected
      //to be called at least once.
      control.setReturnValue(true, 1);
      ...

MockControl에는 미리 정의된 ArgumentsMatcher인스턴스가 있다. MockControl.ALWAYS_MATCHER는 일치할때는 언제나 true를 반환한다. MockControl.EQUALS_MATCHER는 인자값배열내에 각각의 원소에 equals()을 호출한다. MockControl.ARRAY_MATCHER는 인자값의 원소가 배열타입일때 equals()대신에 Arrays.equals()이 호출되는것을 제외하면 MockControl.EQUALS_MATCHER와 거의 같다. 물론 개발자는 자신만의 ArgumentsMatcher을 구현할수 있다.

변경된 ArgumentsMatcher의 부작용은 메소드 호출의 외부 인자값이다.

      ...
      //just to demonstrate the function
      //of out parameter value definition
      foo.add(new String[]{null, null});
      //set the argument match rule -- always
      //match no matter what parameter given.
      //Also defined the value of out param.
      control.setMatcher(new ArgumentsMatcher() {
        public boolean matches(Object[] expected,
                               Object[] actual) {
           ((StringBuffer)actual[0])
                              .append(actual[1]);
           return true;
        }
      });
      //define the behavior of add().
      //This method is expected to be called at
      //least once.
      control.setVoidCallable(true, 1);
      ...

setDefaultMatcher()는 MockControl의 기본 ArgumentsMatcher인스턴스이다. 만약에 특수한 ArgumentsMatcher가 주어지지 않는다면, 기본 ArgumentsMatcher 이 사용될것이다. 이 메소드는 메소드호출 행위정의(method invocation behavior definition)전에 호출되어야만 한다. 아니면 AssertionFailedError가 발생할것이다.

      //get mock control
      MockControl control = ...;
      //get mock object
      Foo foo = (Foo)control.getMock();

      //set default ArgumentsMatcher
      control.setDefaultMatcher(
                     MockControl.ALWAYS_MATCHER);
      //begin behavior definition
      foo.bar(10);
      control.setReturnValue("ok");
      ...

만약 setDefaultMatcher()이 사용되지 않는다면 MockControl.ARRAY_MATCHER이 시스템 기본 ArgumentsMatcher이 된다.

예제#

아래에 단위테스트에서 Mocquer 사용법의 데모가 있다.

FTPConnector이름의 클래스를 지원하자.

package org.jingle.mocquer.sample;

import java.io.IOException;
import java.net.SocketException;

import org.apache.commons.net.ftp.FTPClient;


public class FTPConnector {
    //ftp server host name
    String hostName;
    //ftp server port number
    int port;
    //user name
    String user;
    //password
    String pass;

    public FTPConnector(String hostName,
                        int port,
                        String user,
                        String pass) {
        this.hostName = hostName;
        this.port = port;
        this.user = user;
        this.pass = pass;
    }

    /**
     * Connect to the ftp server.
     * The max retry times is 3.
     @return true if succeed
     */
    public boolean connect() {
        boolean ret = false;
        FTPClient ftp = getFTPClient();
        int times = 1;
        while ((times <= 3&& !ret) {
            try {
                ftp.connect(hostName, port);
                ret = ftp.login(user, pass);
            catch (SocketException e) {
            catch (IOException e) {
            finally {
                times++;
            }
        }
        return ret;
    }

    /**
     * get the FTPClient instance
     * It seems that this method is a nonsense
     * at first glance. Actually, this method
     * is very important for unit test using
     * mock technology.
     @return FTPClient instance
     */
    protected FTPClient getFTPClient() {
        return new FTPClient();
    }
}

connect()메소드는 FTP서버에 연결하고 로그인을 시도할수 있다. 만약에 이것이 실패한다면 최고 3회에 걸쳐 재시도를 한다. 만약에 성공한다면 이것은 true를 반환한다. 반면에 false를 반환한다면 클래스는 실제 연결을 생성하기 위해서 org.apache.commons.net.FTPClient를 사용한다. 여기엔 getFTPClient()의 protected속성의 메소드가 있다. 첫번째 휘광(glance..??)에서 일반적이지 않은것으로 보일것이다. 정말로 이 메소느는 mock기술을 사용한 단위 테스트에서 매우 중요하다. 나는 이것을 나중에 다시 논의할것이다.

JUnit테스트 케이스에서 FTPConnectorTest는 connect()메소드 로직을 테스트 하기 위해 제공된다. 우리가 위부 FTP서버처럼 다른 어떤 요소들로 부터 단위테스트 환경을 분리시키기를 원하기 때문이다. 우리는 FTPClient를 모의로 실험하기 위해서 Mocquer을 사용한다.

package org.jingle.mocquer.sample;

import java.io.IOException;

import org.apache.commons.net.ftp.FTPClient;
import org.jingle.mocquer.MockControl;

import junit.framework.TestCase;

public class FTPConnectorTest extends TestCase {

    /*
     * @see TestCase#setUp()
     */
    protected void setUp() throws Exception {
        super.setUp();
    }

    /*
     * @see TestCase#tearDown()
     */
    protected void tearDown() throws Exception {
        super.tearDown();
    }

    /**
     * test FTPConnector.connect()
     */
    public final void testConnect() {
        //get strict mock control
        MockControl control =
             MockControl.createStrictControl(
                                FTPClient.class);

        //get mock object
        //why final? try to remove it
        final FTPClient ftp =
                    (FTPClient)control.getMock();

        //Test point 1
        //begin behavior definition
        try {
            //specify the method invocation
            ftp.connect("202.96.69.8"7010);
            //specify the behavior
            //throw IOException when call
            //connect() with parameters
            //"202.96.69.8" and 7010. This method
            //should be called exactly three times
            control.setThrowable(
                            new IOException()3);
            //change to working state
            control.replay();
        catch (Exception e) {
            fail("Unexpected exception: " + e);
        }

        //prepare the instance
        //the overridden method is the bridge to
        //introduce the mock object.
        FTPConnector inst = new FTPConnector(
                                  "202.96.69.8",
                                  7010,
                                  "user",
                                  "pass") {
            protected FTPClient getFTPClient() {
                //do you understand why declare
                //the ftp variable as final now?
                return ftp;
            }
        };
        //in this case, the connect() should
        //return false
        assertFalse(inst.connect());

        //change to checking state
        control.verify();

        //Test point 2
        try {
            //return to preparing state first
            control.reset();
            //behavior definition
            ftp.connect("202.96.69.8"7010);
            control.setThrowable(
                           new IOException()2);
            ftp.connect("202.96.69.8"7010);
            control.setVoidCallable(1);
            ftp.login("user""pass");
            control.setReturnValue(true, 1);
            control.replay();
        catch (Exception e) {
            fail("Unexpected exception: " + e);

        }

        //in this case, the connect() should
        //return true
        assertTrue(inst.connect());

        //verify again
        control.verify();
    }
}

Strict MockObject는 생성되었다. mock객체 변수 선언은 내부 익명클래스(inner anonymous class)내에서 사용되기 때문에 final속성의 변경자를 가진다. 하지만 컴파일에러는 발생할것이다.

테스트 메소드내에서 두가지 테스팅 포인트가 있다. 첫번째는 FTPClient.connect()가 언제나 FTPConnector.connect()가 결과로 false를 반환하는것을 의미하는 excepton을 던진다는 것이다.

try {
    ftp.connect("202.96.69.8"7010);
    control.setThrowable(new IOException()3);
    control.replay();
catch (Exception e) {
    fail("Unexpected exception: " + e);
}

MockControl이 서술하는것처럼 202.96.96.8의 호스트 ip와 7010의 포트번호의 인자를 가진 mock객체에서 connect()가 호출되었을때 IOException은 던져질것이다. 이 메소드호출은 정확히 3회 호출될것이다. 행위정의후에 replay()는 mock객체를 수행(working)상태로 바꾸어놓을것이다. 여기서 try/catch블럭은 throw구문에 IOException이 정의된 FTPClient.connect()의 선언에 따라온다.

FTPConnector inst = new FTPConnector("202.96.69.8",
                                     7010,
                                     "user",
                                     "pass") {
    protected FTPClient getFTPClient() {
        return ftp;
    }
};

위의 코드는 오버라이드된 getFTPClient()을 가지는 FTPConnector인스턴스를 생성한다. 이것은 테스트될 목표로 mock객체를 생성하는것을 소개하기 위한 다리역활을 한다.

assertFalse(inst.connect());

connect()의 기대되는 결과는 이 테스트 시점에서는 false이다.
control.verify();

마침내 mock객체는 체크(checking)상태로 변경된다.

두번째 테스트 포인트는 FTPClient.connect()가 두번은 exceptionㅇ르 던지고 세번째에 성공하고 FTPClient.login()또한 성공하는것이다. FTPConnector.connect()는 결과로써 true를 반환할것이다.

이 테스트포인트는 MockObject가 reset()를 사용해서 처음에 준비(preparing)상태로 변경되어야만 하는것을 제외하고는 이전의 테스트포인트의 절차를 따라간다.

결론#

Mock기술은 다른 외부 요소로 부터 테스트되어야만 하는 목표물을 분리한다. JUnit프레임워크로 통합된 mock기술은 단위테스트를 좀더 간단하고 깔끔하게 만든다. EasyMock는 특정 인터페이스를 위해 mock객체를 생성할수 있는 좋은 툴이다. Dunamis의 도움으로 Mocquer는 EasyMock의 기능을 확장했다. 이것은 단지 인터페이스를 위해서 mock객체를 생성할뿐 아니라 클래스를 위해서도 mock객체를 생성한다. 이글은 단위테스트에서 Mocquer의 사용법을 소개했다. 더 많은 세부적인 정보를 위해서는 아래의 references를 참조하길 바란다.

References #

Mocquer project: mocquer.dev.java.net
Download sample code for this article
The Dunamis project: dunamis.dev.java.net
An article about dynamic delegation: "Dynamic Delegation and Its Application"
EasyMock: www.easymock.org
JUnit: www.junit.org
Lu Jian is a senior Java architect/developer with four years of Java development experience.

Add new attachment

Only authorized users are allowed to upload new attachments.

List of attachments

Kind Attachment Name Size Version Date Modified Author Change note
jpg
logo.jpg 17.2 kB 1 06-Apr-2006 09:45 이동국
gif
mock1.gif 3.8 kB 1 06-Apr-2006 09:45 이동국
gif
mock2.gif 9.9 kB 1 06-Apr-2006 09:45 이동국
« This page (revision-3) was last changed on 06-Apr-2006 09:45 by 이동국  
G’day (anonymous guest) My Prefs

Referenced by
Theory

JSPWiki v2.8.4