Прошли те времена, когда мы были вынуждены написать слишком много строк утверждений в нашем тестовом коде. В городе появился новый шериф: assertThat и его заместитель: спички. Ну, не так уж и ново, но в любом случае я хотел бы вкратце представить вам, как используются сопоставители, и после этого концепцию расширения для сопоставителей, которую я нашел очень полезной при разработке модульных тестов для моего кода.
Прежде всего, я расскажу об основных принципах использования мэтчеров. Конечно, вы можете получить полную презентацию возможностей устройств для подколенного сухожилия непосредственно от их авторов:
https://code.google.com/p/hamcrest/wiki/Tutorial .
По сути, сопоставление — это объект, который определяет совпадение двух объектов. Первый вопрос обычно таков: почему бы вам не использовать равные? Ну, иногда вы не хотите сопоставлять два объекта по всем их полям, только по некоторым из них, и если вы работаете с устаревшим кодом, вы обнаружите, что реализация equals отсутствует или не соответствует ожиданиям. Другая причина заключается в том, что использование assertThat дает вам более последовательный способ «утверждения утверждений» и, возможно, более читаемый код. Так, например, вместо того, чтобы писать:
1
2
|
int expected, actual; assertEquals(expected, actual); |
ты напишешь
1
|
assertThat(expected, is(actual)); |
где «is» — статически импортированный org.hamcrest.core.Is.is
Не так уж много различий … пока. Но Hamcrest предлагает вам много очень полезных совпадений:
- Для массивов и карт: hasItem, hasKey, hasValue
- Числа: closeTo — способ указать равенство с ошибкой на полях, moreThan, lessThan…
- Объекты: nullValue, sameInstance
Сейчас мы добиваемся прогресса … но сила совпадений Hamcrest заключается в том, что у вас есть возможность писать собственные сопоставители для ваших объектов. Вам просто нужно расширить класс BaseMatcher <T>. Вот пример простого пользовательского сопоставления:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
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); } } |
Это совершенно новая лига по сравнению со старыми методами утверждения.
Так что это в двух словах использование спичек Хэмкреста.
Но когда я начал использовать его в реальной жизни, особенно при работе с унаследованным кодом, я понял, что это еще не все. Вот некоторые проблемы, с которыми я столкнулся при использовании matchers:
- Конструкция Matcher может быть очень повторяющейся и скучной. Мне нужен был способ применить принцип DRY к коду соответствия.
- Мне нужен был единый способ доступа к матчерам. Правильный подборщик должен быть выбран каркасом по умолчанию.
- Мне нужно было сравнить объекты, которые имели ссылки на другие объекты, которые должны были быть сопоставлены с сопоставителями (ссылки на объекты могут идти так глубоко, как вы хотите)
- Мне нужно было проверить коллекцию объектов, используя сопоставители, не повторяя эту коллекцию (выполнимо также с сопоставителями массивов…, но я хотел больше J)
- Мне нужно было более гибкое соответствие. Например, для того же объекта мне нужно было проверить один набор полей, а в другом — другой. Готовое решение состоит в том, чтобы иметь соответствие для каждого случая. Не понравилось это.
Я преодолел эти проблемы, используя иерархию сопоставлений, которая соответствует некоторым соглашениям и которая знает, какое сопоставление применять и какое поле сравнивать или игнорировать. В основе этой иерархии лежит RootMatcher <T>, который расширяет BaseMatcher <T>.
Чтобы справиться с проблемой № 1 (повторяющийся код), класс RootMatcher содержит общий код для всех сопоставителей, таких как методы для проверки, является ли фактическое значение пустым, или имеет тот же тип с ожидаемым объектом, или даже если они являются тот же экземпляр:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
|
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 ; } |
Это упростит способ написания соответствий; мне не нужно принимать во внимание случаи с нулевым или единичным углом; об этом все позаботились в корневом классе.
Также ожидаемый объект и ошибки находятся в корневом классе:
1
2
3
|
public abstract class RootMatcher extends BaseMatcher { protected T expected; protected StringBuilder error = new StringBuilder( "[Matcher : " + this .getClass().getName() + "] " ); |
Это позволяет вам перейти к реализации метода совпадений, как только вы расширите RootMatcher, а для ошибок просто поместите сообщения в StringBuilder; RootMatcher будет обрабатывать отправку их в инфраструктуру JUnit для представления пользователю.
Для проблемы № 2 (автоматический поиск соответствия) решение было в заводском методе:
01
02
03
04
05
06
07
08
09
10
11
12
13
|
@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
- Сопоставитель находится в том же пакете, что и сопоставляемый объект (рекомендуется находиться в том же пакете, но в каталоге test)
Используя эту стратегию, мне удалось использовать один сопоставитель: RootMatcher.is, который предоставит мне точный сопоставитель, который мне нужен
И чтобы решить рекурсивную природу объектных отношений (проблема № 3), при проверке полей объекта я использовал метод из RootManager, чтобы проверить равенство, которое будет использовать совпадения:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
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». Это объект, который средство сопоставления будет игнорировать, когда найдет ссылку на него в шаблоне (ожидаемый объект). Как это работает? Прежде всего, в RootMatcher я предлагаю методы для возврата объекта игнорирования для любого типа Java:
01
02
03
04
05
06
07
08
09
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
|
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()); } |
Как вы можете видеть, игнорируемый объект будет тем, который высмеивается. Но для классов, которые нельзя смоделировать (конечные классы), я предоставил некоторые произвольные фиксированные значения, которые очень маловероятно появиться (эту часть можно улучшить J). Чтобы это работало, разработчик должен использовать методы равенства, предоставленные в RootMatcher: checkEqualityAndReturnError, который будет проверять наличие игнорируемых объектов. Используя эту стратегию и шаблон построения, который я представил в прошлом году ( http://www.javaadvent.com/2012/12/using-builder-pattern-in-junit-tests.html ), я легко могу сделать свои утверждения для сложного объект:
1
2
3
4
5
|
import static […]RootMatcher.is; Order expected = OrderBuilder.anOrder().withQuantity( 2 ) .withTimestamp(RootManager.getIgnoredObject(Long. class )) .withDescription(“specific description”).build() assertThat(order, is(expected); |
Как вы можете видеть, я мог легко указать, что метка времени должна игнорироваться и что позволило мне использовать один и тот же сопоставитель с совершенно другим набором полей для проверки.
Действительно, эта стратегия требует довольно большой подготовки, в которой участвуют все строители и спички. Но если мы хотим иметь тестируемый код, если мы хотим сделать тестирование работой, в которой основное внимание уделяется потоку тестов, который необходимо охватить, нам нужна такая основа и эти инструменты, которые помогут нам легко установить наше предварительное условие и построить наше ожидаемое состояние.
Конечно, реализация может быть улучшена с помощью аннотации, но основные концепции все еще остаются.
Я надеюсь, что эта статья поможет вам улучшить ваш стиль тестирования, и если будет достаточно интереса, я приложу все усилия, чтобы поместить весь код в общедоступный репозиторий.
Спасибо.