AspectJ в автоматическом тестировании — несколько практических примеров

Мне понравился механизм аспектно-ориентированного программирования (АОП), который используется в Allure Framework для перехвата выполнения тестовых шагов, отмеченных аннотацией @Step. И я попробовал применить его в автотестировании, не подключая к тестам таких монстров, как Spring или Guice.

В этой статье вы найдете несколько полезных примеров использования аспектов.

В двух словах аспектно-ориентированное программирование — это концепция, разделения функциональности программы для упрощения ее разложения на модули. Здесь я не буду останавливаться на теории, чтобы быстрее перейти к практическим примерам. Неплохая статья для знакомства с подходом есть на Хабре. А в Википедии есть словарь основных терминов АОП.

С точки зрения автотестирования это механизм, который позволяет в существующий большой проект дописать файлик Aspect с так называемыми Advice, которые будут выполняться в нужных местах и добавлять некоторые полезные инструменты в весь проект.

В общем случае выглядит это так

Все тестовые шаги мы помечаем аннотацией @Step — это способ отделить их от остальных методов. Если в ходе работы приложения выполняется метод, который соответствует какому-либо указателю @Pointcut из наших аспектов, то в зависимости от вида Advice-а будет:

  • выполнен код Advice, а затем уже метод (@Before);
  • выполнен метод, а затем уже Advice (@After, @AfterReturning);
  • выполнен код Advice, внутри которого можно запустить исходный метод (один или несколько раз) или же не делать ничего (@Around).

Например, если при выполнении метода вываливается исключение, его можно перехватить и “проглотить” с помощью аспекта, записав при этом в лог. Со стороны же выполнение будет выглядеть так, будто программа выполнилась без ошибок.

Advice имеет доступ к телу метода — мы можем получить параметры, с которыми его вызывают и использовать эти данные для выполнения каких-то своих действий — той же записи в log.

Allure Framework, который мы (и многие другие) используем для формирования отчетов автотестов, построен как раз на механизмах AOP. Allure-адаптер делает ровно то же самое — отлавливает аннотации @Step в последовательности выполняющихся методов. А описание того, что необходимо сделать при обнаружении аннотации, хранится в аспекте. Среди прочего там есть добавление информации о шаге в отчет. Если шаг выполняется успешно, он помечается в отчете зеленым, если нет — красным.

Честно говоря, я и набрел на аспектно-ориентированное программирование, пока ковырялся в Allure, пытаясь реализовать там необходимое мне поведение. Наверное, для моих целей можно было бы использовать тот же Lombok, но библиотека AspectJ уже есть в комплекте с Allure и легко внедряется в наши проекты. Плюс на примере Allure было отлично видно, как с ее помощью получить то, что мне нужно. А усложнение и увеличение числа используемых инструментов не входило в планы.

Полезные примеры применения аспектов в автотестах

Для сборки я использую Gradle, в Maven все будет немного иначе.

Прежде чем переходить к конкретным шагам, необходимо подключить AspectJ с помощью зависимостей:

compile 'org.aspectj:aspectjrt:1.9.2'
compile ‘org.aspectj:aspectjweaver:1.9.2’

Я использую плагин io.qameta.allure:allure-gradle:2.8.1, в котором уже есть библиотека “aspectjweaver”, т.е. для Allure достаточно указать версию:

allure {
    version = '2.8.1'
    autoconfigure = true
    allureJavaVersion = '2.13.7'
    aspectjVersion = '1.9.2'
    configuration = 'compile'
}

Исходники примеров, изложенных ниже, вы можете найти в моем GitHub.

Сквозное логирование

Аспекты позволяют добавить сквозное логирование ко всему проекту. Для этого достаточно создать всего лишь один класс, который будет записывать в log-файл данные о выполнении метода, обернутого в аспект.

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

@Test
    public void allureLogTest () {
        GoogleSteps.openSearchPage();
        GoogleSteps.searchFor("java");
        String resultStats = GoogleSteps.getResultStats();
        MatcherAssert.assertThat(
                "Неправильная сводка по результатам поиска",
                resultStats,
                Matchers.equalTo("-")
        );
    }

В этом примере я специально задал кривой текст для сравнения, чтобы тест упал.

Если мы не используем другие инструменты (например, встроенный механизм Selenide, который позволяет добавлять своих слушателей на разные события), то тест будет работать молча, не сообщая о текущих выполняемых шагах в консоль.

Под капотом у нас есть аннотации @Step, которые мы используем для шагов, попадающих в отчет Allure. Но по умолчанию эти шаги в консоль не выводятся.

public class GoogleSteps {
    @Step("Получаем сводку по результатам поиска")
    @Attachment
    public static String getResultStats() {
        return Selenide.$("#result-stats").text();
    }

    @Step("Выполняем поиск по запросу: {0}")
    public static void searchFor(String request) {
        Selenide.$("[name='q']").setValue(request);
        Selenide.$("[name='btnK']").click();
    }

    @Step("Открываем страницу поиска")
    public static void openSearchPage() {
        Selenide.open("https://www.google.ru/");
    }
}

Вывести их в консоль с минимальными вмешательствами можно с помощью аспектов. Достаточно создать аспект — фактически, обычный класс java со специальной аннотацией — который будет выделять название шагов @Step.

@Aspect
public class AllureLogAspect {
    private static final Logger log = LoggerFactory.getLogger(AllureLogAspect.class);
…
}

Внутри мы должны указать pointcut, где будет срабатывать аспект. Допустим, нам нужно, чтобы он срабатывал перед запуском любого метода, в котором проставлена аннотация io.qameta.allure.Step:

    @Before("@annotation(io.qameta.allure.Step) && execution(* *(..))")
    public void beforeStep(JoinPoint joinPoint) {
        String stepName = getStepName(joinPoint);
        log.info("BEGIN: " + stepName);
    }

@Before означает, что аспект должен сработать перед запуском метода. В этот метод передается точка присоединения (JoinPoint), содержащая указатель на запускаемый метод. Здесь же мы можем получить параметры запуска:

    private String getStepName(JoinPoint joinPoint) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Map<String, Object> parametersMap = getParametersMap(joinPoint);
        Method method = methodSignature.getMethod();
        Step step = method.getAnnotation(Step.class);
        String stepName = step.value();

        return Optional.of(stepName)
                .filter(StringUtils::isNoneEmpty)
                .map(it -> processNameTemplate(it, parametersMap))
                .orElse(methodSignature.getName());
    }

Отмечу, что вместо

        return Optional.of(stepName)
                .filter(StringUtils::isNoneEmpty)
                .map(it -> processNameTemplate(it, parametersMap))
                .orElse(methodSignature.getName());

можно было написать просто

return stepName;

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

Что мы получаем в результате?

При запуске теста еще до открытия страницы выполняется метод beforeStep, из него мы выделяем адрес страницы и пишем в лог. Дальше управление возвращается исходному методу.

Чтобы лог был полным после его выполнения мы подставим еще один аспект с @AfterReturning:

    @AfterReturning(
            pointcut = "@annotation(io.qameta.allure.Step) && execution(* *(..))",
            returning = "result"
    )
    public void afterStep(JoinPoint joinPoint, Object result) {
        String stepName = getStepName(joinPoint);
        log.info("END: " + stepName);
        if(result != null) {
            log.info("RESULT: " + result);
        }
    }

Здесь помимо аналогичной записи в лог мы также проверяем, есть ли какой-то результат вызова метода. Если результат не пустой, он также выводится в консоль.

С описанием аспекта мы закончили. Осталось создать в папке /resources/META-INF мета-файл aop-ajc.xml, в котором следует указать пульт до нового класса:

<aspectj>
    <aspects>
        <aspect name="org.example.aop.aspects.AllureLogAspect"/>
    </aspects>
</aspectj>

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

У меня на проектах задача логирования в консоль решается через встроенный механизм Selenide, который позволяет добавлять listener-ы на различные события. А аспекты используются для других задач. Например, для логирования HTTP-запросов из созданных через OpenAPI-генератор Swagger-клиентов. Также с помощью AOP я добавляю текущее время и дату к названиям тестовых шагов в отчетах Allure. Так удобнее утром разбирать ночные прогоны. Еще один вариант использования — логирование SQL-запросов в БД.

Механизм повторов

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

Предположим, некий тестовый шаг со специальной аннотацией не всегда проходит с первого раза. Мы хотели бы, чтобы при неудаче у нас было еще 2-3 попытки. И если хотя бы одна из них выполнилась корректно, шаг считался бы зеленым.

В качестве примера я сделал шаг checkRandom(), где мы генерируем случайное число от 0 до 9 и проверяем, что оно равно desiredValue:

public class CommonSteps {
    @Step("Проверяем, что случайное число = {0}")
    @WithRetries(20)
    public static void checkRandom(int desiredValue) {
        Random random = new Random();
        int i = random.nextInt(10);
        MatcherAssert.assertThat(
                "Неправильный результат",
                i,
                Matchers.equalTo(desiredValue)
        );
    }
}

Без аспекта тест ожидаемо падает (с соответствующей вероятностью).

Чтобы обеспечить повторы, я создал новую аннотацию @WithRetries. По умолчанию методы, отмеченные этой аннотацией, повторяются 3 раза:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface WithRetries {
    int value() default 3;
}

Также я создал класс:

@Aspect
public class WithRetriesAspect {

   private static final ThreadLocal<Boolean> processingWrapper = ThreadLocal.withInitial(() -> false);
    private static final Logger log = LoggerFactory.getLogger(WithRetriesAspect.class);

    public static Boolean isProcessing() {
        return processingWrapper.get();
    }
...
}

В отличие от предыдущего примера, здесь я использую pointcut @Around. Это позволяет полностью контролировать выполнение методов, отмеченных аннотацией @WithRetries.

    @Around("@annotation(org.example.aop.annotations.WithRetries) && execution(* *(..))")
    public Object handleRetries(final ProceedingJoinPoint joinPoint) throws Throwable {
        processingWrapper.set(true);
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        WithRetries annotation = method.getAnnotation(WithRetries.class);
        int retryCount = annotation.value();
        Throwable storedException = null;
        Object result = null;
        boolean processed = false;
        int i = 0;
        while (!processed && i < retryCount) {
            try {
                result = joinPoint.proceed();
                processed = true;
            } catch(Throwable throwable) {
                log.warn("Попытка №" +i+":\r\n"+throwable.toString());
                storedException = throwable;
            }
            i++;
        }

Разберем, что здесь происходит.

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

       processingWrapper.set(false);

        if(!processed) {
            assert storedException != null;
            throw storedException;
        }

        return result;

Подключается аспект точно также в файле /resources/META-INF/aop-ajc.xml


<aspectj>
    <aspects>
        <aspect name="org.example.aop.aspects.AllureLogAspect"/>
        <aspect name="org.example.aop.aspects.WithRetriesAspect"/>
    </aspects>
</aspectj>

Поглощение исключений

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

Подход аналогичный предыдущим двум примерам. Я создаю аспект:

@Aspect
public class MatcherAssertAspect {
    private static final Logger log = LoggerFactory.getLogger(MatcherAssertAspect.class);
… 
}

Но pointcut здесь немного сложнее.

Такой pointcut сработает на все методы, которые называются assertThat:

    @Pointcut("execution(* assertThat(..))")
    void inMethods() {
    }

Следующий pointcut — на все методы, которые расположены в классе  org.hamcrest.MatcherAssert:

    @Pointcut("execution(* org.hamcrest.MatcherAssert.*(..))")
    void inMatcherAssert() {
    }

А этот pointcut сработает на все публичные методы:

    @Pointcut("execution(public * *(..))")
    void anyPublic() {
    }

После объединения всех pointcut-ов у нас остаются все публичные методы с названием assertThat внутри класса org.hamcrest.MatcherAssert.

Внутри аспекта используем тот же Advice @Arround:

    @Around("anyPublic() && inMatcherAssert() && inMethods()")
    public Object handleAssert(final ProceedingJoinPoint joinPoint) throws Throwable {
        Object result = null;
        try {
            result = joinPoint.proceed();
        } catch (Throwable throwable) {
            if (WithRetriesAspect.isProcessing()) {
                throw throwable;
            } else {
                log.warn(throwable.toString());
            }
        }
        return result;
    }

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

Простейший тест для проверки выглядит следующим образом:

    @Test
    void errorsSkippingTest() {
        MatcherAssert.assertThat("test", true, Matchers.equalTo(false));
        MatcherAssert.assertThat("test2", 1, Matchers.equalTo(2));
        MatcherAssert.assertThat("test3", "olol", Matchers.equalTo("pishpish"));
    }

Перед запуском также подключаем класс в файле /resources/META-INF/aop-ajc.xml


<aspectj>
    <aspects>
        <aspect name="org.example.aop.aspects.AllureLogAspect"/>
        <aspect name="org.example.aop.aspects.WithRetriesAspect"/>
        <aspect name="org.example.aop.aspects.WithRetriesAspect"/>
    </aspects>
</aspectj>

Используя повторы и поглощение исключений можно обрабатывать самые неприятные тесты — Flaky-тесты. Ты запускаешь их несколько раз подряд с одними и теми же тестовыми данными в одном и том же окружении, а результат оказывается случайным (то зеленые, то красные). Они не несут в себе совершенно никакой полезной информационной нагрузки, но мешают получению данных о работоспособности тестируемого продукта.

У Allure для таких тестов предусмотрена специальная аннотация — @Flaky. Если упали только тесты с такой аннотацией, весь прогон считается зеленым, а сами тесты помечены специальным символом. Я написал аналогичный аспект для тестовых шагов. В отчете такие шаги помечаются красными, прикрепляется StackTrace ошибки, но сам тест остается зеленым.

Это полезно, когда в автотестах среди прочего проверяются какие-то дополнительные сценарии — например, что в файловой системе записались логи, что они ушли в Kibana, в БД создались все сущности и т.п. В определенные моменты логи файловой системы могут быть недоступны, а Kibana может не функционировать. Чтобы спокойно заниматься своими делами, пока коллеги все чинят, достаточно расставить аннотации и остальные автотесты смогут проверить главное. Это у меня реализовано через AOP.

Автор статьи: Юрий Кудрявцев.

Наши статьи по теме:

Все статьи

Связаться с нами

Мы свяжемся с вами в течение 24 часов.