Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Качеством кода в тестах часто пренебрегают. Когда в совместной разработке участвуют десятки QA-инженеров, возникает острая необходимость ввести формализованные правила, чтобы все могли быстро ориентироваться в тестовом проекте. К тому же часто тесты пишутся по аналогии или копируются с небольшими изменениями. Когда счет тестов идет на тысячи, то код, написанный в плохом стиле, быстро распространяется. Для решения этих проблем в тестовом проекте Wrike мы уже больше двух лет используем связку инструментов PMD и Checkstyle. И она отлично работает. В этой статье хотим поделиться опытом по настройке этих инструментов, их использованию и кастомизации.
В статье мы рассматриваем инструменты для контроля качества кода в тестовом проекте, который написан на Java с помощью фреймворка JUnit. В качестве системы хранения версий мы используем Git, а для сборки — Maven.
Для команды QA Automation в Wrike проблема чистоты кода очень актуальна: в нашем тестовом проекте более 30 тысяч тестов, а контрибьютят в него более 70 человек.
Наиболее частые проблемы, с которыми мы сталкиваемся:
Неиспользуемые или нежелательные импорты, переменные и private-методы.
Большая длина строки и большой размер файла.
Названия сущностей не в соответствии с договоренностями.
Проблемы стилистического характера: пустые строки, несколько выражений на одной строке, отсутствие пробелов и т.п.
А еще есть проблемы, которые связаны со спецификой тестовых проектов:
Неправильная разметка Epic/Feature/Story тестов.
Необходимость проставления тегов некоторым группам тестов.
Поддержка уникальности ID тестовых сценариев.
Для поддержания чистоты кода в компаниях часто используется практика ревью кода перед мержем ветки в мастер. Такой подход работает хорошо, но ревьюер — это человек, а человек может ошибиться. Хочется, чтобы на ревью приходил код без пропущенных пробелов, неправильно названных переменных и других подобных недостатков.
Практически все современные IDE поддерживают механизм инспекций в среде разработки без подключения внешних инструментов. Инструмент инспекций в популярной Intellij IDEA удобный для разработки, но достаточно тяжеловесный для организации процессов CI, поэтому этот вариант для нас не подошел. Также мы хотели сделать универсальный инструмент для всех разработчиков, а не только тех, которые используют Intellij IDEA.
Проверять практически все стилистические проблемы в коде нам в Wrike помогает инструмент Checkstyle. Но он не позволяет проверить логические кейсы — проставление аннотаций тестам, которые соответствуют определенным требованиям, проверка на неиспользуемые сущности в коде и т.д.
Для проверки логических ошибок мы используем статический анализатор кода PMD. Он позволяет проверять наиболее популярные кейсы, а также писать свои кастомные правила для любых проверок внутри файла. Но у него тоже есть недостаток — PMD может производить проверку файла только независимо от других файлов. Этого достаточно для большинства проверок, но мы не можем, например, проверить неиспользуемые public-методы, как в IDE.
Как подключить PMD и Checkstyle
Существует несколько способов работы с PMD. Мы используем PMD Java API. Хоть этот способ не самый простой для подключения, он позволяет наиболее гибко настраивать проверки и обрабатывать результаты. Тонкости настройки PMD можно прочитать в мануале.
PMD принимает список файлов для проверки и конфигурацию, в которой указаны нужные правила, и возвращает отчет в виде класса Report. Отчет можно вывести в консоль в читабельном виде.
Список правил для проверки хранится в XML-файле, туда же можно добавлять кастомные правила.
<?xml version="1.0"?>
<ruleset name="Custom Ruleset of standard checks"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 http://pmd.sourceforge.net/ruleset_2_0_0.xsd">
<description>
Ruleset of standard checks.
</description>
<exclude-pattern>.*/com/wrike/codestyle/.*</exclude-pattern>
<rule ref="category/java/codestyle.xml">
<exclude name="LocalVariableCouldBeFinal"/>
...
</rule>
...
<rule ref="category/java/codestyle.xml/FieldNamingConventions">
<properties>
<property name="enumConstantPattern" value="[A-Z][a-zA-Z_0-9]*"/>
</properties>
</rule>
</ruleset>
Checkstyle, как и PMD, подключается через Maven:
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>8.41</version>
</dependency>
Для запуска проверки необходимо передать в Main список файлов для проверки и путь к файлу для отчета. После выполнения файл отчета можно проанализировать. Если Checkstyle находит ошибки, он завершает исполняемую программу с кодом ошибки 1. В этом случае мы не сможем проанализировать файл отчета. Чтобы избежать этого, перед выполнением Checkstyle нужно переопределить SecurityManager таким образом, чтобы программа не заканчивала работу, а «бросала» исключение. Если исключение было «брошено», будем выводить ошибки из файла отчета в консоль.
Выглядит это так:
private void checkCodeStyle(Set<File> fileSet) {
System.setSecurityManager(new SecurityManager() {
@Override
public void checkPermission(Permission perm) {
/* Allow everything.*/
}
@Override
public void checkExit(int status) {
/* Don't allow exit with failed status code.*/
if (status > 0) {
throw new CodeStyleViolationException();
}
}
});
try {
checkFilesWithCheckStyle(fileSet);
} catch (CodeStyleViolationException e) {
printViolation();
}
}
Список правил в Checkstyle также представляет собой XML-файл:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="error"/>
<module name="SuppressWarningsFilter"/>
...
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
...
<module name="SuppressWarningsHolder"/>
</module>
Список файлов, которые исключаются из проверки, хранится в отдельном XML-файле:
<?xml version="1.0"?>
<!DOCTYPE suppressions PUBLIC
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
<suppressions>
<suppress files="[/\\]\.idea[/\\].*" checks=".*"/>
<suppress files="src[/\\]main[/\\]resources[/\\]checkstyle[/\\]" checks=".*"/>
</suppressions>
После того, как обе утилиты сконфигурированы, необходимо определить, какие файлы проверять. Не хочется прогонять проверки Checkstyle и PMD по всему проекту, если изменились всего несколько файлов. Для решения этой проблемы мы используем команду git diff --name-only origin/master
. С ее помощью мы получаем список файлов для проверки, которые были изменены в этой ветке (в сравнении с мастером). Предварительно выполняется команда git fetch
, которая «подтягивает» все изменения.
Чтобы не настраивать PMD и Checkstyle самостоятельно, вы можете воспользоваться готовым проектом. В нем уже настроены все базовые проверки и кастомные правила, описанные в этой статье.
Для данного класса:
public class CommonTest {
@Test
public void test() {
if (true) {
;
}
}
}
Вывод проверок Checkstyle и PMD выглядит так:
Вывод проверок
2021-07-20 15:24:41,815 [main] ERROR com.wrike.codestyle.PMDTest -
Problems with PMD, found [2] violation(s)
2021-07-20 15:24:41,815 [main] ERROR com.wrike.codestyle.PMDTest - [
/src/test/java/com/wrike/tests/CommonTest.java
.(CommonTest.java:9)
at line `9` and column `13`
Do not use if statements that are always true or always false
UnconditionalIfStatement[
public class Foo {
public void close() {
if (true) { // fixed conditional, not recommended
// ...
}
}
}
]
check in https://pmd.github.io/pmd-6.33.0/pmd_rules_java_errorprone.html#unconditionalifstatement
-----------------------------------------------------------------------------
/src/test/java/com/wrike/tests/CommonTest.java
.(CommonTest.java:10)
at line `10` and column `13`
An empty statement (semicolon) not part of a loop
EmptyStatementNotInLoop[
public void doit() {
// this is probably not what you meant to do
;
// the extra semicolon here this is not necessary
System.out.println("look at the extra semicolon");;
}
]`
check in https://pmd.github.io/pmd-6.33.0/pmd_rules_java_errorprone.html#emptystatementnotinloop
-----------------------------------------------------------------------------
]
com.wrike.codestyle.PMDTest$PMDViolationException
...
2021-07-20 15:24:41,829 [main] INFO com.wrike.codestyle.CheckCodeStyleUtils -
List of 1 files for checking is:
/src/test/java/com/wrike/tests/CommonTest.java
Checkstyle ends with 1 errors.
2021-07-20 15:24:42,416 [main] ERROR com.wrike.codestyle.CheckStyleTest - [
/src/test/java/com/wrike/tests/CommonTest.java:7:5
.(CommonTest.java:7)
'METHOD_DEF' has more than 1 empty lines after. [EmptyLineSeparator]
----------------------------------------]
2021-07-20 15:24:42,417 [main] INFO com.wrike.codestyle.CheckStyleTest - 2021-07-20 15:24:42,417 [main] INFO com.wrike.codestyle.CheckStyleTest -
com.wrike.codestyle.CheckStyleTest$CodeStyleViolationException: Your code have [1] violation(s)! Please fix issue. Check logs for more information...
Как настроить кастомные правила
С помощью стандартных правил PMD и Checkstyle можно проверить не все кейсы. Напишем несколько кастомных правил для PMD и подключим их к уже существующим проверкам. Для написания кастомных правил мы советуем ознакомиться с документацией PMD по AST-классам и кастомным правилам.
Уникальность тестов. Для хранения истории прогонов тестов мы используем Allure Server. Чтобы однозначно связать историю теста с конкретным тест-кейсом, мы используем собственную аннотацию @TestCaseId
. Allure Server собирает историю теста, основываясь на его ID. В качестве уникального идентификатора можно было бы использовать имя теста, но оно может меняться, а ID — нет.
Для проверки уникальности теста и наличия у него ID мы используем два кастомных правила: CheckDuplicateTestIdRule
и TestMustHaveTestIdAnnotationRule
.
Правило TestMustHaveTestIdAnnotationRule проверяет наличие ID у каждого теста. Если у метода есть аннотации @Test
или @ParameterizedTest
из JUnit5, то он должен также иметь аннотацию @TestCaseId
:
public class TestMustHaveTestIdAnnotationRule extends AbstractJavaRule {
@Override
public Object visit(final ASTMethodDeclaration node, final Object data) {
ASTAnnotation testAnnotation = node.getAnnotation(Test.class.getCanonicalName());
ASTAnnotation parameterizedTestAnnotation = node.getAnnotation(ParameterizedTest.class.getCanonicalName());
if (testAnnotation != null || parameterizedTestAnnotation != null) {
ASTAnnotation testCaseIdAnnotation = node.getAnnotation(TestCaseId.class.getCanonicalName());
if (testCaseIdAnnotation == null) {
addViolation(data, node);
}
}
return super.visit(node, data);
}
}
Правило CheckDuplicateTestIdRule
проверяет, что ID во всем проекте не дублируются. Каждый метод проверяется отдельно, чтобы в отчете о проверке можно было вывести, в каком методе ошибка. Метод findAllIds
ищет все ID один раз для всего правила.
public class CheckDuplicateTestIdRule extends AbstractJavaRule {
@Override
public Object visit(final ASTMethodDeclaration node, final Object data) {
ASTAnnotation testCaseIdAnnotation = node.getAnnotation(TestCaseId.class.getCanonicalName());
if (testCaseIdAnnotation != null) {
Map<Integer, Integer> allIds = DuplicateTestCaseIdUtils.findAllIds();
ASTSingleMemberAnnotation memberAnnotation = (ASTSingleMemberAnnotation) testCaseIdAnnotation.getChild(0);
memberAnnotation.findDescendantsOfType(ASTLiteral.class).stream()
.map(ASTLiteral::getValueAsInt)
.filter(it -> allIds.get(it) > 1)
.forEach(it -> addViolation(data, node, it.toString()));
}
return super.visit(node, data);
}
}
Еще одно правило для тестов с ID — проверка количества ID для параметризованных тестов ParametrizedTestIdsCountRule
. Количество ID должно быть строго равно количеству передаваемых наборов параметров, чтобы тесты не «терялись» и попадали в отчеты Allure. Для работы правила необходимо, чтобы метод provider находился в том же классе, что и тесты — это ограничение PMD, которое не получилось обойти.
Также мы решили отказаться от аннотации @EnumSource
, потому что Enum может также находиться в другом файле. Вместо @EnumSource
в тестовом проекте мы используем @MethodSource
. Метод, который предоставляет параметры для теста, должен возвращать заранее вычисленный список, чтобы можно было посчитать количество необходимых ID. В случаях, когда невозможно выполнить все ограничения правила по объективным причинам, его можно подавить с помощью аннотации @SuppressWarnings
. Код правила ParametrizedTestIdsCountRule
можно посмотреть в готовом проекте.
Все правила написаны, теперь подключим их к нашему проекту. Для этого создадим новый файл конфигурации pmd-testcaseid-ruleset.xml
и заполним его:
pmd-testcaseid-ruleset.xml
<?xml version="1.0"?>
<ruleset name="TestCaseId rule set"
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 http://pmd.sourceforge.net/ruleset_2_0_0.xsd">
<description>
Rules for check tests ids
</description>
<rule name="TestMustHaveTestIdAnnotationRule"
language="java"
class="com.wrike.checkstyle.wrikerules.TestMustHaveTestIdAnnotationRule"
message="You have to use @TestCaseId with @Test annotation.">
<description>
You have to use @TestCaseId with @Test annotation.
</description>
<priority>1</priority>
</rule>
<rule name="CheckDuplicateTestIdRule"
language="java"
class="com.wrike.checkstyle.wrikerules.CheckDuplicateTestIdRule"
message="There is a duplicate @TestCaseId {0}">
<description>
There is a duplicate @TestCaseId.
</description>
<priority>1</priority>
</rule>
<rule name="ParametrizedTestIdsCountRule"
language="java"
class="com.wrike.checkstyle.wrikerules.ParametrizedTestIdsCountRule"
message="Unexpected test case ids count for parametrized test. Expected {0} ids, but found {1} ids.">
<description>
Check that parameterized tests have the right count of test case ids.
</description>
<priority>1</priority>
</rule>
</ruleset>
Разметка тестов. Иногда хочется объединить группу тестов по какому-то признаку. Для этого в JUnit5 есть аннотация Tag. В тестовом проекте Wrike мы размечаем все скриншотные тесты отдельным тегом, чтобы запускать их отдельно от других тестов. Тест считается скриншотным, если он использует объект класса ScreenshotSteps
.
Чтобы определить, что тест использует объект определенного класса, необходимо построить дерево вызовов методов и рекурсивно по нему пройтись. Если хотя бы один метод в иерархии использует объект класса ScreenshotSteps
, то тест нужно разметить тегом.
Сокращенный код правила выглядит так:
Код правила ScreenshotTestMustHaveScreenshotTag
public class ScreenshotTestMustHaveScreenshotTag extends AbstractJavaRule {
private static final String SCREENSHOT_TAG = "SCREENSHOT_TEST";
private Map<ASTMethodDeclaration, Set<ASTMethodDeclaration>> screenshotMethodUsages;
private Map<ASTMethodDeclaration, Set<ASTMethodDeclaration>> methodUsages;
private final Set<ASTMethodDeclaration> visitedMethods = new HashSet<>();
@Override
public Object visit(final ASTClassOrInterfaceDeclaration node, final Object data) {
if (nodeHasScreenshotTag(node)) {
return super.visit(node, data);
}
Optional<Entry<VariableNameDeclaration, List<NameOccurrence>>> screenshotStepsDeclaration = getScreenshotStepsDeclaration(node);
if (screenshotStepsDeclaration.isEmpty()) {
return super.visit(node, data);
}
Set<ASTMethodDeclaration> screenshotMethods = getScreenshotMethods(screenshotStepsDeclaration);
methodUsages = getMethodUsages(node);
screenshotMethodUsages = methodUsages.entrySet().stream()
.filter(entry -> screenshotMethods.contains(entry.getKey()))
.collect(Collectors.toMap(Entry::getKey, Entry::getValue));
Set<ASTMethodDeclaration> allScreenshotMethods = dfsTree();
allScreenshotMethods.forEach(method -> {
Set<? extends Class<?>> annotationSet = getAnnotations(method);
if ((annotationSet.contains(ParameterizedTest.class) || annotationSet.contains(Test.class))
&& !nodeHasScreenshotTag(method)) {
addViolation(data, method);
}
});
return super.visit(node, data);
}
}
Также для разметки тестов мы используем Epic/Feature/Story — популярный способ разметки тестов, который поддерживает Allure. В некоторых модулях проекта мы придерживаемся следующей концепции: тест должен иметь один @Epic
и не более одной @Feature
и @Story
.
Для решения этой проблемы мы реализовали универсальное абстрактное правило, которое проверяет аннотации класса и методов:
Код правила TestShouldHaveOneEntity
public abstract class TestShouldHaveOneEntity extends AbstractJavaRule {
private static final String TEST_METHOD_JUNIT4_OR_JUNIT5 = "Test";
private final String entityAnnotation;
private final String multipleEntityAnnotation;
private final boolean mustHaveAnnotation;
public TestShouldHaveOneEntity(String annotationEntity, String multipleEntityAnnotation, boolean mustHaveAnnotation) {
super();
this.entityAnnotation = annotationEntity;
this.multipleEntityAnnotation = multipleEntityAnnotation;
this.mustHaveAnnotation = mustHaveAnnotation;
}
@Override
public Object visit(final ASTMethodDeclaration node, final Object data) {
Set<String> annotationNameSet = getMethodAnnotations(node);
if (annotationNameSet.contains(TEST_METHOD_JUNIT4_OR_JUNIT5)) {
Set<String> classAnnotationNameSet = getClassAnnotations(node);
if (annotationNameSet.stream().anyMatch(annotation -> annotation.equals(multipleEntityAnnotation))
|| classAnnotationNameSet.stream().anyMatch(annotation -> annotation.equals(multipleEntityAnnotation))) {
addViolation(data, node);
return super.visit(node, data);
}
boolean methodHasEntityAnnotation = annotationNameSet.stream().anyMatch(annotation -> annotation.equals(entityAnnotation));
boolean classHasEntityAnnotation = classAnnotationNameSet.stream().anyMatch(annotation -> annotation.equals(entityAnnotation));
if (mustHaveAnnotation && methodHasEntityAnnotation == classHasEntityAnnotation) {
addViolation(data, node);
return super.visit(node, data);
}
if (!mustHaveAnnotation && methodHasEntityAnnotation && classHasEntityAnnotation) {
addViolation(data, node);
}
}
return super.visit(node, data);
}
}
Теперь правила для @Epic
, @Feature
и @Story
выглядят достаточно просто:
public class TestShouldHaveOnlyOneEpic extends TestShouldHaveOneEntity {
public TestShouldHaveOnlyOneEpic() {
super(Epic.class.getSimpleName(), Epics.class.getSimpleName(), true);
}
}
public class TestShouldHaveNoMoreThanOneStory extends TestShouldHaveOneEntity {
public TestShouldHaveNoMoreThanOneStory() {
super(Story.class.getSimpleName(), Stories.class.getSimpleName(), false);
}
}
public class TestShouldHaveNoMoreThanOneFeature extends TestShouldHaveOneEntity {
public TestShouldHaveNoMoreThanOneFeature() {
super(Feature.class.getSimpleName(), Features.class.getSimpleName(), false);
}
}
Другие кастомные правила. Еще одна проблема, с которой приходится сталкиваться — использование аннотации @RepeatedTest
вместо @Test
. Обычно такая аннотация используется для того, чтобы локально проверять тесты на стабильность. На ревью очень легко упустить этот момент, и тест будет запускаться несколько раз вместо положенного одного. Чтобы решить эту проблему, импорт @RepeatedTest
запрещается специальным правилом. Если разработчик запушил ветку с этой аннотацией, ошибка будет выловлена на этапе пайплайна в CI.
public class ShouldNotImportRepeatedTest extends AbstractJavaRule {
private static final String REPEATED_TEST = RepeatedTest.class.getCanonicalName();
@Override
public Object visit(final ASTImportDeclaration node, final Object data) {
String nodeImportedName = node.getImportedName();
if (nodeImportedName.contains(REPEATED_TEST)) {
addViolation(data, node);
return super.visit(node, data);
}
return super.visit(node, data);
}
}
В тестовом проекте Wrike мы используем 62 проверки Checkstyle и 271 правило PMD, из которых 236 стандартных и 35 кастомных. Проверка всего проекта, состоящего из 38к тестов и 13к Java файлов, занимает меньше 10 минут. Однако практически всегда diff между веткой и мастером составляет менее 100 файлов, и проверка занимает 2-3 минуты.
Благодаря внедрению Checkstyle и PMD нам удается поддерживать чистоту в проекте и избегать логических ошибок.
Если вы хотите попробовать использовать связку PMD и Checkstyle, ищите проект с настроенными правилами по этой ссылке.
А какие инструменты для поддержания порядка в кодовой базе используете вы?