В этой статье поговорим о Нагрузочном тестировании при помощи JMeter-Java-Dsl и реализуем наш первый нагрузочный тест для API с генерацией динамических значений.

Документация JMeter Guide и GitHub с примером кода из статьи.

Предыстория

Была идея выбрать инструмент для нагрузочного тестирования API, чтобы можно было разрабатывать скрипт на ЯП Java и не использовать при этом графический интерфейс, после изучения остановился на Gatling и JMeter и вот о последнем захотелось рассказать в статье, как всё было и что из этого получилось.

Целью для нагрузочного тестирования было выбрано приложение для мониторинга использования газа и воды, где есть только API (ссылка на приложение —  utilitiesMonitor). Простое приложение Spring-Boot для мониторинга измерения коммунальных услуг. Приложение имеет две конечные точки REST:

1. Сохранение новых измерений для конкретного пользователя:

POST /measurements

    {
        "userId":1,
        "gas":45.4, 
        "coldWater":45.2,
        "hotWater":557
    }

2. Получение историй измерений конкретного пользователя:

GET /users/{userId}/measurements

Чтобы получить некоторую историю, нужно сохранить измерения для любого userId (по POST запросу) и использовать то же самое userId в запросе GET.  Результаты будут расположены в порядке убывания по дате и времени сохранения измерения.

Итак, поехали

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

Что нам потребуется для этого:
>>  IDE, Java и сборщик, возьмем для этого IntelliJ IDEA,  Java17 и Maven,
Создадим проект (отпустим детали создания проекта ) и для начала нам нужна еще зависимость Jmeter-Java-Dsl  на момент написания статьи последняя версия 1.19

      <dependency>
          <groupId>us.abstracta.jmeter</groupId>
          <artifactId>jmeter-java-dsl</artifactId>
          <version>1.19</version>
          <scope>test</scope>
      </dependency>

В качестве инфраструктуры тестирования будем использовать JUnit 5 и библиотеку AssertJ для написания утверждений.

      <dependency>
          <groupId>org.junit.jupiter</groupId>
          <artifactId>junit-jupiter</artifactId>
          <version>RELEASE</version>
          <scope>test</scope>
      </dependency>
      <dependency>
          <groupId>org.assertj</groupId>
          <artifactId>assertj-core</artifactId>
          <version>RELEASE</version>
          <scope>test</scope>
      </dependency>

а для генерации данных JavaFaker.

      <dependency>
    	  <groupId>com.github.javafaker</groupId>
	      <artifactId>javafaker</artifactId>
	      <version>1.0.2</version>
      </dependency>

*при генерации данных можем использовать как диапазон валидных значений, так и не валидных для негативных сценариев.

Тестовый План

Сделаем Java class и дадим ему название, например: PerformanceTest , где будет наш тестовый план для нагрузочного тестирования API. В целом, JMeter-Java-Dsl предоставляет мощный и гибкий способ создания планов тестирования, как это будет выглядеть, дальше рассмотрим подробнее, Что ? Где ? Когда ?

Наш тестовый план начнём с threadGroup, где добавим количество потоков, пусть будет 5 (одновременных виртуальных пользователей) и 10 итераций, чтобы было удобнее для восприятия, вынесем в переменные THREADS и ITERATIONS, а в httpSampler передаём адрес нашего сервиса, а так как это Post запрос, у которого есть "тело", нам нужно его передать, чтобы каждый наш запрос был уникальным, мы можем использовать разные, н�� заранее определённые данные для каждого запроса, например, использовать разные userId, gas, coldWater, hotWater (из заданного набора) в каждом запросе. Этого можно легко достичь, используя предоставленный элемент csvDataSet. Например, имея csv файл, подобный этому:

USERID,GAS,COLDWATER,HOTWATER
1,11.1,42.1,111
10,11.2,42.3,112

и добавив в наш тестовый план csvDataSet("path to csv"), а уже в "теле" запросе передавать переменные  ${USERID}, ${GAS}, ${COLDWATER}, ${HOTWATER}.

Для уникальных значений можем использовать и так называемый Счётчик (Counter), который обеспечивает простые средства для автоматического увеличения
значений, которые можно использовать в запросах. Подробнее можно почитать здесь

Но нам нужно больше гибкости и масштабируемости, поэтому на следующем шаге в  .post(s -> buildRequestBody() мы будем формировать динамические значения в "теле" запроса. Чтобы использовать собственную логику используем jsr223preProcessor, более подробно о jsr223preProcessor. Самый лаконичный и рекомендуемый вариант через лямбда выражения.

TestPlanStats stats = testPlan(
            threadGroup(THREADS, ITERATIONS,
                    httpSampler("http://localhost:8080/measurements")
                            .method(HTTPConstants.POST)
                            .post(s -> buildRequestBody(), ContentType.APPLICATION_JSON)

Если "провалимся" в метод buildRequestBody() увидим, что здесь мы формируем "тело" запроса для нашего нагрузочного теста, в конкретном примере для работы с данными используем JSONObject, но можем использовать любой другой удобный подход, чтобы каждый запрос был уникальным, используем JavaFaker для генерации передаваемых значений в json. Документация JavaFaker.

Так это выглядит:

public static String buildRequestBody() {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("userId", getRandomInteger());
        jsonObject.put("gas", getRandomDouble());
        jsonObject.put("coldWater", getRandomDouble());
        jsonObject.put("hotWater", getRandomDouble());
        return jsonObject.toJSONString();
    }

Но так как полученные после сохранения новых измерений на методе POST/measurements данные нам нужно использовать дальше в нашем сценарии, мы должны данные извлечь. Извлекать мы будем userId из ответа, используя jsonExtractor в качестве дочернего элемента httpSampler, потому что именно userId нам понадобится для следующего запроса. Для этого дадим название переменной: не будем называть переменную абстрактно, назовём userIdVariable и передадим извлекаемое значение userId. По умолчанию jsonExtractor использует JMeter JSON JMESpath Extractor и, как следствие, JMESpath в качестве языка запросов. Подробнее можно почитать здесь.
Нелишним будет сразу проверить, что данные в ответе соответствуют тому, что мы ожидаем через jsonAssertion, просто проверим, что в ответе у нас есть поле dateTime, подробнее об утверждениях тут. Утверждения, такие как jsonAssertion, рекомендуются именно для работы с json, да это и так понятно из названия. Чтобы использовать утверждения, нужно также добавить его в качестве дочернего элемента httpSampler и идём дальше по нашему тестовому плану. 

.children(jsonExtractor("userIdVariable", "userId"))
.children(jsonAssertion("dateTime"))

Итак, данные извлекли, теперь нам нужно получить историю измерений через GET /users/{userId}/measurements, куда передаём полученный userId, чтобы использовать полученное значение, можно ссылаться на переменную, используя ${variableName}, в нашем случае это ${userIdVariable} и так же, как ранее через jsonAssertion выполним проверку, что в массиве ответа у нас присутствует userId, так как в ответ нам приходит именно массив.

httpSampler("http://localhost:8080/users/${userIdVariable}/measurements")
                                .method(HTTPConstants.GET)
                                .contentType(ContentType.APPLICATION_JSON)
                                .children(jsonAssertion("[*].userId"))

Для получения метрик нашего нагрузочного тестового сценария публикуем показатели в influxDbListener и визуализируем в Grafana, который в конкретном примере запущен у нас локально.

influxDbListener("http://localhost:8086/write?db=jmeter")
                        .measurement("jmeter")
                        .application("jmeter")
                        .token("Token from influxDb")

На этом подробно останавливаться не будем, подробнее можно почитать о метриках здесь. А в завершение добавим утверждение для валидации, используя AssertJ, что означает, что этот тест завершится неудачно, если он не получит указанную информацию или если 99-й процентиль времени ответа на запросы будет превышать или равен 5 секундам.

assertThat(stats.overall().sampleTimePercentile99()).isLessThan(Duration.ofSeconds(5));

Вот что у нас получилось:

import org.apache.http.entity.ContentType;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
import org.junit.jupiter.api.Test;
import us.abstracta.jmeter.javadsl.core.TestPlanStats;

import java.io.IOException;
import java.time.Duration;

import static com.ipavlov.jmeter.generator.RequestBodyGenerator.buildRequestBody;
import static org.assertj.core.api.Assertions.assertThat;
import static us.abstracta.jmeter.javadsl.JmeterDsl.*;

public class PerformanceTest {

    private static final Integer THREADS = 5;
    private static final Integer ITERATIONS = 10;

    @Test
    public void saveAndGetMeasurementsLoadTest() throws IOException {
        TestPlanStats stats = testPlan(
                threadGroup(THREADS, ITERATIONS,
                            
                        httpSampler("http://localhost:8080/measurements")
                                .method(HTTPConstants.POST)
                                .post(s -> buildRequestBody(), ContentType.APPLICATION_JSON)
                                .children(jsonExtractor("userIdVariable", "userId"))
                                .children(jsonAssertion("dateTime")),

                        httpSampler("http://localhost:8080/users/${userIdVariable}/measurements")
                                .method(HTTPConstants.GET)
                                .contentType(ContentType.APPLICATION_JSON)
                                .children(jsonAssertion("[*].userId"))

                ), influxDbListener("http://localhost:8086/write?db=jmeter")
                        .measurement("jmeter")
                        .application("jmeter")
                        .token("TOKEN from influxDb")
        ).run();

        assertThat(stats.overall().sampleTimePercentile99()).isLessThan(Duration.ofSeconds(5));
    }
}

Заключение

Таким образом мы реализовали тестовый план, который можно расширять, изменять под наши требования и тем самым давать различную нагрузку на наше приложение и оценивать стабильность работы при различных сценариях, а JMeter-Java-Dsl значительно облегчает создание, выполнение и обслуживание тестов производительности, помогает создавать более читабельные планы тестирования в подходящем формате для git и позволит нам включить тесты в CI/CD, достигнув тем самым более высокого уровня автоматизации.

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