Статьи

Написание пользовательских поставщиков для вашего JUnit @Theory

Вступление

Средство JUnit «Теории» — это (в основном) недокументированный экспериментальный инструмент, который позволяет вам запускать ваш метод @Test (вашу @Theory) для всех возможных комбинаций параметров (ваших @Datapoints), которые вы можете использовать.

Вы можете использовать инструмент Теории для критически важных и сложных сервисов, чтобы обеспечить их «пуленепробиваемость» против любых комбинаций параметров и параметров, которые вы можете себе представить.

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

Простой пример

Много статей было написано на эту тему, обратитесь к ним для правильного введения, но вот короткий пример:

@RunWith(Theories.class)
public class GuessTheMurdererTest {

    public static class Suspect {
        private String name;
        private String color;

        @Override
        public String toString() {
            return String.format("%s (%s)", name, color);
        }

        public Suspect(String name, String color) {
            this.name = name;
            this.color = color;
        }

        public String getName() {
            return name;
        }

        public String getColor() {
            return color;
        }
    }

    @DataPoints
    public static Suspect[] whoMadeIt = {
        new Suspect("Miss Rose", "Pink"),
        new Suspect("Mrs. Peacock", "Blue"),
        new Suspect("Professor Plum", "Purple")
    };
    @DataPoints
    public static String[] whereWasIt = {
        "Library", "Lounge"
    };

    
    @Theory
    public void dummyTest(Suspect murderer, String murderScene) {
        System.out.println(String.format("%s murdered Colonel Mustard in the %s", murderer, murderScene));
    }
}

Метод @Theory здесь — просто фиктивный метод, который печатает все возможные комбинации параметров, заданных @DataPoints.

Это будет выполнено с каждой возможной комбинацией Подозреваемого и комнаты, отсюда и следующий результат:

Miss Rose (Pink) murdered Colonel Mustard in the Library
Miss Rose (Pink) murdered Colonel Mustard in the Lounge
Mrs. Peacock (Blue) murdered Colonel Mustard in the Library
Mrs. Peacock (Blue) murdered Colonel Mustard in the Lounge
Professor Plum (Purple) murdered Colonel Mustard in the Library
Professor Plum (Purple) murdered Colonel Mustard in the Lounge
Professor Plum (Purple) murdered Colonel Mustard in the Lounge

Хорошо, все в порядке, но вот подвох …

Подвох: несколько параметров одного типа

Предположим, мы пытаемся протестировать какой-то старый унаследованный сервисный метод, чтобы найти уникальную комбинацию параметров, которая слишком долго вызывала «случайную» ошибку …

Старый метод не принимает объект «Подозреваемый» и «Комната» в качестве параметров, а только 2 строки …

Применительно к нашему примеру это будет примерно так:

@RunWith(Theories.class)
public class GuessTheMurdererTest {
    @DataPoints
    public static String[] whoMadeIt = {
        "Miss Rose", "Mrs. Peacock", "Professor Plum"
    }
    ;
    @DataPoints
    public static String[] whereWasIt = {
        "Library", "Lounge"
    }
    ;
    @Theory
    public void dummyTest(String murderer, String murderScene) {
        System.out.println(String.format("%s murdered Colonel Mustard in the %s", murderer, murderScene));
    }
}

Ничего страшного, верно? Давайте запустим это, тогда …

Miss Rose murdered Colonel Mustard in the Miss Rose
Miss Rose murdered Colonel Mustard in the Mrs. Peacock
Miss Rose murdered Colonel Mustard in the Professor Plum
Miss Rose murdered Colonel Mustard in the Library
Miss Rose murdered Colonel Mustard in the Lounge
Mrs. Peacock murdered Colonel Mustard in the Miss Rose
Mrs. Peacock murdered Colonel Mustard in the Mrs. Peacock
Mrs. Peacock murdered Colonel Mustard in the Professor Plum
Mrs. Peacock murdered Colonel Mustard in the Library
Mrs. Peacock murdered Colonel Mustard in the Lounge
Professor Plum murdered Colonel Mustard in the Miss Rose
Professor Plum murdered Colonel Mustard in the Mrs. Peacock
Professor Plum murdered Colonel Mustard in the Professor Plum
Professor Plum murdered Colonel Mustard in the Library
Professor Plum murdered Colonel Mustard in the Lounge
Library murdered Colonel Mustard in the Miss Rose
Library murdered Colonel Mustard in the Mrs. Peacock
Library murdered Colonel Mustard in the Professor Plum
Library murdered Colonel Mustard in the Library
Library murdered Colonel Mustard in the Lounge
Lounge murdered Colonel Mustard in the Miss Rose
Lounge murdered Colonel Mustard in the Mrs. Peacock
Lounge murdered Colonel Mustard in the Professor Plum
Lounge murdered Colonel Mustard in the Library
Lounge murdered Colonel Mustard in the Lounge

Чего ждать?! Подозреваемый убил этого бедного полковника в другом подозреваемом ?! Библиотека кого-то убила? А? Звучит странно!

Фактически, бегун Теорий выбирает потенциальные назначения параметров в зависимости от их типа.
Здесь «murderer» и «murderScene» оба являются String … поэтому список возможных значений представляет собой объединение значений обоих точек данных.

Это может быть действительно громоздким в реальном проекте.
Хотя вы можете обойти это с помощью enums или пользовательских провайдеров, это довольно много …

Обходной путь: «названный» таможенный поставщик

Что если бы мы могли добавить «имя» в @Datapoints и использовать его в качестве ссылки для возможных значений определенного параметра?

К счастью, JUnit позволяет вам определять поставщиков пользовательских данных.

Поймите таможенного поставщика: фиктивный поставщик

Нам нужно определить новую аннотацию, которая определяет класс поставщика, который будет использоваться для параметра, аннотированного этой аннотацией

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@ParametersSuppliedBy(FooBarSupplier.class)
public @interface FooBar{
}

Затем нам нужно определить класс поставщика.

public static class FooBarSupplier extends ParameterSupplier {
    @Override
    public List<PotentialAssignment> getValueSources(ParameterSignature sig) {
        List<PotentialAssignment> list = new ArrayList<PotentialAssignment>();
        list.add((PotentialAssignment.forValue("foo", "bar1"));
        list.add((PotentialAssignment.forValue("foo", "bar2"));
        list.add((PotentialAssignment.forValue("foo", "bar2"));
        return list;
   }
}

Этот поставщик просто предоставит 3 жестко закодированных значения для демонстрации

Чтобы использовать этого поставщика:

@Theory
public static void fooBarTheory(@FooBar String bar) {
...
}

Этот метод будет вызван 3 раза, с «bar1», «bar2», «bar3»

Напишите на заказ поставщика

Давайте использовать эту функцию и добавим нашего собственного поставщика:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface DataPointsRef {
    String value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@ParametersSuppliedBy(WithDataPointsSupplier.class)
public @interface WithDataPoints {
    Class<?> clazz();
    String name();
}
public static class WithDataPointsSupplier extends ParameterSupplier {
    @Override
    public List<PotentialAssignment> getValueSources(ParameterSignature sig) {
        List<PotentialAssignment> list = new ArrayList<PotentialAssignment>();
        WithDataPoints ref = (WithDataPoints) sig.getAnnotation(WithDataPoints.class);
        Field[] fields = ref.clazz().getFields();
        for (Field field : fields) {
            DataPointsRef dpSupplier = field.getAnnotation(DataPointsRef.class);
            if (dpSupplier != null) {
                if (dpSupplier.value().equals(ref.name())) {
                    Class<?> fieldType = field.getType();
                    if (!fieldType.isArray()) {
                        throw new RuntimeException("Referenced Datapoint must be an array.");
                    }
                    try {
                        Object values = field.get(null);
                        for (int i = 0; i < Array.getLength(values); i++) {
                            Object value = Array.get(values, i);
                            list.add(PotentialAssignment.forValue(withTag.name(), value));
                        }
                    } catch (IllegalArgumentException e) {
                        throw new RuntimeException(e);
                    }
                    catch (IllegalAccessException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        }
        return list;
    }
}
Это просто пример реализации, который пытается найти открытое поле в классе (определяемом аннотацией @WithDataPoints для параметра) с типом массива, который имеет аннотацию @DataPointsRef.

Вы можете легко изменить это, чтобы загрузить бин из Spring Context, прочитать файл свойств, получить данные из базы данных, …

Используйте нестандартного поставщика

Давайте воспользуемся этим новым поставщиком и исправим наш тестовый пример:

@RunWith(Theories.class)
public class GuessTheMurdererTest {
    @DataPointsRef("suspects")
    public static String[] whoMadeIt = {
        "Miss Rose", "Mrs. Peacock", "Professor Plum"
    };
    @DataPointsRef("rooms")
    public static String[] whereWasIt = {
        "Library", "Lounge"
    };

    @Theory
    public void dummyTest(
    @WithDataPoints(clazz=GuessTheMurdererTest.class, name="suspects")
    String murderer,
    @WithDataPoints(clazz=GuessTheMurdererTest.class, name="rooms")
    String murderScene) {
        System.out.println(String.format("%s murdered Colonel Mustard in the %s", murderer, murderScene));
    }
}

Our @Datapoints can now have a name, and the @Theory parameters get a new annotation referencing this name.

If we run this test, we get this output:

Miss Rose murdered Colonel Mustard in the Library
Miss Rose murdered Colonel Mustard in the Lounge
Mrs. Peacock murdered Colonel Mustard in the Library
Mrs. Peacock murdered Colonel Mustard in the Lounge
Professor Plum murdered Colonel Mustard in the Library
Professor Plum murdered Colonel Mustard in the Lounge

Much better…

Let’s push it just a little more…
We just purchased the new edition of the game, new suspects are available…

@RunWith(Theories.class)
public class GuessTheMurdererTest {
    @DataPointsRef("suspects")
    public static String[] whoMadeIt = {
        "Miss Rose", "Mrs. Peacock", "Professor Plum"
    }
    ;
    @DataPointsRef("suspects")
    public static String[] orMaybeItWasSomeoneElse = {
        "Mr. Slate-Grey", "Captain Brown"
    }
    ;
    @DataPointsRef("rooms")
    public static String[] whereWasIt = {
        "Hall", "Kitchen"
    }
    ;
    @Theory
    public void dummyTest(
    @WithDataPoints(clazz=GuessTheMurdererTest.class, name="suspects")
    String murderer,
    @WithDataPoints(clazz=GuessTheMurdererTest.class, name="rooms")
    String murderScene) {
        System.out.println(String.format("%s murdered Colonel Mustard in the %s", murderer, murderScene));
    }
}
Miss Rose murdered Colonel Mustard in the Hall
Miss Rose murdered Colonel Mustard in the Kitchen
Mrs. Peacock murdered Colonel Mustard in the Hall
Mrs. Peacock murdered Colonel Mustard in the Kitchen
Professor Plum murdered Colonel Mustard in the Hall
Professor Plum murdered Colonel Mustard in the Kitchen
Mr. Slate-Grey murdered Colonel Mustard in the Hall
Mr. Slate-Grey murdered Colonel Mustard in the Kitchen
Captain Brown murdered Colonel Mustard in the Hall
Captain Brown murdered Colonel Mustard in the Kitchen

Good! Datapoints values are now aggregated by name rather than type.

Enhancing the custom supplier: introducing «Tags»

Suppose we buy yet another game’s extention, the murderer now has an accomplice.
Some suspects can only be «murderers», some can only be «accomplices», some can be both «murderer» or «accomplice»

We could easily imagine extending the Supplier to add multiple «tags» to a @Datapoint.
Let’s modify our annotations and supplier:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface TaggedDataPoints {
    //Multiple values allowed
    String[] value();
}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@ParametersSuppliedBy(TaggedDataPointsSupplier.class)
public @interface WithTags {
    Class<?> clazz();
    String name();
}
public static class TaggedDataPointsSupplier extends ParameterSupplier {
    @Override
    public List<PotentialAssignment> getValueSources(ParameterSignature sig) {
        List<PotentialAssignment> list = new ArrayList<PotentialAssignment>();
        WithTags withTag = (WithTags) sig.getAnnotation(WithTags.class);
        Field[] fields = withTag.clazz().getFields();
        for (Field field : fields) {
            TaggedDataPoints taggedDataPoints = field.getAnnotation(TaggedDataPoints.class);
            if (taggedDataPoints != null) {
                //browse tags, an select values if one matches
                for (String tag : taggedDataPoints.value()) {
                    if (tag.equals(withTag.name())) {
                        Class<?> fieldType = field.getType();
                        if (!fieldType.isArray()) {
                            throw new RuntimeException("Referenced Datapoint must be an array.");
                        }
                        try {
                            Object values = field.get(null);
                            for (int i = 0; i < Array.getLength(values); i++) {
                                Object value = Array.get(values, i);
                                list.add(PotentialAssignment.forValue(withTag.name(), value));
                            }
                        }
                        catch (IllegalArgumentException e) {
                            throw new RuntimeException(e);
                        }
                        catch (IllegalAccessException e) {
                            throw new RuntimeException(e);
                        }
                    }
                }
            }
        }
        return list;
    }
}

We can now distinguish those new «suspects» and «accomplices», and add an accomplice to the @Theory:

@RunWith(Theories.class)
public class GuessTheMurdererTest {
    @TaggedDataPoints("suspects")
    public static String[] suscpectsOnly = {
        "Miss Rose", "Mrs. Peacock", "Professor Plum"
    };
    @TaggedDataPoints({ "suspects", "accomplices" })
    public static String[] suspectsOrAccomplices = {
        "Mr. Slate-Grey"
    };
    @TaggedDataPoints("accomplices")
    public static String[] accomplicesOnly = {
        "Captain Brown"
    };
    @TaggedDataPoints("rooms")
    public static String[] whereWasIt = {
        "Hall", "Kitchen"
    };

    @Theory
    public void dummyTest(
    @WithTags(clazz = GuessTheMurdererTest.class, name = "suspects") String murderer,
    @WithTags(clazz = GuessTheMurdererTest.class, name = "accomplices") String accomplice,
    @WithTags(clazz = GuessTheMurdererTest.class, name = "rooms") String murderScene) {
        // Ensure no-one is accompliced to himself...
        Assume.assumeTrue(!murderer.equalsIgnoreCase(accomplice));
        System.out.println(String.format("%s and his/her accomplice %s both murdered Colonel Mustard in the %s", murderer, accomplice, murderScene));
        // Assert that Captain brown cannot be the murderer
        Assert.assertFalse(murderer.equalsIgnoreCase("Captain Brown"));
        // Assert that Professor Plum cannot be an accomplice
        Assert.assertFalse(accomplice.equalsIgnoreCase("Professor Plum"));
    }
}

If everything went fine, asserts are all ok, and you get:

Miss Rose and his/her accomplice Mr. Slate-Grey both murdered Colonel Mustard in the Hall
Miss Rose and his/her accomplice Mr. Slate-Grey both murdered Colonel Mustard in the Kitchen
Miss Rose and his/her accomplice Captain Brown both murdered Colonel Mustard in the Hall
Miss Rose and his/her accomplice Captain Brown both murdered Colonel Mustard in the Kitchen
Mrs. Peacock and his/her accomplice Mr. Slate-Grey both murdered Colonel Mustard in the Hall
Mrs. Peacock and his/her accomplice Mr. Slate-Grey both murdered Colonel Mustard in the Kitchen
Mrs. Peacock and his/her accomplice Captain Brown both murdered Colonel Mustard in the Hall
Mrs. Peacock and his/her accomplice Captain Brown both murdered Colonel Mustard in the Kitchen
Professor Plum and his/her accomplice Mr. Slate-Grey both murdered Colonel Mustard in the Hall
Professor Plum and his/her accomplice Mr. Slate-Grey both murdered Colonel Mustard in the Kitchen
Professor Plum and his/her accomplice Captain Brown both murdered Colonel Mustard in the Hall
Professor Plum and his/her accomplice Captain Brown both murdered Colonel Mustard in the Kitchen
Mr. Slate-Grey and his/her accomplice Captain Brown both murdered Colonel Mustard in the Hall
Mr. Slate-Grey and his/her accomplice Captain Brown both murdered Colonel Mustard in the Kitchen

Pretty nice isn’t it?

A few final words…

This could give you a neat way of dividing values of a same list according to a fonctional property.

Suppose we get a «Folder» list, some of them are «Input folders», some others are «Output folders», and a few are «Input/Output folders»
Of course, ideally, you would create different objects, but if the tested method just takes two String, that’s a lot of boilerplate…

Additionally, this could be used as an easy way to qualify the test dataitself, some information that does not belong to the «Domain», but belongs to the test itself.
eg: you might want to test some theories against only a subset of the @Datapoints.

Well, that’s it for today, I hope this article gave you some insights on how you can use @Theory, and how you can improve the data you feed to it using custom suppliers.