Contents

Gatling Java DSL

Gatling Java DSL webp image

Finally!!! A Java API for Gatling DSL. Since the 3.7 version, we can use Gatling with the Java language (and Kotlin).

Some time ago, I wrote a post about Gatling vs JMeter with a slightly unusual comparison of these tools. One of the "arguments" against Gatling - that it requires learning Scala - is officially gone. Let's play with it and verify the new performance-test-as-a-code Java DSL. It is not the aim of this article to describe all aspects of the Java Gatling DSL. Check the official documentation for that. It is also not a basic step-by-step tutorial like here. My idea is to focus on a real example and describe how Gatling might be used for load testing.

Serial reservation scenario

Before we jump into the code, we need to talk about the system under test. It is a very simple web application for cinema show seat reservations. It was described and developed in the Reactive Event Sourcing in Java series. From the HTTP API perspective, we can:

  • create a show (with a predefined number of available seats)
  • reserve a seat,
  • cancel a reservation.

If you are familiar with the Intellij HTTP client, here is a list of possible actions:

POST http://localhost:8080/shows
Content-Type: application/json

{
  "showId": "{{show-id}}",
  "title": "show title {{$randomInt}}",
  "maxSeats": 100
}

###

GET http://localhost:8080/shows/{{show-id}}

###

PATCH http://localhost:8080/shows/{{show-id}}/seats/1
Content-Type: application/json

{
  "action": "RESERVE"
}

###

PATCH http://localhost:8080/shows/{{show-id}}/seats/1
Content-Type: application/json

{
  "action": "CANCEL_RESERVATION"
}

Let's assume a very simple scenario. For each virtual user, we will create a Show and then, in a loop, we will reserve all seats. Seats are numbered from 0 to maxSeats (exclusive).

To start, we need to create a Maven (or Gradle) project and either go with the official instructions or simply clone the existing demo project. The second one is faster.

One of the main ideas behind Gatling DSL is test readability. I've noticed that in the Java DSL, they abandoned a lot of standard language conventions like capital letters for constants, etc. Personally, I am happy with this decision, the code is more readable this way.

Constructing the first request is very straightforward.

ScenarioBuilder simpleScenario = scenario("Create show and reserve seats")
    .feed(showIdsFeeder) //1
    .exec(http("create-show")
          .post("/shows")
          .body(createShowPayload) //2
         )

The code after the exec method speaks for itself. However, we need to explain two things. The feeder (//1) is used to feed our scenario with predefined data like the Show ids. Feeders can read data from many different sources, like databases, CSV files, URLs, etc. In our case, it is an infinite stream of key-value maps.

private Iterator<Map<String, Object>> showIdsFeeder =
            Stream.generate((Supplier<Map<String, Object>>) () -> {
                        return Collections.singletonMap("showId", UUID.randomUUID().toString());
                    }
            ).iterator();

Ok, but how to get this data in our scenario? For that, we can use the Gatling Expression Language (EL).

    Body createShowPayload = StringBody("""
                {
                  "showId": "#{showId}",
                  "title": "Moonrise Kingdom",
                  "maxSeats": 50
                }
                """);;

Or more manually from the session object if we want to parametrize other fields:

Body createShowPayload = StringBody(session -> {
        var showId = session.getString("showId");
        return String.format("""
                {
                  "showId": "%s",
                  "title": "show title %s",
                  "maxSeats": %s
                }
                """, showId, showId.substring(0, 8), maxSeats);
    });

The seat reservation requests will be invoked in a loop:

.foreach(randomSeatNums, "seatNum").on(
    exec(http("reserve-seat")
         .patch("shows/#{showId}/seats/#{seatNum}")
         .body(reserveSeatPayload))
);

This time, we are using EL not only to get the showId but also the seatNum from the loop instruction. The reserveSeatPayload doesn't require any extra work.

    private Body reserveSeatPayload = StringBody("""
            {
              "action": "RESERVE"
            }
            """);

The whole scenario together is very easy to read even if we haven’t used Gatling before. It's one of my favorite Gatling features that the test code is readable almost as normal sentences. Also, you can use any coding refactoring techniques to make it even more pleasant.

ScenarioBuilder simpleScenario = scenario("Create show and reserve seats")
    .feed(showIdsFeeder)
    .exec(http("create-show")
          .post("/shows")
          .body(createShowPayload)
         )
    .foreach(randomSeatNums, "seatNum").on(
    exec(http("reserve-seat")
         .patch("shows/#{showId}/seats/#{seatNum}")
         .body(reserveSeatPayload))
);

The last part is the scenario setup where we can define how to inject virtual users. Remember that scenario is only the business flow definition. To actually run it, we must launch it against some load model (open or closed).

setUp(simpleScenario.injectOpen(constantUsersPerSec(10).during(15))
                    .protocols(httpProtocol));

We start with a basic load generation strategy, for 15 seconds, each second 10 users will concurrently launch 51 requests (seat creation + 50 * seat reservation). After running it with a command:

mvn gatling:test -Dgatling.simulationClass=workshop.perftest.SimpleScenarioSimulation  

we can check the results:

================================================================================
2022-09-15 10:04:53                                          15s elapsed
---- Requests ------------------------------------------------------------------
> Global                                                   (OK=7650   KO=0     )
> create-show                                              (OK=150    KO=0     )
> reserve-seat                                             (OK=7500   KO=0     )

---- Create show and reserve seats ---------------------------------------------
[##########################################################################]100%
         waiting: 0      / active: 0      / done: 150    
================================================================================

================================================================================
---- Global Information --------------------------------------------------------
> request count                                       7650 (OK=7650   KO=0     )
> min response time                                      4 (OK=4      KO=-     )
> max response time                                     60 (OK=60     KO=-     )
> mean response time                                     8 (OK=8      KO=-     )
> std deviation                                          4 (OK=4      KO=-     )
> response time 50th percentile                          7 (OK=7      KO=-     )
> response time 75th percentile                         10 (OK=10     KO=-     )
> response time 95th percentile                         15 (OK=15     KO=-     )
> response time 99th percentile                         21 (OK=21     KO=-     )
> mean requests/sec                                478.125 (OK=478.125 KO=-     )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms                                          7650 (100%)
> t ≥ 800 ms <br> t < 1200 ms                            0 (  0%)
> t ≥ 1200 ms                                            0 (  0%)
> failed                                                 0 (  0%)
================================================================================

Very often I need to check what the performance limit of a given system is. The brute force approach for that would be to run the test, increase the constantUsersPerSec value, and repeat the process until the system is not responsive. Since the scenario setup is just code, we can do this programmatically, with some sort of a loop. The recommended way is to use Meta DSL for load generation.

setUp(simpleScenario.injectOpen(incrementUsersPerSec(10)
                                .times(7)
                                .eachLevelLasting(300)
                                .startingFrom(10)))
    .protocols(httpProtocol);

Super handy for capacity tests. Just launch the code once and wait for the results. The general summary after such a test won't be useful to analyze since you want to see how the system behaves on each step. For that, a visual report, generated by Gatling after each test, gives us a much better insight.

Please open the following file: file:///home/andrzej/…/reactive-event-sourcing-java-gatling-perftest/target/gatling/serialreservationsimulation-20220915080437416/index.html

gatling capacity tests

Concurrent reservation scenario

Of course, this is a very naive business flow. In a real-life scenario, many users will try to concurrently reserve seats for the same show. To achieve this, we need to change the strategy a bit and split our scenarios into two separate ones:

ScenarioBuilder createShows = scenario("Create show scenario")
    .feed(showIdsFeeder)
    .exec(http("create-show")
          .post("/shows")
          .body(createShowPayload)
         );

ScenarioBuilder reserveSeats = scenario("Reserve seats")
    .feed(reservationsFeeder)
    .exec(http("reserve-seat")
          .patch("shows/#{showId}/seats/#{seatNum}")
          .body(reserveSeatPayload));

The same applies to feeders. The first one will generate showIds based on a predefined list:

Iterator<Map<String, Object>> showIdsFeeder = showIds.stream().map(showId -> Collections.<String, Object>singletonMap("showId", showId)).iterator();

The second will create a feeder with a mapping showId ->* seatNum.

Iterator<Map<String, Object>> reservationsFeeder = showIds
            .stream().flatMap(showId ->
                              IntStream.range(0, maxSeats).boxed()
                              .map(seatNum -> Map.<String, Object>of("showId", showId, "seatNum", seatNum)
                                  ))
    .iterator();

In a tabular form, it would look like this:

showIdseatNum
a1
a2
a50
b1
b2

Once we create all the shows, we can go with the reserveSeat scenario and run concurrent seat reservation.

{
    setUp(createShows.injectOpen(atOnceUsers(howManyShows)).andThen(
        reserveSeats.injectOpen(constantUsersPerSec(10).during(60))))
        .protocols(httpProtocol);
}

The problem with this approach is that we need to calculate how many Shows we need to create upfront for the second part. This is easy for a single test run, but for a capacity test, it might not be so obvious. We can change the strategy again and take advantage of the fact that we can cancel the reservation. This way, it is possible to reuse existing Show aggregates. In a "loop", we can reserve all seats and then cancel reservations depending on the doSwitch expression.

ScenarioBuilder reserveSeatsOrCancelReservation = scenario("Reserve seats or cancel reservation")
            .feed(listFeeder(reservationFeeder).circular())
            .doSwitch("#{action}").on(
                    Choice.withKey(RESERVE_ACTION, tryMax(5).on(exec(http("reserve-seat") //tryMax in case of concurrent reservation/cancellation
                            .patch("shows/#{showId}/seats/#{seatNum}")
                            .body(reserveSeatPayload)))),
                    Choice.withKey(CANCEL_RESERVATION_ACTION, tryMax(5).on(exec(http("cancel-reservation")
                            .patch("shows/#{showId}/seats/#{seatNum}")
                            .body(cancelReservationPayload))))
            );

Because actions are launched by concurrent users, there is a chance that we will try to cancel non-existing reservations. To minimize this issue, we can wrap the HTTP call with a tryMax(...) error handler.

Updated feeder will be more complicated because it will produce reservations and cancellations for a given showId.

List<Map<String, Object>> reserveOrCancelActions = showIds.stream()
            .flatMap(showId -> {
                Stream<Map<String, Object>> showReservations = prepareActions(showId, RESERVE_ACTION);
                Stream<Map<String, Object>> showCancellations = prepareActions(showId, CANCEL_RESERVATION_ACTION);
                return concat(showReservations, showCancellations);
            })
            .toList();

    private Stream<Map<String, Object>> prepareActions(String showId, String action) {
        return IntStream.range(0, maxSeats).boxed()
                .map(seatNum -> Map.of("showId", showId, "seatNum", seatNum, "action", action));
    }

Summary

I hope you liked this quick introduction to Gatling Java DSL. We transform something easy into a more complex example to show how Gatling might fit into our performance test requirements. Frankly speaking, the last scenario could be transformed even more to be realistic (follow the link if you are interested).

After years of playing with Gatling, I know the business flow is most likely the easiest part of a good performance test scenario. Proper test data generation with feeders and load generation strategies with scenario setup are the keys to valuable performance tests which simulate real production challenges.

If you would like to check out the full source of Gatling tests, they are available here.

Blog Comments powered by Disqus.