Load me up, Gatling

What will we talk about?

Hello. This is a tutorial article about choosing a technology and implementing a load test project for REST microservices API. I described myself and the specifics of the product I’m working on in detail here when I talked about integration tests. I will not pay attention to this here. If you decide to continue, a long read awaits you. The result of the time and attention spent will be an understanding of why load testing is needed, where to start, where to go next, and a template load test project that you can adapt for yourself. All the technologies I use in this article bear the stamp of the Java ecosystem. This may also influence whether you decide to continue. Go …

Technologies used

Technology stack on which the narrative will be based:

  • Java 17;

  • Gatling;

  • Gitlab;

  • Maven;

How will this article benefit you?

What?

Testing, a process aimed at early and comprehensive identification of problems. The sooner and more problems are identified, the higher the chance of creating code that will fulfill its purpose. There is a clear theoretical classification of types of testing, which helps to understand what tests and what are they for?. In this article we will talk about load tests. They allow you to simulate the dynamics of functioning under load (extreme number of requests, “volume” requests, etc.). Load tests help identify components that will be more susceptible to failure than others. This will give an understanding under what conditions the application will work successfully, and under what conditions additional resources will be required (instance/pod/RAM/CPU, etc.).

Where?

The load testing function should be in the development team. The availability, ease of implementation, maintenance and use of load tests determine the success of their use in development processes. It should be easy for a developer to run a load test to confirm that a particular code is functioning normally at any given time. Separating code from load tests will lead to a normal, natural disregard for the aspects of functionality being tested. The ideal implementation of load tests is a project that will be easy for any role in the team to work with and can be launched at any convenient time.

When?

Late detection of errors is costly expensive. Load testing is aimed at working with problems that appear during the operation of the system.

Incentives for change

Applications reside in a specific landscape. They receive, transform and transmit data. Data sources – integrations, databases, specific repositories. The landscape changes over time. Some sources become more productive, others change technologies and are updated. These are the uncertainties for the landscape and applications. Did the changes lead to the desired results? Has it become more productive? It is important for clients to maintain specified response times and functional parameters of interaction contracts. There are more consumers over time. Reflections led to the idea that, after integration tests, we need a tool that confirms the non-functional characteristics of the functioning of applications.

Justification for choice

We have come to the choice of a specific tool. My head was spinning and my nose was bleeding diversity. There are many to choose from, but the process of determining the optimal tool was not long or complicated. Without procrastinating, let's highlight the important things. The desired tools must meet these attributes:

  • Implementation of tests as a project in Java:

    • It is important for a developer to quickly switch context between tasks. Different technologies add variety, but disrupt focus and require additional resources to switch. Implementing load tests in the form of a project with a web interface or in a programming language other than Java was attractive to me; I would be able to understand something new, but this is just interest for the sake of interest. We need a reliable tool that will simply support, update, maintain;

  • Implementing tests as simple ones DSL expressions:

    • I looked towards Jmeter and Gatling web. There was expertise in these technologies, but it was confusing that something additional had to be installed, adapted for automatic execution in the pipeline, work requests kept somewhere nearby and dragged along with the tool. Ultimately, these works also confuse the focus and contribute to the fact that the tool implemented with their help moves away from the application itself;

  • Out of the Box Test Reporting:

    • The test report should be generated without any costs. The ideal scenario for working with the results in my case should have looked like this: Running tests → Executing tests → Generating a report in something web that can be quickly attached to gitlab pages. I wanted to quickly get a complete picture that would demonstrate aspects of performing load tests. I will show the reports further. There is a separate paragraph on this topic ahead;

  • Ability to flexibly balance the choice of test strategy:

    • There are different types of load – constantly-stable, constantly-increasing, increasing-decreasing. I wanted to be able to flexibly, with minimal effort, implement different types of load and balance between them to assess the state of the application. We will talk further about load strategies and balancing between them;

  • Support for HTTP, HTTPS, JMS, JDBC protocols:

Taken together, everything led to a specific solution – gatling. Some of the existing tools are implemented in python (lokust), others can only be implemented as a customized tool with a UI (Jmeter, Gatling, Yandex Tank). Some improvements can only be done in JavaScript (k6). What’s interesting is that the ability to write projects in Java was added to gatling not so long ago. Before this, it was possible to use the UI and write tests in Scala. Well, everything turned out extremely well for me.

Load performance metrics

Load testing – testing the behavior of an application under load. It consists of:

  • Load testing;

  • Stress testing;

  • Soak testing;

  • Spike testing;

  • scalability testing;

These testing strategies should be assessed against the following:

  • Call response time;

  • Number of calls completed during the period;

  • Number of responses received during the period;

  • Number of users whose requests are processed;

  • Errors when calling;

When choosing a strategy for testing a service and evaluating indicators, you need to approach it extremely carefully, assessing the specifics of the operation of a particular service:

  • What's behind the touchpoint?

  • Is query data cached?

  • Is the test bench adequate/linearly correlated with the performance of the production system?

  • Communication channel?

Over time, the values ​​of the indicators will change depending on how loaded the data source is, so it is important to choose a sufficient duration for performing load testing. If you don't know where to start, then load testing will be the optimal strategy to start with.

Gatling

Gatling supports concurrency and high-speed query processing. This is achieved through an asynchronous and non-blocking architecture. I wanted to add a link to the gatling documentation here, but I suddenly discovered that it was no longer there. It's a shame :-(. Gatling is implemented in Scala and uses the Akka library to manage requests/active objects/actors. Here is a detailed description of how it works. The bottom line is that the actor model allows you to process many simultaneous requests without blocking threads. Gatling uses non-blocking input operations -Output (NIO) This allows you to process a large number of requests without creating additional threads, making Gatling a fast, scalable performance testing tool.

Gatling Abstractions

To work with Gatling, you need to have an idea of ​​its main abstractions:

  • Chain – specific action, request;

  • Scenario – a sequence of actions (chain) to reproduce a process or user behavior;

  • Feeders – mechanisms that allow you to enter data from external sources (files, JSON, etc.) when performing a sequence of actions (scenario);

  • Simulation is a transaction, the process of executing a scenario or scenarios (scenario), by a certain number of users. They run scripts over a period of time;

  • Session – user interaction with the system during script execution;

  • Recorder – Gatling interface that generates scenarios and simulations;

Project Dependencies

Let's start building the project. We will use maven. We will need the following dependencies:

Library with implementation of basic gatling abstractions:

<dependency>     
    <groupId>io.gatling</groupId>
    <artifactId>gatling-app</artifactId>
    <version>${берите последнюю из возможных}</version>
</dependency>

Library with implementation of gatling reports:

<dependency>
    <groupId>io.gatling.highcharts</groupId>
    <artifactId>gatling-charts-highcharts</artifactId>
    <version>${берите последнюю из возможных}</version>
</dependency>

In addition, we will add components for auto-code generation (lombok), logging (sl4j) and Json serialization/deserialization (jackson). I added these dependencies because I am used to/comfortable using them and they will be present throughout the code references. You can choose something more suitable for yourself:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>${берите последнюю из возможных}</version>
    <scope>provided</scope>
    <optional>true</optional>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>${берите последнюю из возможных}</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>  
    <artifactId>jackson-databind</artifactId>
    <version>${берите последнюю из возможных}</version>
</dependency>

To run the simulation, you need a maven-compiler-plugin, which will run tests and a special gatling-maven-plugin, for which you need to specify a specific class that contains the entry point to the project. This is an analogue of public static void main. Setting up both plugins in the pom.xml block will look like this:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>${берите последнюю из возможных}</version>
            <configuration>
                <source>${java.version}</source>
                <target>${java.version}</target>
            </configuration>
        </plugin>
        <plugin>
            <groupId>io.gatling</groupId>
            <artifactId>gatling-maven-plugin</artifactId>
            <version>${берите последнюю из возможных}</version>
            <configuration>
                <simulationClass>simulation.Simulation</simulationClass>
            </configuration>
        </plugin>
    </plugins>
</build>

Configuration components for test execution

The tests themselves should be located in the directory – src/test. The src/main/java directory is needed for classes that will process test configurations and their launch parameters. Let's add the config.properties file to the src/main/resources folder. This file will contain the url of the service that we will test. Now we will limit ourselves to one url:

#ServiceUnderPerformance
##Test
serviceUrl=http://localhost:8090/api

We will create a separate class to process configuration parameters. Configuration parameter additions should increase the number of parameters in this class. Now we will only have one. We use record for this.

/**
 * Объект для работы с:
 * конфигурационными переменными - ConfigProperty (config.properties)
 */
public record ConfigProperty(
        String serviceUrl
) {
}

Let's create a custom exception, which we will need while processing the parameters:

/**
 * Custom exception
 * */
public class ProcessingException extends RuntimeException {
    public ProcessingException(String message) {
        super(message);
    }
}

Next, we will process the parameters obtained from the configuration so that they are available in the tests:

/**
 * Component processing property from config.properties
 */
@UtilityClass
@Slf4j
public class ProcessingProperty {
    private static final ObjectMapper objectMapper =
            new ObjectMapper();
     
    /**
     * Получить значения из config.properties
     *
     * @return Property объект
     */
    public static ConfigProperty getConfigProperty() {
        log.info("Load properties file config.properties");
        return objectMapper.convertValue(
                loadPropertiesFromFile("config.properties"),
                ConfigProperty.class);
    }
 
    /**
     * Загрузить содержимое файла
     *
     * @param fileProperties - название файла с конфигурационными переменными
     * @return содержимое файла
     */
    private static Properties loadPropertiesFromFile(String fileProperties) {
        Properties properties = new Properties();
        try (InputStream inputStream =
                     ProcessingProperty.class.getClassLoader()
                             .getResourceAsStream(fileProperties)) {
 
            properties.load(inputStream);
        } catch (IOException exception) {
            throw new ProcessingException(
                    String.format(
                            "Ошибка при обработке конфигурационных переменных : %s", 
                exception.getMessage()));
        }
        return properties;
    }
 
}

Finally, we typify the test strategies that we are going to use. Let's do this via enum. 2 strategies fit our needs:

  • CONSTANT – the application immediately receives requests from a specified number of users;

    • A strategy that involves immediately setting a limit on the number of users who will send requests;

  • RAMP – increase in users from the initial to the specified value over a certain period of time;

Both strategies will be relevant for load testing my applications. Let's implement both of them and determine their launch using the parameter:

@Getter
@AllArgsConstructor
public enum TestMode {
    CONSTANT,
    RAMP
}

That's all with customization classes. Let's move on to the tests.

Implementation of tests

The tests folder will contain two subfolders. In one – “src/test/java” we will have steps, scripts, strategies and simulations, in the other – “src/test/resources/json”, files of the services being tested.

Steps

These are calls to specific services. When implementing a specific step (chain), the name of the test is set, we use a previously defined url, we define the call method, if necessary we set the body of the request, you can also set a specific type (urlencoded), headers, timeouts, and many, many, many settings for a specific step. It is possible to parameterize the work using feeders. The essence of this functionality is -> you can get values ​​for executing queries from third-party files, data generators, integration, etc. I didn't need it. Enough prepared queries. You may need it. Here I'll leave a link to the documentation. To run a set of tests, you may need to save values ​​from the received responses and substitute them into transmitted requests in order to reproduce the business process chain. Gatling has expression language functionality. Of this functionality, I only needed functions for converting json files into requests. There was no need to use other functions. My goal is to develop a set of load tests in order to run load testing. It will be possible to complicate things. Description of the expression language – here. Another step is the ability to check the response attributes. It was important for me to get status 200. A typical step in my case looks like this:

public static final ChainBuilder stepSomeService =
            exec(http("Name")
                    .post(ProcessingProperty.getConfigProperty().serviceUrl())
                    .body(ElFileBody("json/service/success/request.json"))
                       .asJson()
                    .check(status().is(200)));

There were 32 similar steps.

Scenarios

Once the steps are prepared, you can assemble them into a script. Scenario – implemented as DSL component. The DSL helps you focus on what needs to happen during the script. Comfortable. For the script there is an option:

  • Follow the steps step by step;

  • Set a pause between steps;

  • Use feeders to pass common parameters between steps;

  • Set/stop the service load strategy for individual steps;

  • Set/stop the condition for continuing steps;

I chose to follow the prepared steps step by step. That's enough for me. In load test scenarios there are no possible options for system operation. If necessary, we will add it. Now it is enough to set a basic scenario that defines a step-by-step polling of all access points of a particular application. In my case the scenario is like this:

public class ServiceScenario {
 
    public static ScenarioBuilder scenarioUniversalCheck() {
        return CoreDsl.scenario("Performance Test Service")
                .exec(
                        List.of(
                                Chain.stepSomeService,
                                ...
                        ));
    }
 
}

There can be any number of steps.

Application load profiles and strategies (injections)

Gatling supports 2 main types of load profile:

  • Open:

    • Suitable for systems in which you can only control the rate at which users arrive, but cannot influence the number of users;

    • In this type of system, users arrive even if the systems have difficulty processing requests from the current number of users;

    • This type of load is suitable for WEB UI applications that are aimed at working with a wide user audience.

      • This is the type of application that must support an interactive user experience. The more users there are, the more in demand and valuable the product is for its audience;

  • Closed:

    • Suitable for systems where it is necessary to control the number of concurrent users;

    • We can draw an analogy with an open pool of connections, that is, we know exactly how many users can potentially come to us;

    • This is the load profile that is suitable for testing my application;

Methods for testing different profiles are well described in the gatling documentation here. A thorough fundamental article that will help you understand the nuances of how profiles work. here. I decided to implement 2 strategies (injections) that the private profile supports:

  • Constant value of users – set the number of users and operating time under load;

  • User threshold value – set the initial, final value of users and time under load. In this case, users are gradually added up to a threshold value;

Using the prepared strategy typification it turned out like this:

public class InjectionMode {
 
    public static ClosedInjectionStep chooseInjectionStrategy(TestMode testMode) {
        switch (testMode) {
            case RAMP -> {
                return injectionRampRateProfile();
            }
            default -> {
                return injectionConstantRateProfile();
            }
        }
    }
 
    // Схема нагрузки -> Заданное количество пользователей сразу
    private static ClosedInjectionStep injectionConstantRateProfile() {
        return constantConcurrentUsers(END_USER_COUNT)
                .during(Duration.ofSeconds(WORK_UNDER_PRESSURE));
    }
 
    // Схема нагрузки -> Увеличение количества пользователей 
    //от стартового значения
    // до конечного значений
    private static ClosedInjectionStep injectionRampRateProfile() {
        return rampConcurrentUsers(START_USER_COUNT)
                .to(END_USER_COUNT)
                .during(Duration.ofSeconds(WORK_UNDER_PRESSURE));
    }
 
}

You can define testing profiles using DSL gatling expressions, define parallel or sequential scenarios for executing strategies. This is described in detail here.

Our project will be launched using maven. It will be convenient to define a specific launch strategy by setting it as a specific system parameter. Let's set the default values ​​for running load tests. They can be overridden by variables when running the script. About launching the application will be below. We will also set the parameters for the starting number of users and the final number of users in the form of system variables:

@UtilityClass
public class PerformanceParameters {
		 
public static final int START_USER_COUNT
    = Integer.parseInt(System.getProperty("START_USER_COUNT", "1"));
 
public static final int END_USER_COUNT
    = Integer.parseInt(System.getProperty("START_USER_COUNT", "10"));
 
public static final int WORK_UNDER_PRESSURE
    = Integer.parseInt(System.getProperty("WORK_UNDER_PRESSURE", "120"));
 
public static final TestMode TEST_MODE
    = TestMode.valueOf(System.getProperty("TEST_MODE", TestMode
                                                        .CONSTANT.toString()));

}

Simulation

In order to implement the simulation, it is necessary to determine the connection protocol. Comparison parameters are optional during simulation execution, but it was important for me to set them in order to have a clear understanding of how our test will be performed. These comparison parameters for me are:

Our simulation needs to be implemented as a class that should be inherited from the root gatling simulation component “io.gatling.javaapi.core.Simulation”. Our class will finally look like this:

public class Simulation extends Simulation {
 
    // Симуляция
    public Simulation() {
        setUp(
                scenarioUniversalCheck()
                        .injectClosed(
                            InjectionMode.chooseInjectionStrategy(TEST_MODE)
                        )
 
        )
                .protocols(setupHttpForSimulation())
                .assertions(
                        global().responseTime()
                                  .max().lte(GATE_FOR_RESPONSE_MILISECONDS),
                        global().successfulRequests()
                                    .percent().gt(PERCENT_SUCCESS_RESPONSE)
                );
    }
 
    // Протокол подключения
    private static HttpProtocolBuilder setupHttpForSimulation() {
 
        return HttpDsl.http
                .acceptHeader(JSON_TYPE_HEADER)
                .contentTypeHeader(JSON_TYPE_HEADER)
                .maxConnectionsPerHost(CONNECTIONS_PER_HOST)
                ;
    }
 
}

Launch and integration into the development process

It is important for a developer to be able to run a project while developing locally. A local launch will help you check the improvements made. Running in a test environment will be part of the quality gate and will give confidence that each mr with code or configuration does not violate performance metrics. To run locally with default parameters, just run the command → `mvn gatling:test` and the project will be executed. In the console we will see the launch log of each script run. It will execute for the time we set for the load. As a result, we will receive a report in the log (Fig. 1) and a generated page with a report on the execution of the script (Fig. 2).

Fig.1

Fig.1

Fig.2

Fig.2

Above we defined the parameters. If you need to run a test with parameters different from the default parameters, you need to run the script with the parameters – `mvn -D[наименование параметра]=[значение параметра] ga.tling:test`. We will integrate our project into the pipeline on gitlab CI, and we will publish reports on the gitlab pages. The page will be dynamically updated after each load test run. This way we will always have up-to-date performance metrics. Since the test and production environments differ only in the number of instances on which applications are deployed, we have a completely up-to-date picture of the state of the production environment, divided by the number of pods. This is a simplification. We did not take into account a number of parameters – different types of requests, different sizes of requests, caching by services and much more. But we took the first step, figured out the tool. It needs to be further developed.

Reports

The reports that are generated based on the results of load tests deserve special mention. This is high-quality and thoughtfully laid out html. I have experience working with different reporting systems. For example allure, which I mentioned earlier here. Subjectively, I liked the reporting on gatling more. It allows you to evaluate how the system felt under load. We have the state of the simulation as a whole (Fig. 3), which gives an idea of ​​the time intervals during which the steps of our simulation were completed, the number of requests and the time it took to complete load testing:

Fig.3

Fig.3

Block of results with checks of the simulation performed (Fig. 4). Here we see the results of whether the parameters we set were met.

Fig.4

Fig.4

A block that shows the results of each step (Fig. 5) and 4 distributions under load of the simulation work. It is worth adding that the report has additional tabs that demonstrate similar statistics for each completed step (Details tab).

Fig.5

Fig.5

Number of active virtual users during the simulation (Fig. 6). That is, virtual connections from which requests were processed.

Fig.6

Fig.6

Distribution of response time by groups, percentiles (Fig. 7). This way we get groups of query execution times that are close in value.

Fig.7

Fig.7

Distribution of response time to requests (Fig. 8.). By clicking on the chart, you can see the distribution of requests at a specific time point.

Fig.8

Fig.8

The number of processed requests at a specific time point (Fig. 9).

Fig.9

Fig.9

If any step is performed with errors, then the script execution parameters as a whole immediately signal this (Fig. 10).

Fig.10

Fig.10

In the summary report, it will be possible to immediately identify a specific step (Fig. 11), sort the data in a general view, and analyze what is wrong on the adjacent tab with detailed data.

Fig.11

Fig.11

For me this is sufficient information. By manipulating strategies for executing load requests and the duration of services running under load, we can identify patterns and understand which applications in our production loop are more and less productive.

Instead of completion and a link to the repository

The load test project is ready. There are a lot of ideas about its development and possibilities of use. I only covered one application. Need more. In the course of this work, it will become clear what is missing, what should be added, and what should be abandoned.

Acknowledgments

Thanks to my team for their help, support and motivation. May we all withstand any load)

Repository link

Repository with an impersonal project – here. May it benefit you.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *