Статьи

Использование Matchers в тестах

Прошли те времена, когда мы были вынуждены написать слишком много строк утверждений в нашем тестовом коде. В городе появился новый шериф: 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:

  1. Конструкция Matcher может быть очень повторяющейся и скучной. Мне нужен был способ применить принцип DRY к коду соответствия.
  2. Мне нужен был единый способ доступа к матчерам. Правильный подборщик должен быть выбран каркасом по умолчанию.
  3. Мне нужно было сравнить объекты, которые имели ссылки на другие объекты, которые должны были быть сопоставлены с сопоставителями (ссылки на объекты могут идти так глубоко, как вы хотите)
  4. Мне нужно было проверить коллекцию объектов, используя сопоставители, не повторяя эту коллекцию (выполнимо также с сопоставителями массивов…, но я хотел больше J)
  5. Мне нужно было более гибкое соответствие. Например, для того же объекта мне нужно было проверить один набор полей, а в другом — другой. Готовое решение состоит в том, чтобы иметь соответствие для каждого случая. Не понравилось это.

Я преодолел эти проблемы, используя иерархию сопоставлений, которая соответствует некоторым соглашениям и которая знает, какое сопоставление применять и какое поле сравнивать или игнорировать. В основе этой иерархии лежит 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);
    }

Как вы можете видеть, фабричный метод пытается выяснить, какое совпадение должно возвращаться, используя два соглашения

  1. Соответствующий объект имеет имя объекта + строка Matcher
  2. Сопоставитель находится в том же пакете, что и сопоставляемый объект (рекомендуется находиться в том же пакете, но в каталоге 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);

Как вы можете видеть, я мог легко указать, что метка времени должна игнорироваться и что позволило мне использовать один и тот же сопоставитель с совершенно другим набором полей для проверки.

Действительно, эта стратегия требует довольно большой подготовки, в которой участвуют все строители и спички. Но если мы хотим иметь тестируемый код, если мы хотим сделать тестирование работой, в которой основное внимание уделяется потоку тестов, который необходимо охватить, нам нужна такая основа и эти инструменты, которые помогут нам легко установить наше предварительное условие и построить наше ожидаемое состояние.

Конечно, реализация может быть улучшена с помощью аннотации, но основные концепции все еще остаются.

Я надеюсь, что эта статья поможет вам улучшить ваш стиль тестирования, и если будет достаточно интереса, я приложу все усилия, чтобы поместить весь код в общедоступный репозиторий.

Спасибо.

Ссылка: Использование Matchers в тестах от нашего партнера JCG Стефана Булзана в блоге Java Advent Calendar .