Photo by Dominik Van Opdenbosch on Unsplash
Photo by Dominik Van Opdenbosch on Unsplash

Всем привет!

В команде Мир Plat.Form я занимаюсь системами сертификации эмиссии – наш отдел разрабатывает программные продукты для внутренних и внешних пользователей, автоматизирующие сертификацию всего, что в конечном итоге превращается в «кредитку» в вашем кармане, начиная с карточных модулей и заканчивая дизайном карты.

До недавнего времени моей основной задачей была автоматизация тестирования для нашей команды, и вот в один прекрасный день, около года тому назад, нам «внезапно» понадобилось протестировать производительность некоторых наших веб-приложений. Задача достаточно тривиальная, поэтому, без долгих раздумий, для её решения был выбран Apache JMeter – пусть он и не идеален, но если нужно что-то нагрузить без затяжной подготовки – самое то.

Ничего не предвещало беды, были подготовлены стенды, подключен мониторинг, написаны сценарии и выполнено само тестирование, написан и разослан всем заинтересованным лицам отчёт о результатах. Мы даже нашли и поправили пару узких мест, что позволило значительно снизить нагрузку на БД и повысить производительность системы. Думаю, читатель уже догадался, что, если бы всё было так просто – этой статьи бы не было, но тут вдруг появилась потребность развивать продукт, регулярно выкатывая новые фичи, и мы поняли, что разовым тестированием мы уже не обойдёмся. Пришлось придумывать, как вписать регулярное проведение тестирования производительности в наши процессы CI/CD, используя имеющиеся инструменты.

В нашей команде для автоматизации тестирования используется Java и связка TestNG / Cucumber / Allure, для задач CI/CD мы пользуемся Jenkins, так что и наше «непрерывное нагрузочное тестирование» было решено делать, не отступая от стандартного стека – таким образом мы не только не плодим зоопарк технологий, но и упрощаем подключение новых автоматизаторов.

Основная идея и подготовка

Раз мы здесь занимаемся в основном функциональным тестированием – почему бы не превратить нагрузочные тесты в функциональные? Пусть сценарии пишутся на Gherkin, запускаются через TestNG + Cucumber внутри Jenkins, и туда же выкладываются сформированные Allure отчёты!

Сам Apache JMeter позволяет запускать сценарии и обрабатывать результаты их выполнения из Java-кода, единственное, что потребуется дополнительно – это дистрибутив Jmeter или запущенный сервер-агент JMeter (мы использовали оба подхода, но в данной статье речь пойдёт только об использовании дистрибутива, перейти на использование агента при необходимости будет очень просто).

Для агрегации результатов мы воспользовались библиотекой Math3 от того же Apache, она позволяет собирать нужные нам значения и затем выдавать рассчитанные показатели, такие как среднее значение, медиана, перцентили и так далее.

В итоге получился примерно такой набор зависимостей:

dependencies {
    annotationProcessor 'org.projectlombok:lombok:1.18.24'
    compileOnly 'org.projectlombok:lombok:1.18.24'
    implementation 'io.cucumber:cucumber-java:7.8.1'
    implementation 'io.cucumber:cucumber-testng:7.8.1'
    implementation 'io.qameta.allure:allure-cucumber7-jvm:2.20.0'
    implementation 'org.aeonbits.owner:owner:1.0.12'
    implementation 'org.apache.commons:commons-math3:3.6.1'
    implementation 'org.apache.jmeter:ApacheJMeter_components:5.5' exclude group: 'org.apache.jmeter', module: 'bom'
    implementation 'org.apache.jmeter:ApacheJMeter_config:5.5' exclude group: 'org.apache.jmeter', module: 'bom'
    implementation 'org.apache.jmeter:ApacheJMeter_core:5.5' exclude group: 'org.apache.jmeter', module: 'bom'
    implementation 'org.apache.jmeter:ApacheJMeter_http:5.5' exclude group: 'org.apache.jmeter', module: 'bom'
    testImplementation 'org.testng:testng:7.6.1'
}

Из-за давней «особенности» библиотек JMeter приходится в Gradle исключать модуль bom, иначе проект не будет собираться.

Так как тесты будут запускаться с использованием TestNG и Cucumber, а отчёты будут формироваться в Allure – добавляем плагин и настраиваем таски:

plugins {
    id 'java'
    id "io.qameta.allure" version "2.11.1"
}

//...

test {
    systemProperties System.getProperties()
    include("**/JMeterTestRunner*")
    scanForTestClasses = false
    ignoreFailures = true
    useTestNG()
}

allure {
    autoconfigure = false
    aspectjweaver = true
}

Архив с дистрибутивом JMeter и файл jmeter.properties (он понадобится для инициализации) размещаем в main/resources.

Стандартный runner для Cucumber-тестов, ничего нового:

import io.cucumber.testng.AbstractTestNGCucumberTests;
import io.cucumber.testng.CucumberOptions;

@CucumberOptions(features = {"src/test/resources/features"})
public class JMeterTestRunner extends AbstractTestNGCucumberTests {
}

Запуск сценариев JMeter из Java-кода

Для удобства подготовки движка JMeter, загрузки и выполнения сценариев, создаём класс JMeterTestExecutor. В нём будем хранить нужные для работы пути и, собственно, сам движок:

private final File temporaryDirectory = Files.createTempDirectory("common-load-testing-tool").toFile();
private final File jmeterHomeDirectory;
private final File jmeterPropertiesFile;
private final StandardJMeterEngine jmeterEngine;

Конструктор принимает параметры с версией JMeter (заложено, скорее, «на вырост», на текущий момент у нас нет причины использовать несколько различных версий) и именем properties-файла, распаковывает лежащий в ресурсах дистрибутив во временную директорию, кладёт туда же properties и инициализирует движок:

public JMeterTestExecutor(String jmeterVersion, String jmeterPropertiesFileName) throws URISyntaxException, IOException {
    jmeterHomeDirectory = unzipJMeterDistribution(jmeterVersion);
    jmeterPropertiesFile = copyFileFromResources(String.format("properties/%s", jmeterPropertiesFileName));
    jmeterEngine = new StandardJMeterEngine();
    JMeterUtils.setJMeterHome(jmeterHomeDirectory.getPath());
    JMeterUtils.loadJMeterProperties(jmeterPropertiesFile.getPath());
    JMeterUtils.initLocale();
    SaveService.loadProperties();
}

Код распаковки архива и копирования файлов во временную директорию особого интереса не представляет, также, как и загрузка сценария, а вот непосредственное выполнение сценария уже содержит некоторые интересные моменты:

public JMeterTestStatistics executeJMeterScenario(HashTree testPlanTree) {
    var jmeterCustomListener = new JMeterCustomListener();
    testPlanTree.add(testPlanTree.getArray()[0], jmeterCustomListener);
    jmeterEngine.configure(testPlanTree);
    jmeterEngine.run();
    return jmeterCustomListener.getTestStatistics();
}

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

Получение и обработка результата

Для того, чтобы в процессе выполнения сценария JMeter сохранять результаты, мы реализовали простой listener, главная функция которого – передача результата в класс сбора статистики.

public class JMeterCustomListener extends AbstractListenerElement implements SampleListener, Clearable, Serializable, Remoteable, NoThreadClone {

    @Getter
    private JMeterTestStatistics testStatistics = new JMeterTestStatistics();

    @Override
    public synchronized void sampleOccurred(SampleEvent event) {
        var sampleResult = event.getResult();
        testStatistics.preserveSampleResult(sampleResult);
    }

    @Override
    public void sampleStarted(SampleEvent event) {
        // Nothing to do
    }

    @Override
    public void sampleStopped(SampleEvent event) {
        // Nothing to do
    }

    @Override
    public void clearData() {
        // Nothing to do
    }
}

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

@Getter
private JMeterStatisticsAggregator statisticsTotal = new JMeterStatisticsAggregator();
@Getter
private JMeterStatisticsAggregator statisticsSuccessful = new JMeterStatisticsAggregator();
private Map<String, JMeterStatisticsAggregator> statisticsByLabels = new HashMap<>();
private Map<String, JMeterStatisticsAggregator> statisticsByResponseCodes = new HashMap<>();

public void preserveSampleResult(SampleResult sampleResult) {
    var sampleTime = sampleResult.getTime();
    var sampleLabel = sampleResult.getSampleLabel();
    var responseCode = sampleResult.getResponseCode();
    statisticsTotal.addValue(sampleTime);
    if (sampleResult.isSuccessful()) {
        statisticsSuccessful.addValue(sampleTime);
    }
    statisticsByLabels.putIfAbsent(sampleLabel, new JMeterStatisticsAggregator());
    statisticsByResponseCodes.putIfAbsent(responseCode, new JMeterStatisticsAggregator());
    statisticsByLabels.get(sampleLabel).addValue(sampleTime);
    statisticsByResponseCodes.get(responseCode).addValue(sampleTime);
}

Класс JMeterStatisticsAggregator служит, по сути, обёрткой над хранилищем собранных значений, в качестве которого используется класс DescriptiveStatistics из пакета Apache Math3. Здесь же реализованы методы для получения рассчитанной статистики:

import org.apache.commons.math3.stat.descriptive.DescriptiveStatistics;

public class JMeterStatisticsAggregator {

    private final DescriptiveStatistics statistics = new DescriptiveStatistics();

    public void addValue(Long value) {
        statistics.addValue(value);
    }

    public Double getCount() {
        return Double.valueOf(statistics.getN());
    }

    public Double getAverage() {
        return statistics.getMean();
    }

    public Double getMedian() {
        return statistics.getPercentile(50);
    }

    public Double getPercentile(Double percentile) {
        return statistics.getPercentile(percentile);
    }

    public Double getMin() {
        return statistics.getMin();
    }

    public Double getMax() {
        return statistics.getMax();
    }

}

Прячем нагрузочный тест в функциональном

Пожалуй, самым сложным в итоге оказалось обернуть выполнение тестов в привычный нашей команде Gherkin. Сама по себе концепция нагрузочного тестирования довольно слабо пересекается с тестированием функциональным, здесь нет как таковых шагов – по сути, мы только проверяем результаты и сравниваем их с эталонными (поэтому практически сразу было решено саму загрузку и прогон нагрузочного теста вынести в @BeforeAll, а шаги делать только для проверок, чтобы результаты красиво легли в Allure). В итоге feature-файлы превратились в наборы таблиц с перечислением параметров и их эталонных значений, примерно такие:

Структура сценария: Общие результаты тестирования производительности
  * <параметр> <условие> <значение>
  Примеры:
  | параметр               | условие          | значение |
  | количество ошибок      | меньше           | 10       |
  | процент ошибок         | меньше или равно | 10       |
  | среднее время ответа   | меньше           | 200      |
  | медиана времени ответа | меньше           | 200      |
  | перцентиль 90%         | меньше           | 200      |
  | перцентиль 95%         | меньше           | 200      |
  | перцентиль 99%         | меньше           | 200      |

Так как различных параметров достаточно много и, кроме того, их значения требуется сравнивать с эталонными в зависимости от условий, реализация шагов была сделана максимально обобщённой:

@Тогда("{testStatisticsDimension} {comparisonOperation} {double}")
public void checkErrorsPercent(JMeterTestStatisticsDimension testStatisticsDimension, ComparisonOperation comparisonOperation, Double value) {
    var actual = testStatisticsDimension.evaluate(jmeterTestStatistics);
    comparisonOperation.compare(actual, value);
}

@ParameterType("количество ошибок|процент ошибок|среднее время ответа|медиана времени ответа|перцентиль 90%|перцентиль 95%|перцентиль 99%")
public JMeterTestStatisticsDimension testStatisticsDimension(String demensionName) throws IllegalUserActionException {
    return JMeterTestStatisticsDimension.getByName(demensionName);
}

@ParameterType("меньше|меньше или равно|равно|больше|больше или равно")
public ComparisonOperation comparisonOperation(String comparisonOperation) throws IllegalUserActionException {
    return ComparisonOperation.getByName(comparisonOperation);
}

Основная логика получения значений параметров и их сравнения была спрятана в хитрые enum’ы, внутри которых используются лямбды, берущие на себя всю грязную работу.

Для выполнения сравнения значений достаточно вызвать функцию compare() соответствующего enum’а, реализация же самого сравнения отличается в зависимости от операции:

@FunctionalInterface
public interface ComparisonFunction<ACTUAL, EXPECTED, RESULT> {
    public RESULT apply(ACTUAL actual, EXPECTED expected);
}

public enum ComparisonOperation {

    LESS("меньше", (a, e) -> { return ((Double)a < (Double)e);}),
    LESS_OF_EQUALS("меньше или равно", (a, e) -> { return ((Double)a <= (Double)e);}),
    EQUALS("равно", (a, e) -> { return ((Double)a == (Double)e);}),
    MORE("больше", (a, e) -> { return ((Double)a > (Double)e);}),
    MORE_OR_EQUALS("больше или равно", (a, e) -> { return ((Double)a >= (Double)e);});

    public static ComparisonOperation getByName(String name) throws IllegalUserActionException {
        for (var operation: values()) {
            if (operation.name.equals(name)) {
                return operation;
            }
        }
        throw new IllegalUserActionException(String.format("Операции сравнения с именем \"%s\" не найдена", name));
    }

    public void compare(Double actual, Double expected) {
        var isCorrect = function.apply(actual, expected);
        if (!isCorrect) {
            Assert.fail(String.format("Значение %f должно быть %s %f", actual, name, expected));
        }
    }

    private String name;
    private ComparisonFunction<Double, Double, Boolean> function;

    private ComparisonOperation(String operationName, ComparisonFunction comparisonFunction) {
        name = operationName;
        function = comparisonFunction;
    }

}

Аналогичным образом реализовано и получение значений параметров из статистики выполнения теста – достаточно вызвать функцию evaluate() и передать в неё объект с агрегированной статистикой, за остальное отвечают лямбды:

@FunctionalInterface
public interface JMeterTestStatisticsParametrizedEvaluation<TEST_STATISTICS, PARAMETER, RESULT> {
    public RESULT apply(TEST_STATISTICS testStatistics, PARAMETER parameter);
}

public enum JMeterTestStatisticsDimension {

    ERRORS_COUNT("количество ошибок", s -> {
        var statistics = (JMeterTestStatistics)s;
        return statistics.getStatisticsTotal().getCount() - statistics.getStatisticsSuccessful().getCount();
    }),
    ERRORS_PERCENT("процент ошибок", s -> {
        var statistics = (JMeterTestStatistics)s;
        return (statistics.getStatisticsTotal().getCount() - statistics.getStatisticsSuccessful().getCount()) / statistics.getStatisticsTotal().getCount() * 100;
    }),
    AVERAGE("среднее время ответа", s -> {
        var statistics = (JMeterTestStatistics)s;
        return statistics.getStatisticsTotal().getAverage();
    }),
    MEDIAN("медиана времени ответа", s -> {
        var statistics = (JMeterTestStatistics)s;
        return statistics.getStatisticsTotal().getMedian();
    }),
    PERCENTILE90("перцентиль 90%", s -> {
        var statistics = (JMeterTestStatistics)s;
        return statistics.getStatisticsTotal().getPercentile(90.0);
    }),
    PERCENTILE95("перцентиль 95%", s -> {
        var statistics = (JMeterTestStatistics)s;
        return statistics.getStatisticsTotal().getPercentile(95.0);
    }),
    PERCENTILE99("перцентиль 99%", s -> {
        var statistics = (JMeterTestStatistics)s;
        return statistics.getStatisticsTotal().getPercentile(99.0);
    });

    public static JMeterTestStatisticsDimension getByName(String name) throws IllegalUserActionException {
        for (var dimension: values()) {
            if (dimension.name.equals(name)) {
                return dimension;
            }
        }
        throw new IllegalUserActionException(String.format("Имя измерения результатов теста \"%s\" не найдено", name));
    }

    public Double evaluate(JMeterTestStatistics statistics) {
        return evaluationTotalStatistics.apply(statistics);
    }

    private String name;
    private Function<JMeterTestStatistics, Double> evaluationTotalStatistics;

    private JMeterTestStatisticsDimension(String dimensionName, Function evaluationFormula) {
        name = dimensionName;
        evaluationTotalStatistics = evaluationFormula;
   }

}

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

Что дальше?

В итоге мы получили небольшой фреймворк, который позволяет загрузить в него практически любой сценарий нагрузочного тестирования, выполнить тесты и проанализировать результаты. К сожалению, используемые средства не позволяют посмотреть, например, статистику по изменению времени ответа на запрос – мы проверяем только то, соответствует ли время ответа ожидаемому. Однако при использовании Jenkins и Allure мы видим график успешных и неуспешных тестов, и можем вовремя заметить, что в новой сборке приложения какие-то из запросов стали работать медленнее допустимого или выдавать ошибки.

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

Сейчас мы рассматриваем два варианта реализации этого функционала – сохранение данных в InfluxDB и использование Grafana для построения графиков, или реализация собственного решения, некоего портала с результатами нагрузочных тестов. Какой бы вариант решения мы ни выбрали, внедрение дополнительных возможностей в имеющийся фреймворк будет заключаться только в добавлении новой точки агрегации результатов в класс JMeterTestStatistics, что не составит особого труда.

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

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


  1. login40k
    14.01.2023 00:33
    +1

    Молодцы! Я бы только порекомендовал не поддерживать самописные велосипеды, а юзать хороший опенсорс https://github.com/abstracta/jmeter-java-dsl

    Хороших стрельб!


    1. spopovru Автор
      14.01.2023 00:38

      Спасибо!

      Смотрели на эту библиотеку, но на текущий момент решили попробовать свой «велосипед», банально переиспользуя имеющиеся jmx’ы. Но направление мысли, которое диктует JMeter DSL, тоже весьма интересное, мне лично напоминает вариант с использованием Gatling для написания нагрузочный тестов. Думаю, стоит попробовать сделать пилот и сравнить, насколько будет удобно.

      В данном случае мы ещё прибивались к используемому в функциональных тестах стеку, поэтому получилось такое своеобразное решение - с Gherkin и велосипедами ????