Разделы портала

Онлайн-тренинги

.
Нагрузочное тестирование API без использования UI
26.10.2023 00:00

Автор статьи: Павлов Игорь

В этой статье поговорим о Нагрузочном тестировании при помощи 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, достигнув тем самым более высокого уровня автоматизации.

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

Обсудить в форуме