技术开发 频道

单元测试中的伪对象

【IT168 技术文章】

    在单元测试的策略中伪对象被广泛使用。他从测试中分离了外部的不需要的因素并且帮助开发人员专注于被测试的功能。

    EasyMock是一个在这方面很有名的工具,可以在运行时为给定的接口创建伪对象。伪对象的行为可以在测试用例中的执行测试代码之前被定义。EasyMock基于java.lang.reflect.Proxy,他可以根据给定的接口创建动态代理类或者对象。但因为使用Proxy使得他有一个天生的缺陷:只能创建基于接口的伪对象。

    Mocquer是一个类似的工具,但他扩展了EasyMock的功能能够支持创建类的伪对象。

    Mocquer介绍
    Mocquer基于Dunamis项目,被用来为特定的类或接口生成动态代理类或对象。为方便使用,他遵循EasyMock的类和方法的命名规范,只是在内部使用不同的实现方法。

    MockControl是Mocquer项目中最重要的类。他被用来控制伪对象的生命周期和行为定义。这个类中有四类方法。

    1、生命周期控制方法:
    ·public void replay();
    ·public void verify();
    ·public void reset();

    伪对象在他的生命周期中有三种状态:准备态、工作态、验证态。图1显示了伪对象的生命周期。

    

    Figure 1. Mock object life cycle

    刚开始,伪对象处于准备态,他的表现行为可以在这里定义。replay()将改变伪对象的状态为工作态。在这个状态中所有伪对象的方法调用将会遵循在准备态下定义的行为。在verify()调用后,伪对象就处于验证态。MockControl会比较伪对象的预定义行为与实际行为是否匹配。匹配规则依赖于使用的MockControl类型,这个会在稍后解释。开发人员可以在需要时调用replay()来重现预定义的行为。而任何状态下调用reset()将会清除状态历史并重置为初始的准备态。

    2、工厂方法
    ·public static MockControl createNiceControl(...);
    ·public static MockControl createControl(...);
    ·public static MockControl createStrictControl(...);

    Mocquer提供了三种MockControl:宽松的,普通的和严格的。开发人员可以在自己的测试用例中根据测试的内容(测试点)和测试的执行方式(测试策略)选择相应的MockControl。宽松的MockControl是最随意的,他不关心伪对象中方法调用的顺序,甚至未预期的方法调用,只是返回一个缺省值(依赖于方法的返回值)。普通的MockControl比宽松的MockControl严格些,未预期的方法调用会导致AssertionFailedError异常。严格的MockControl是最严格的,如果伪对象在工作态下方法调用的顺序与准备态的不同,就会抛出AssertionFailedError异常。下表显示了三种不同MockControl的区别。    
    下面是每一个工厂方法的两个不同版本。
     public static MockControl createXXXControl(Class clazz);
     public static MockControl createXXXControl(Class clazz,
    Class[] argTypes, Object[] args);
    如果类是作为接口来模拟的或者他有一个公共或保护的缺省构造函数,则第一个版本的方法会被使用。否则第二个版本会被用来定义标识和提供参数给期望的构造函数。例如,假设 ClassWithNoDefaultConstructor是一个没有缺省构造函数的类:
    public class ClassWithNoDefaultConstructor {
      public ClassWithNoDefaultConstructor(int i) {
        ...
      }
      ...
    }
    ·伪对象获取方法
    public Object getMock();

    每一个MockControl包含一个生成的伪对象的引用。开发人员可以使用这个方法取得伪对象并且转换为实际的对象类型。
 //get mock control
    MockControl control = MockControl.createControl(Foo.class);
    //Get the mock object from mock control
    Foo foo = (Foo) control.getMock();
·行为定义方法
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允许开发人员定义伪对象的每一个方法的行为。当他在准备态时,开发人员可以调用伪对象的方法。首先规定哪一个调用方法的行为需要被定义。然后开发人员可以使用行为定义的方法之一来定义行为。例如,看一下下面的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) {
        ...
      }
    }
伪对象的行为可以按照下面的方式来定义:
//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个方法是行为定义方法。他们可以如下分类。

o        setReturnValue()
    这些方法被用来定义最后的方法调用应该返回一个值作为参数。这儿有7个使用原始类型作业参数的`setReturnValue()方法,如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", 1, 3);
      ...
    现在bar(10)被限制至少被调用一次最多3次。更方便的是Range已经预定义了一些限制范围。
      ...
      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实例,这意味着方法应该被调用至少一次。如果setReturnValue()中没有定义调用次数限制,如setReturnValue("Hello"),Range.ONE_OR_MORE被认为是缺省值。还有两个预定义的Range实例,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");
      ...
o        setThrowable

    setThrowable(Throwable throwable)被用来定义方法调用异常抛出的行为。如果给定的throwable不匹配方法的异常定义,则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);
      ...
o        setVoidCallable()
    setVoidCallable()被用于没有返回值的方法。调用次数的限制与方法参数值敏感性是一致的。

      ...
      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();
      ...
o        Set ArgumentsMatcher
    在工作态时,MockControl会在伪对象的方法被调用时搜索预定义的行为。有三个因素会影响搜索的标准:方法标识,参数值和调用次数限制。第一和第三个因素是固定的。第二个因素可以通过参数值敏感性来忽略。更灵活的是,还可以自定义参数值匹配规则。setMatcher()可以通过ArgumentsMatcher在准备态时使用。

      public interface ArgumentsMatcher {
        public boolean matches(Object[] expected,
                               Object[] actual);
      }
ArgumentsMatcher唯一的方法matches()包含两个参数。一个是期望的参数值数组(如果参数值敏感特性应用时为NULL)。另一个是实际参数值数组。如果参数值匹配就返回真。

      ...
      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在匹配时始终返回真而不管给什么参数值。MockControl.EQUALS_MATCHER会为参数值数组的每一个元素调用equals()方法。MockControl.ARRAY_MATCHER与MockControl.EQUALS_MATCHER基本一致,除了他调用的是Arrays.equals()。当然,开发人员可以实现自己的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会被使用。这个方法应该在任何方法调用行为定义前被调用。否则,会抛出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();
    }
}
    cnnect()方法尝试连接FTP服务器并且登录。如果失败了,他可以尝试三次。如果操作成功返回真。否则返回假。这个类使用org.apache.commons.net.FTPClient来生成一个实际的连接。他有一个初看起来毫无用处的保护方法getFTPClient()。实际上这个方法对使用伪技术的单元测试是非常重要的。我会在稍后解释。
    一个JUnit测试实例FTPConnectorTest被用来测试connect()方法的逻辑。因为我们想要将单元测试环境从其他因素中(如外部FTP服务器)分离出来,因此我们使用Mocquer来模拟FTPClient。
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();
    }
}
    这里创建了一个严格的MockObject。伪对象变量有一个final修饰符因为变量会在匿名内部类中使用,否则有产生编译错误。
    在这个测试方法中包含两个测试点。第一个是什么时候FTPClient.connect()始终抛出异常,也就是说FTPClient.connect()返回假。
try {
    ftp.connect("202.96.69.8", 7010);
    control.setThrowable(new IOException(), 3);
    control.replay();
} catch (Exception e) {
    fail("Unexpected exception: " + e);
}
    MockControl在调用伪对象connect()方法传入参数202.96.96.8作为主机地址及7010作为端口号时会抛出IOException异常。这个方法调用预期执行三次。在行为定义后,replay()改变伪对象状态为工作态。这里的try/catch块包裹着FTPClient.connect()的定义,因为他定义了抛出IOException异常。
FTPConnector inst = new FTPConnector("202.96.69.8",
                                     7010,
                                     "user",
                                     "pass") {
    protected FTPClient getFTPClient() {
        return ftp;
    }
};
    上面的代码创建一个重写了getFTPClient()方法的FTPConnector实例。这样就桥接了创建的伪对象给用来测试的目标。
    assertFalse(inst.connect());
    在这里预期connect()应该返回假。
    control.verify();

    最后,改变伪对象到验证态。
    第二个测试点是什么时候FTPClient.connect()前两次抛出异常而第三次会成功,这时FTPClient.login()当然也是成功的,这意味着FTPConnector.connect()会返回真。
    这个测试点是在前一个测试点之后运行,因此需要将MockObject的状态通过reset()重新置为准备态。

    总结
    模拟技术将测试的对象从其他外部因素中分离出来。在JUnit框架中集成模拟技术使得单元测试更加简单和优雅。EasyMock是一个好的伪装工具,可以为特定接口创建伪对象。在Dunamis协助下,Mocquer扩展了EasyMock的功能,他可以为类创建伪对象。这篇文章简单介绍了Mocquer在单元测试中的使用。

0
相关文章