在测试中使用匹配器,测试使用匹配器
在测试中使用匹配器,测试使用匹配器
以前在测试代码中,我们必须写很多断言。现在镇上出现了一位新长官: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));
这里的 is
是 org.hamcrest.core.Is.is
的静态导入,虽然没有什么太大的差异,但是Hamcrest为你提供了很多非常有用的匹配器:
- 数组和map:hasItem、hasKey、hasValue。
- 数值:
closeTo
——指定有一定误差的相等方法,greaterThan
、lessThan
等等。 - 对象:
nullValue
、sameInstance
。
我们仍然继续努力。虽然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匹配器的用法简介。
当我在实际中使用,特别是在处理遗留代码时,却发现没那么简单。下面是我在使用匹配器时遇到的一些问题:
-
需要重复地构造匹配器,这很令人厌烦。我需要一种能将DRY(即Don’t Repeat Yourself 不要重复自己)原则运用于匹配器代码的方式。
-
需要一种统一的方式来获取匹配器。默认是由框架来选择合适的匹配器。
-
匹配器需要支持比较包含引用的对象,引用的对象也要由匹配器进行比较(对象引用的深度可由需要决定)。
-
匹配器需要支持在不遍历的情况下比较一组对象集合(数组匹配器也应该如此……支持越多越好:))。
-
需要一个更灵活的匹配器。打个比方,对于同样的对象我要检验一组字段,其它情况下又需要检验另外一组字段。现在的解决方法是为每种情况定义一个匹配器。这种使用方法非常不友好。
通过定义匹配器层次结构,我解决了上述问题。这个层次结构知道对象使用什么匹配器,要比较哪些字段、忽略哪些字段。层次结构的最底层继承了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); }
正如你所看到的,工厂方法试图按照下面两个约定确定匹配器。
- 对象匹配器的名字定义为:对象的名字+Matcher。
- 匹配器与待匹配的对象位于同一个包(除测试目录外,建议定义在同一个包)。
通过这个策略,只需一个匹配器——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 翻译: Wld5.com - 范婵香译文链接: http://www.wld5.com/8923.html
[ 转载请保留原文出处、译者和译文链接。]
用户点评