Матчеры: когда они полезны и как легко их использовать

в 7:53, , рубрики: junit, Блог компании Яндекс, тестирование, яндекс, метки: , ,

Матчеры: когда они полезны и как легко их использоватьАпельсины здесь ни при чёмКак вы могли догадаться по картинке справа, речь пойдёт об автоматизированном тестировании. Точнее о такой технологии, как матчеры. Они помогают серьёзно сократить дублирование кода и упростить код тестов для восприятия, а создавать и использовать матчеры достаточно просто.

Сама по себе технология матчеров не новая — в текущем виде она была залита в репозиторий в июле 2012 года, а появилась и того раньше. Но, несмотря на это, многие о ней до сих пор не слышали или по каким-то причинам избегают. Мы хотим рассказать, как легко получать преимущества от её использования, и поделиться с вами нашей библиотекой матчеров.

Предположим, у нас есть набор неких фруктов. Среди них чаще всего встречается круглый, оранжевый и сладкий.

public class Fruit {
    
    ...
     
    public Color getColor() {...}

    public boolean isSweet() {...}

    public Shape getShape() {...}
}

Известно, что именно таким условиям удовлетворяет апельсин. Помимо фруктов, у нас есть и конвейер, через который можно пропустить такие фрукты. Есть и задача для конвейера — отсеять не апельсины, проведя серию тестов.

И вот удача — у нас под рукой как раз оказался аппарат, который умеет определять сладкий ли фрукт и какого он цвета, сравнивать его форму с рядом известных и проводить еще множество проверок. Аппарат этот называется JUnit.

Перед началом теста, на конвейер вываливается новый фрукт.

@Before
    public void setUp() throws Exception {
        someFruit = getNextFruit();
    }

Определим сперва, что фрукт круглый.

@Test
    public void orangeIsRound() {
        assertEquals("Expected shape - " + Shape.ROUND + ", but was - " + someFruit.getShape(),
                someFruit.getShape(), Shape.ROUND);
    }

Затем, что фрукт сладкий.

@Test
    public void orangeIsSweet() {
        assertTrue("Fruit should be sweet - expected TRUE", someFruit.isSweet());
    }

И, наконец, посмотрим на его цвет.

@Test
    public void orangeHasOrangeColor() {
        assertEquals("Orange has orange color, but was - " + someFruit.getColor(),
                someFruit.getColor(), Color.ORANGE);
    }

Какие минусы сразу очевидны? Во-первых, в каждой из проверок пришлось самостоятельно склеивать комментарий из ожидаемого значения, фактического значения, значения дополнительных уточнений и разных междометий. Даже описание этого списка утомительно.

Во-вторых, представьте, что мы решили определять еще и сорт апельсинов, добавили для этого специального «дегустатора» и сказали ему: «Одобряй только сорт «Валенсия»». Но «дегустатор» не знает, что решил проверяющий автомат, если сам не спросит — автомат не болтлив. В итоге он будет пробовать все подряд. Чтобы не отравить «дегустатора» мячиком, который не успели проверить на сладость, нужно научить его игнорировать всё лишнее. Для этого ему нужно спросить автомат отдельно и самостоятельно, после чего всё забракованное отложить в сторону и больше не трогать.

«Дегустатор» — это всего лишь еще один тест в нашем JUnit-аппарате, поэтому можно и нужно использовать встроенный рантайм-механизм игнорирования теста — assume. Тогда сценарий начала дегустации будет выглядеть так.

@Test
    public void degusto() {
        assumeTrue("Expected shape - " + Shape.ROUND + ", but was - " + someFruit.getShape(),
                someFruit.getShape().equals(Shape.ROUND));
        assumeTrue("Fruit should be sweet - expected TRUE", someFruit.isSweet());
        assumeTrue("Orange has orange color, but was - " + someFruit.getColor(),
                someFruit.getColor().equals(Color.ORANGE));

        // Далее дегустатор полностью уверен что фрукт можно кусать.
    }

Хорошо заметно, что у каждого нового «дегустатора» при таком описании сценария есть два пути — копипастить сценарий или делать новый метод на каждый набор предпроверок. И в каждой предпроверке снова нужно самому заботиться о составлении сообщения о причине выбраковки. Любой из этих вариантов сложно поддерживать и очень страшно воспринимать.

Вдобавок ко всему, придется отказываться от assertEquals, assertNotEquals, assertNotNull, assertArrayEquals и т.д. В стандартной поставке JUnit эти assert* есть почти на любой тривиальный случай. А некоторых еще и несколько — на каждый тип аргументов. То есть логика проверки заключена в названии метода и жёстко привязана к его реализации. А теперь представьте, сколько нужно было бы кода дублировать и поддерживать, если на каждый assert* пришлось бы сделать аналогичный assume*.

Значит, нужно разделить логику самой проверки, вывода описания и функцию принятия решения:

  • бракуем — assert,
  • игнорируем — assume,
  • а так же, фильтруем, ищем нужные, просеиваем, изменяем и т.д.

Тут в дело и вступают матчеры — маленькие объекты, которые содержат логику принятия решения, знают, что ждали и что получили, о чем самостоятельно сообщают. Первые три проверки при помощи матчеров почти что поэтично описывают действие. И это может прочитать любой, кто хочет узнать, что делает сценарий.

@Test
    public void orangeIsRoundWithMatcher() {
        assertThat(someFruit, is(round()));
    }

    @Test
    public void orangeIsSweetWithMatcher() {
        assertThat(someFruit, is(sweet()));
    }
    
    @Test
    public void orangeHasColorWithMatcher() {
        assertThat(someFruit, hasColor(Color.ORANGE));
    }

Для такой красоты, существует специальная библиотека Hamcrest. Она содержит в себе и интерфейс для реализации, и методы assertThat и assumeThat (последний, на самом деле, внутри JUnit, но использует интерфейс из Hamcrest). Они и спрашивают матчер об объекте, принимая решение.

Начиная с версии 4.11, в зависимостях JUnit библиотека Hamcrest имеет версию не ниже 1.3. Именно она ввела интерфейс, в котором реализовано всё, что описано дальше. Поэтому, используя мавен, достаточно подключить JUnit 4.11, и минимально необходимый набор инструментов готов к использованию. А для полного набора всех доступных матчеров из поставки Hamcrest, понадобится артифакт hamcrest-all, который можно подключить отдельно.

Так может выглядеть ваш pom.

Как это работает?

В библиотеке есть абстрактный класс TypeSafeMatcher<Fruit>, параметризуемый по типу проверяемого объекта. Класс предоставляет для переопределения три метода:

  • public boolean matchesSafely(Fruit fruit) — логика проверки,
  • public void describeTo(Description description) — описание ожидаемого значения,
  • protected void describeMismatchSafely(Fruit item, Description mismatchDescription) — описание полученного значения.

Экземпляр класса, расширяющего этот, перед выполнением собственного кода выполнит родительский — рутинные проверки поступающего объекта на null и соответствие указанному классу.

Например, матчер, проверяющий форму фрукта, выглядит так:

public class ShapeMatcher extends TypeSafeMatcher<Fruit> {
    private Shape expected;

    public ShapeMatcher(Shape expected) {
        this.expected = expected;
    }

    @Override
    public boolean matchesSafely(Fruit fruit) {
        return expected.equals(fruit.getShape());
    }

    @Override
    protected void describeMismatchSafely(Fruit item, Description mismatchDescription) {
        mismatchDescription.appendText("fruit has shape - ").appendValue(item.getShape());
    }

    @Override
    public void describeTo(Description description) {
        description.appendText("shape - ").appendValue(expected);
    }

    @Factory
    public static ShapeMatcher round() {
        return new ShapeMatcher(Shape.ROUND);
    }
}

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

Но и это не все

Частая ситуация, как, например, выше, вызвана необходимостью использовать для проверки только одно свойство объекта. Целый класс для этого — лишняя трата времени и сил. Здесь на помощь приходят анонимные классы Java и абстрактный класс FeatureMatcher<WhatWeGet, WhatWeWannaCheck>, параметризуемый двумя типами: какой объект поступит на вход и свойство какого типа нужно проверить.

Конструктор у этого класса один и требует 3 атрибута:

  • матчер, который применим к WhatWeWannaCheck типу,
  • описание ожидания (оно добавится к описанию субматчера),
  • описание полученного значения (оно добавится к мисматч-описанию субматчера)

Потомок этого класса, переопределив метод featureValueOf позволит вытащить нужное свойство из объекта и применить к нему существующий матчер. А их в поставке Hamcrest хватает для любых стандартных типов.

Перепишем наш матчер для формы, а заодно и остальные, используя этот класс.

public class Matchers {

    public static Matcher<Fruit> hasShape(final Shape shape) {
        return new FeatureMatcher<Fruit, Shape>(equalTo(shape), "fruit has shape - ", "shape -") {
            @Override
            protected Shape featureValueOf(Fruit fruit) {
                return fruit.getShape();
            }
        };
    }

    public static Matcher<Fruit> round() {
        return hasShape(Shape.ROUND);
    }


    public static Matcher<Fruit> sweet() {
        return new FeatureMatcher<Fruit, Boolean>(is(true), "fruit should be sweet", "sweet -") {
            @Override
            protected Boolean featureValueOf(Fruit fruit) {
                return fruit.isSweet();
            }
        };
    }


    public static Matcher<Fruit> hasColor(Color color) {
        return new FeatureMatcher<Fruit, Color>(equalTo(color), "fruit have color - ", "color -") {
            @Override
            protected String featureValueOf(Fruit fruit) {
                return fruit.getColor();
            }
        };
    }

}

Feel the POWER OF MATCHERS

Одно из главных преимуществ матчеров — возможность их объединения. Для этого в Hamcrest есть целый ряд специальных связующих: allOf, anyOf, both, either. Каждый из них заботливо соединит и описание ожидаемого значения, и описание проваленных матчеров из цепочки.

Благодаря этому, сценарий предпроверки для нашего «дегустатора» сокращается еще сильнее:

@Test
    public void orangeBothSweetRoundAndOrangeColorWithMatchers() throws Exception {
        assumeThat(someFruit, both(round()).and(sweet()).and(hasColor(Color.ORANGE)));
        // дальше дегустаторская магия
    }

Исходники всех тестов.

Еще одна из замечательных возможностей, которую даёт эта технология, — применение одного или ряда матчеров к коллекции. Предположим, вместо одного фрукта за раз стал поступать целый набор и все объекты в нём нужно проверить одновременно. Больше не нужно никаких циклов — все проще простого:


@Test
    public void orangeBothSweetRoundAndOrangeColorWithMatchers() throws Exception {
        assertThat(someFruitList, everyItem(both(round()).and(sweet()).and(hasColor(Color.ORANGE))));
    }

Как говорим, так и пишем: проверить каждый элемент нашим матчером. Возможны вариации — например, проверять что в пачке есть хотя бы один объект, удовлетворяющий условию hasItem().

Примеры работы с коллекцией.

Еще раз о велосипедах

Матчеры появились уже давно, и за всё время их существования их и их наборов было создано очень много. Так что прежде чем воодушевленно писать свой матчер, поищите в интернете — скорее всего, он уже кем-то написан. Если вы его не нашли, заходите в нашу библиотеку матчеров, — возможно, там он есть. Если нужного вам матчера нигде нет и вы решили его написать, присылайте нам его реализацию в виде пулл-реквеста. Давайте вместе помогать другим не тратить время на изобретение велосипеда.

Наша библиотека матчеров находится по адресу github.com/yandex-qatools/matchers-java.

Автор: Lanwen

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js