在测试中使用匹配器

以前在测试代码中,我们必须写很多断言。现在镇上出现了一位新长官:assertThat 和他的副职——匹配器(matcher)。当然,这位也不是非常新。可是不管怎样,我想简短地介绍一下如何使用匹配器,并对匹配器的概念进行延伸。对于编写单元测试代码非常有用。

首先我会介绍一下匹配器的基本用法。你也可以直接从Hamcrest匹配器的作者找到该匹配器的完整功能介绍:https://code.google.com/p/hamcrest/wiki/Tutorial

从本质上说,匹配器是一个用来判断两个对象是否匹配的对象。通常,大家的第一个问题是:为什么不使用equals?因为有时候你不希望匹配两个对象的所有字段,而仅仅是其中某些字段。并且如果你在处理遗留代码,就会发现它没有实现equals方法或者实现的equals方法跟你想的不太一样。另外一个原因是,使用assertThat可以用更一致的方式”声明断言”,写出可读性更高的代码。譬如,你不用这样写:

int expected, actual;
assertEquals(expected, actual);

而是可以写成这样:

assertThat(expected, is(actual));

这里的 isorg.hamcrest.core.Is.is 的静态导入,虽然没有什么太大的差异,但是Hamcrest为你提供了很多非常有用的匹配器:

  • 数组和map:hasItem、hasKey、hasValue。
  • 数值:closeTo ——指定有一定误差的相等方法,greaterThanlessThan等等。
  • 对象:nullValuesameInstance

我们仍然继续努力。虽然Hamcrest匹配器很强大,你仍然可以为自己的对象编写自定义匹配器,通过继承BaseMatcher<T>类即可。我们来看一个简单的自定义匹配器示例:

public class OrderMatcher extends BaseMatcher<Order> {
    private final Order expected;
    private final StringBuilder errors = new StringBuilder();

    private OrderMatcher(Order expected) {
        this.expected = expected;
    }

    @Override
    public boolean matches(Object item) {
        if (!(item instanceof Order)) {
            errors.append("received item is not of Order type");
            return false;
        }
        Order actual = (Order) item;
        if (actual.getQuantity() != (expected.getQuantity())) {
            errors.append("received item had quantity ").append(actual.getQuantity()).append(". Expected ").append(expected.getQuantity());
            return false;
        }
        return true;
    }

    @Override
    public void describeTo(Description description) {
        description.appendText(errors.toString());
    }

    @Factory
    public static OrderMatcher isOrder(Order expected) {
        return new OrderMatcher(expected);
    }
}

相比旧的断言,这是一种全新的断言方法。以上就Hamcrest匹配器的用法简介。

当我在实际中使用,特别是在处理遗留代码时,却发现没那么简单。下面是我在使用匹配器时遇到的一些问题:

  1. 需要重复地构造匹配器,这很令人厌烦。我需要一种能将DRY(即Don’t Repeat Yourself 不要重复自己)原则运用于匹配器代码的方式。

  2. 需要一种统一的方式来获取匹配器。默认是由框架来选择合适的匹配器。

  3. 匹配器需要支持比较包含引用的对象,引用的对象也要由匹配器进行比较(对象引用的深度可由需要决定)。

  4. 匹配器需要支持在不遍历的情况下比较一组对象集合(数组匹配器也应该如此……支持越多越好:))。

  5. 需要一个更灵活的匹配器。打个比方,对于同样的对象我要检验一组字段,其它情况下又需要检验另外一组字段。现在的解决方法是为每种情况定义一个匹配器。这种使用方法非常不友好。

通过定义匹配器层次结构,我解决了上述问题。这个层次结构知道对象使用什么匹配器,要比较哪些字段、忽略哪些字段。层次结构的最底层继承了BaseMatcher<T>RootMatcher<T>

要解决第1个问题(重复代码),RootMatcher类包含了所有匹配器的公共代码,比如判断得到的结果是否为空,或者得到的结果与期待的对象类型是否一致,甚至还有判断得到结果与期待的对象是否为同一实例。

 public boolean checkIdentityType(Object received) {
        if (received == expected) {
            return true;
        }
        if (received == null || expected == null) {
            return false;
        }
        if (!checkType(received)){
            return false;
        }
        return true;
    }
    private boolean checkType(Object received) {
        if (checkType && !getClass(received).equals(getClass(expected))) {
            error.append("Expected ").append(expected.getClass()).append(" Received : ").append(received.getClass());
            return false;
        }
        return true;
    }

这样可以使编写匹配器变得简单,无需考虑null或者测试的边界情况,所有这些都由基类负责考虑。

并且,期待的对象和错误信息也包含在基类中。

public abstract class RootMatcher extends BaseMatcher {
    protected T expected;
    protected StringBuilder error = new StringBuilder("[Matcher : " + this.getClass().getName() + "] ");

这样只需继承RootMatcher,就可以调用匹配方法。出现错误时,只需把消息放到StringBuilder即可;RootMatcher会负责将这些错误信息传给JUnit框架,然后向用户显示。

对于第2个问题(自动化匹配器查找),可以通过工厂方法解决。

@Factory
    public static  Matcher is(Object expected) {
        return getMatcher(expected, true);
    }
    public static  RootMatcher getMatcher(Object expected, boolean checkType) {
        try {
            Class matcherClass = Class.forName(expected.getClass().getName() + "Matcher");
            Constructor constructor = matcherClass.getConstructor(expected.getClass());
            return (RootMatcher) constructor.newInstance(expected);
        } catch (ClassNotFoundException | NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
        }
        return (RootMatcher) new EqualMatcher(expected);
    }

正如你所看到的,工厂方法试图按照下面两个约定确定匹配器。

  1. 对象匹配器的名字定义为:对象的名字+Matcher。
  2. 匹配器与待匹配的对象位于同一个包(除测试目录外,建议定义在同一个包)。

通过这个策略,只需一个匹配器——RootMatcher.is,就可以提供我需要的匹配器。

解决对象关系的递归特性(问题3),在检查对象字段时,我使用RootManager方法来检查是否相等,该方法会调用匹配器。

public boolean checkEquality(Object expected, Object received) {
        String result = checkEqualityAndReturnError(expected, received);
        return result == null || result.trim().isEmpty();
    }

    public String checkEqualityAndReturnError(Object expected, Object received) {
        if (isIgnoreObject(expected)) {
            return null;
        }
        if (expected == null && received == null) {
            return null;
        }
        if (expected == null || received == null) {
            return "Expected or received is null and the other is not: expected " + expected + " received " + received;
        }
        RootMatcher matcher = getMatcher(expected);
        boolean result = matcher.matches(received);
        if (result) {
            return null;
        } else {
            StringBuilder sb = new StringBuilder();
            matcher.describeTo(sb);
            return sb.toString();
        }
    }

那集合的问题要怎么办呢(问题4)?要解决这个问题,只需继承RootMatcher实现集合匹配器即可。

接下来就只剩下第5个问题了。为了让匹配器更加灵活,能够让匹配器知道哪些字段要忽略、哪些字段要匹配。为此我引入了”ignoreObject”这个概念。当匹配器在模板(期待的对象)中发现对”ignoreObject”的引用,就会忽略对该引用匹配。它是怎么运作的呢?首先,在RootMatcher中定义了一个方法返回任意Java类型的”ignoreObject”。

private final static Map ignorable = new HashMap();

    static {
        ignorable.put(String.class, "%%%%IGNORE_ME%%%%");
        ignorable.put(Integer.class, new Integer(Integer.MAX_VALUE - 1));
        ignorable.put(Long.class, new Long(Long.MAX_VALUE - 1));
        ignorable.put(Float.class, new Float(Float.MAX_VALUE - 1));
    }

    /**
     * we will ignore mock objects in matchers
     */
    private boolean isIgnoreObject(Object object) {
        if (object == null) {
            return false;
        }
        Object ignObject = ignorable.get(object.getClass());
        if (ignObject != null) {
            return ignObject.equals(object);
        }
        return Mockito.mockingDetails(object).isMock();
    }

    @SuppressWarnings("unchecked")
    public static  M getIgnoreObject(Class clazz) {
        Object obj = ignorable.get(clazz);
        if (obj != null) {
            return (M) obj;
        }
        return (M) Mockito.mock(clazz);
    }

    @SuppressWarnings("unchecked")
    public static  M getIgnoreObject(Object obj) {
        return (M) getIgnoreObject(obj.getClass());
    }

可以看出,被忽略的是模拟对象(mocked object)。对于不能模拟的类(final类),我提供了一些不大可能出现的任意的固定值(这个部分还可以完善:))。为了使用这个功能,开发人员必须使用RootMatcher提供的equals方法——checkEqualityAndReturnError,这个方法会检查”ignored Object”。通过这个策略以及我去年讨论过的构造模式http://www.javaadvent.com/2012/12/using-builder-pattern-in-junit-tests.html,就可以给一个复杂的对象设定断言语句。

import static […]RootMatcher.is;
Order expected = OrderBuilder.anOrder().withQuantity(2)
                                       .withTimestamp(RootManager.getIgnoredObject(Long.class))
                                       .withDescription(“specific description”).build()
assertThat(order, is(expected);

如你所见,可以很容易忽略时间戳,这样就可以使用相同的匹配来验证一组完全不同的字段。

事实上,这种策略要求相当多的准备,需要准备所有的构造器和匹配器。但如果我们想要得到测试过的代码,或者想让测试主要关注于测试流的覆盖,就需要这样的工具。工具可以帮助我们搭建基础并建立测试需要的前提条件和状态。

当然也可以使用注解来实现,但是核心思想是一样的。.

我希望这篇文章可以帮改进你的测试风格,如果有很多人对此感兴趣,我会把完整的代码放到公共资源库。

谢谢!

原文链接: javaadvent 翻译: ImportNew.com - 范婵香
译文链接: http://www.importnew.com/8923.html
[ 转载请保留原文出处、译者和译文链接。]



相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部