Представьте себе ситуацию, когда ваш проект должен компилироваться в различных окружениях.
Теперь представьте, что не все тесты должны проходить в этих окружениях — кажому свой набор тестов.
И предпочтительней настроить выбор, какие тесты должны выполняться, в… файле 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)
orthanner
25.08.2019 07:17Хм, Spring Boot… а почему не используем SpringRunner и @IfProfileValue?
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 же нужен для автовайринга. Для демо-примера не используется.
sshikov
А не проще на TestNG перейти? Не, ну реально — что есть в JUnit такого, чего нет в TestNG?
Ассерты все равно удобнее на hamcrest писать.
А группы тестов там есть (насколько я помню — давно), они включаются и выключаются при запуске.
bvn13 Автор
«Перейти» не всегда проще.
К тому же, некоторые статьи пишутся для proof of concept.
sshikov
>Перейти» не всегда проще.
Вы не поверите — я пробовал. TestNG появился в 2004 — вот тогда же и попробовал. Вот посмотрите на этот код, и скажите мне, отличается ли он чем-то от JUnit?
На мой взгляд — практически ничем, кроме разве что набора аннотаций. При этом группы, как вы видите, тут есть из коробки. Если вы пользуетесь не чем-то экзотическим, то например maven эти тесты будет запускать точно так же, как и запускал. Включить и отключить можно из командной строки.
proof of concept — ну это вполне понятно, даже ради удовлетворения любопытства. Я в общем-то и хочу понять, дает ли это в итоге что-то принципиально такое, чего не было ранее, уже много лет?
Kanut
С «перейти» бычно проблема в «легаси», которое существует на момент перехода.
sshikov
С легаси — то есть с написанными тестами? Ну понятно, что усилия придется потратить. Ну так предлагаемое решение — оно ведь тоже далеко не бесплатное. @TestEnabled(property = «first») — оно ведь само в код не добавится, его нужно ручками вписать везде где нужно. И никакая IDE за нас не решит, где именно нам нужно.
На первый взгляд — усилия сопоставимые. Впрочем, я никого не агитирую, хотя TestNG — хорошая, проверенная временем штука.
Kanut
Скажем так: у меня есть опыт работы с несколькими юнит-тест-фреймворками в одном проекте и повторять его я не особо хочу:)
А дальше всё зависит от того сколько старых юнит-тестов надо переписать и сколько проперти-атрибутов надо добавить в код. И если соотношение несколько тысяч к нескольким десяткам, то, процитирую ещё раз:
:)
sshikov
>Скажем так: у меня есть опыт работы с несколькими юнит-тест-фреймворками в одном проекте
У меня тоже. Негативных эффектов не припомню (но агитировать просто так ради развлечения — не стану тоже. Оно того не стоит — эти фреймворки на мой взгляд почти равноценны). Я бы еще подумал, если бы второй фреймворк был скажем типа 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.
Ну то есть, в оптимистичном случае переписывать вообще не нужно. Но это не я вам обещал, а Цедрик ;)