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


Теперь представьте, что не все тесты должны проходить в этих окружениях — кажому свой набор тестов.


И предпочтительней настроить выбор, какие тесты должны выполняться, в… файле application.properties — кажому тесту свой переключатель "вкл/выкл".


Звучит здорово, не правда ли?


Тогда добро пожаловать под кат, где мы все это и реализуем с помощью SpringBoot 2 и JUnit 5.


Предварительные настройки


Сперва давайте выключим JUnit 4, который поставляется в SpringBoot 2 по-умолчанию, и включим JUnit 5.


Для этого внесем изменения в pom.xml:


<dependencies>
    <!--...-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
        <exclusions>
            <exclusion>
                <groupId>junit</groupId>
                <artifactId>junit</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.3.2</version>
        <scope>test</scope>
    </dependency>
    <!--...-->
</dependencies>

Предполагаемое решение


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


Аннотация


Создадим аннотацию:


@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(TestEnabledCondition.class)
public @interface TestEnabled {
    String property();
}

Обработка аннотации


Без обработчика аннотации не обойтись.


public class TestEnabledCondition implements ExecutionCondition {

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        Optional<TestEnabled> annotation = context.getElement().map(e -> e.getAnnotation(TestEnabled.class));

        return context.getElement()
                        .map(e -> e.getAnnotation(TestEnabled.class))
                        .map(annotation -> {
                            String property = annotation.property();

                            return Optional.ofNullable(environment.getProperty(property, Boolean.class))
                                    .map(value -> {
                                        if (Boolean.TRUE.equals(value)) {
                                            return ConditionEvaluationResult.enabled("Enabled by property: "+property);
                                        } else {
                                            return ConditionEvaluationResult.disabled("Disabled by property: "+property);
                                        }
                                    }).orElse(
                                            ConditionEvaluationResult.disabled("Disabled - property <"+property+"> not set!")
                                    );
                        }).orElse(
                                ConditionEvaluationResult.enabled("Enabled by default")
                        );
    }
}

Необходимо создать класс (без аннотации Spring-а @Component), который реализует интерфейс ExecutionCondition.


В этом классе необходимо реализовать один метод этого интерфейса — ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context).


Этот метод принимает контекст выполняемого JUnit теста и возвращает условие — должен ли тест быть запущен или нет.


Прочитать подробней по условное выполнение тестов JUnit5 можно в официальной документации.


Но как нам проверить значение свойства, которое прописано в application.properties в таком случае?


Получение доступа к контексту Spring из контекста JUnit


Вот таким образом мы можем получить окружение Spring, с которым был запущен наш JUnit тест, из ExtensionContext.


Environment environment = SpringExtension.getApplicationContext(context).getEnvironment();

Можете взглянуть на полный код класса TestEnabledCondition.


Создадим тесты


Давайте создадим несколько тестов и попробуем управлять их запуском:


@SpringBootTest
public class SkiptestApplicationTests {

    @TestEnabled(property = "app.skip.test.first")
    @Test
    public void testFirst() {
        assertTrue(true);
    }

    @TestEnabled(property = "app.skip.test.second")
    @Test
    public void testSecond() {
        assertTrue(false);
    }

}

Наш application.properties файл при этом выглядит так:


app.skip.test.first=true
app.skip.test.second=false

Итак...


Результат запуска:



Следующий шаг — отделим префиксы наших свойств в аннотацию класса


Писать перед каждым тестом полные названия свойств из application.properties — утомительное занятие. Поэтому резонно их префикс вынести на уровень класса тестов — в отдельную аннотацию.


Создадим annotation для хранения префиксов — TestEnabledPrefix:


@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestEnabledPrefix {
    String prefix();
}

Обработка и использование аннотации TestEnabledPrefix


Приступим к обработке новой аннотации.


Давайте создадим вспомогательный класс AnnotationDescription


С помощью этого класса мы сможем хранить имя свойства из application.properties и его значение.


public class TestEnabledCondition implements ExecutionCondition {

    static class AnnotationDescription {
        String name;
        Boolean annotationEnabled;
        AnnotationDescription(String prefix, String property) {
            this.name = prefix + property;
        }
        String getName() {
            return name;
        }
        AnnotationDescription setAnnotationEnabled(Boolean value) {
            this.annotationEnabled = value;
            return this;
        }
        Boolean isAnnotationEnabled() {
            return annotationEnabled;
        }
    }

    /* ... */
}

Нам этот класс пригодится, т.к. мы собираемся использовать lambda-выражения.


Создадим метод, который извлечет нам значение свойства "префикс" из аннотации класса TestEnabledPrefix


public class TestEnabledCondition implements ExecutionCondition {

    /* ... */

    private AnnotationDescription makeDescription(ExtensionContext context, String property) {
        String prefix = context.getTestClass()
                .map(cl -> cl.getAnnotation(TestEnabledPrefix.class))
                .map(TestEnabledPrefix::prefix)
                .map(pref -> !pref.isEmpty() && !pref.endsWith(".") ? pref + "." : "")
                .orElse("");
        return new AnnotationDescription(prefix, property);
    }

    /* ... */

}

И теперь проверим значение свойства из application.properties по имени, указанном в аннотации теста


public class TestEnabledCondition implements ExecutionCondition {

    /* ... */

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        Environment environment = SpringExtension.getApplicationContext(context).getEnvironment();

        return context.getElement()
                .map(e -> e.getAnnotation(TestEnabled.class))
                .map(TestEnabled::property)
                .map(property -> makeDescription(context, property))
                .map(description -> description.setAnnotationEnabled(environment.getProperty(description.getName(), Boolean.class)))
                .map(description -> {
                    if (description.isAnnotationEnabled()) {
                        return ConditionEvaluationResult.enabled("Enabled by property: "+description.getName());
                    } else {
                        return ConditionEvaluationResult.disabled("Disabled by property: "+description.getName());
                    }
                }).orElse(
                        ConditionEvaluationResult.enabled("Enabled by default")
                );

    }

}


Полный код класса доступен по ссылке.


Использование новой аннотации


Теперь применим нашу аннотацию к тест-классу:


@SpringBootTest
@TestEnabledPrefix(property = "app.skip.test")
public class SkiptestApplicationTests {

    @TestEnabled(property = "first")
    @Test
    public void testFirst() {
        assertTrue(true);
    }

    @TestEnabled(property = "second")
    @Test
    public void testSecond() {
        assertTrue(false);
    }

}

Теперь наш код тестов стал чище и проще.


Хочу выразить благодарность пользователям reddit-а за их советы:


1) dpash за совет
2) BoyRobot777 за совет


P.S.


Статья является авторским переводом. Английский вариант опубликован в README.md файле рядом с кодом проекта.

Комментарии (9)


  1. sshikov
    24.08.2019 18:17
    -1

    А не проще на TestNG перейти? Не, ну реально — что есть в JUnit такого, чего нет в TestNG?

    Ассерты все равно удобнее на hamcrest писать.

    А группы тестов там есть (насколько я помню — давно), они включаются и выключаются при запуске.


    1. bvn13 Автор
      24.08.2019 18:27
      +1

      «Перейти» не всегда проще.

      К тому же, некоторые статьи пишутся для proof of concept.


      1. sshikov
        24.08.2019 18:54

        >Перейти» не всегда проще.
        Вы не поверите — я пробовал. TestNG появился в 2004 — вот тогда же и попробовал. Вот посмотрите на этот код, и скажите мне, отличается ли он чем-то от JUnit?

        package example1;
         
        import org.testng.annotations.*;
         
        public class SimpleTest {
         
         @BeforeClass
         public void setUp() {
           // code that will be invoked when this test is instantiated
         }
         
         @Test(groups = { "fast" })
         public void aFastTest() {
           System.out.println("Fast test");
         }
         
         @Test(groups = { "slow" })
         public void aSlowTest() {
            System.out.println("Slow test");
         }
         
        }
        


        На мой взгляд — практически ничем, кроме разве что набора аннотаций. При этом группы, как вы видите, тут есть из коробки. Если вы пользуетесь не чем-то экзотическим, то например maven эти тесты будет запускать точно так же, как и запускал. Включить и отключить можно из командной строки.

        proof of concept — ну это вполне понятно, даже ради удовлетворения любопытства. Я в общем-то и хочу понять, дает ли это в итоге что-то принципиально такое, чего не было ранее, уже много лет?


        1. Kanut
          24.08.2019 20:15
          -1

          С «перейти» бычно проблема в «легаси», которое существует на момент перехода.


          1. sshikov
            24.08.2019 20:55

            С легаси — то есть с написанными тестами? Ну понятно, что усилия придется потратить. Ну так предлагаемое решение — оно ведь тоже далеко не бесплатное. @TestEnabled(property = «first») — оно ведь само в код не добавится, его нужно ручками вписать везде где нужно. И никакая IDE за нас не решит, где именно нам нужно.

            На первый взгляд — усилия сопоставимые. Впрочем, я никого не агитирую, хотя TestNG — хорошая, проверенная временем штука.


            1. Kanut
              24.08.2019 21:11
              +2

              Скажем так: у меня есть опыт работы с несколькими юнит-тест-фреймворками в одном проекте и повторять его я не особо хочу:)

              А дальше всё зависит от того сколько старых юнит-тестов надо переписать и сколько проперти-атрибутов надо добавить в код. И если соотношение несколько тысяч к нескольким десяткам, то, процитирую ещё раз:

              Перейти» не всегда проще.

              :)


              1. sshikov
                24.08.2019 21:28

                >Скажем так: у меня есть опыт работы с несколькими юнит-тест-фреймворками в одном проекте
                У меня тоже. Негативных эффектов не припомню (но агитировать просто так ради развлечения — не стану тоже. Оно того не стоит — эти фреймворки на мой взгляд почти равноценны). Я бы еще подумал, если бы второй фреймворк был скажем типа property based, ну или что-то типа мутаций тестов бы делал. Тогда может быть была бы некая польза.

                >Перейти не всегда проще.

                Так я не спорю с этим утверждением в целом. Я просто оценил на глаз для частного случая стоимость того и другого. Выглядит примерно одинаково. Я запросто мог чего-то не учесть, но опыт перехода на TestNG у меня был тоже — и такой переход совсем не сложный, особенно если мы можем в спокойной обстановке переводить один тест за другим.

                Более того (цитирую документацию):

                TestNG can automatically recognize and run JUnit tests, so you can use TestNG as a runner for all your existing tests and write new tests using TestNG.

                Ну то есть, в оптимистичном случае переписывать вообще не нужно. Но это не я вам обещал, а Цедрик ;)


  1. orthanner
    25.08.2019 07:17

    Хм, Spring Boot… а почему не используем SpringRunner и @IfProfileValue?


    1. bvn13 Автор
      25.08.2019 20:04

      @IfProfileValue не используется, потому что:


      app.skip.test.third=false

      @IfProfileValue(name = "app.skip.test.third", value = "true")
          @Test
          public void testThird() {
              assertTrue(false);
          }

      Результат:


      org.opentest4j.AssertionFailedError: expected: <true> but was: <false>

      А SpringRunner же нужен для автовайринга. Для демо-примера не используется.