Sooner or later, there comes the time to measure how your RESTful service behaves under load. There are many out of the shelf tools, that allow to do this in "quick-and-dirty" way — like wrk / wrk2, ab, etc. However, if you’re working with JVM and want to setup reproducible and comprehensive load testing, probably, the best tool would be Gatling.

So, this post is how-to article for setting up load testing of Spring Boot service with Gatling.

1. What will we build ?

In this post we will do the following:

  1. Setup simple reactive web-service

  2. Configure Gatling for our service under test

  3. Write simple load testing scenario

Without further ado — let’s start!

2. Service Under Test

We will perform load testing for simple greetings service, that allows:

  • store greeting, and get back its id

  • get greeting by id

To make things interesting, we will use Spring reactive web stack.

The full service code can be found here. So, you can skip explanations below and go straight to Gatling configuration section.

Bootstrapping

$ curl https://start.spring.io/starter.zip \
-d dependencies=webflux,lombok,actuator \
-d type=maven-project \
-d baseDir=greetings \
-d groupId=com.oxymorus.greetings \
-d artifactId=greetings \
-d bootVersion=2.1.9.RELEASE \
-o service.zip

$ unzip service.zip && rm service.zip && cd service

Domain model

For our service, we will use simple domain class — Greeting.

package com.oxymorus.greeting.domain;

import lombok.Value;

@Value
public class Greeting {

    public static final Greeting DEFAULT = new Greeting("<undefined>", "<undefined>");

    private String name;
    private String greeting;
}

Controller

To implement the requirements, we will expose 2 endpoints:

  • POST /greetings endpoint — to create greeting

  • GET /greetings endpoint — to fetch greeting by id

package com.oxymorus.greeting.api;

import com.oxymorus.greeting.api.model.CreateGreetingRequest;
import com.oxymorus.greeting.api.model.CreateGreetingResponse;
import com.oxymorus.greeting.api.model.GetGreetingRequest;
import com.oxymorus.greeting.api.model.GetGreetingResponse;
import com.oxymorus.greeting.service.GreetingService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/greetings")
public class GreetingController {

    private final GreetingService service;

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    Mono<CreateGreetingResponse> createGreeting(@RequestBody CreateGreetingRequest request) {
        return service.createGreeting(request.getName(), request.getGreeting())
                .map(CreateGreetingResponse::new)
                .doOnSubscribe(subscription -> log.info("Create greeting '{}'", request));
    }

    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    Mono<GetGreetingResponse> getGreeting(GetGreetingRequest request) {

        return service.findGreeting(request.getId())
                .map(greeting -> new GetGreetingResponse(greeting.getName(), greeting.getGreeting()))
                .doOnSubscribe(subscription -> log.info("Get greeting by id '{}'", request.getId()));
    }
}

Service

For our use-case, there is no need to setup complex persistence layer, so we just use in-memory storage:

package com.oxymorus.greeting.service;

import com.oxymorus.greeting.domain.Greeting;
import lombok.experimental.UtilityClass;
import reactor.core.publisher.Mono;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public class DefaultGreetingService implements GreetingService {

    private static final Map<Long, Greeting> GREETINGS_STORAGE = new ConcurrentHashMap<>();

    @Override
    public Mono<Long> createGreeting(String name, String greeting) {
        return Mono.fromCallable(Generator::next)
                .map(id -> {
                    GREETINGS_STORAGE.put(id, new Greeting(name, greeting));
                    return id;
                });
    }

    @Override
    public Mono<Greeting> findGreeting(Long id) {
        return Mono.fromCallable(() -> GREETINGS_STORAGE.getOrDefault(id, Greeting.DEFAULT));
    }

    @UtilityClass
    private static class Generator {
        private static final AtomicLong id = new AtomicLong(0);

        private static long next() {
            return id.incrementAndGet();
        }
    }
}

Smoke Testing

Ok, now we have everything in place, so let’s issue a few requests:

  • POST query:

    $ curl -X POST http://localhost:8080/greetings \
    -H "Content-Type: application/json" \
    -H "Accept: application/stream+json" \
    -d '{"name":"Alina", "greeting":"Hola senorita. Como esta?"}'

    Response:

    {"id":1}
  • GET query:

    $ curl -X GET http://localhost:8080/greetings?id=1

    Response:

    {"name":"Alina", "greeting":"Hola senorita. Como esta?"}

3. Gatling configuration

Gatling is written in Scala and provides pretty convenient DSL for describing load test scenarios.

So, to enable it for our service we need to configure Scala:

<build>
    <testSourceDirectory>src/test/scala</testSourceDirectory>
    <plugins>
        <plugin>
            <groupId>net.alchim31.maven</groupId>
            <artifactId>scala-maven-plugin</artifactId>
            <version>${scala-maven-plugin.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>testCompile</goal>
                    </goals>
                    <configuration>
                        <jvmArgs>
                            <jvmArg>-Xss100M</jvmArg>
                        </jvmArgs>
                        <args>
                            <arg>-target:jvm-1.8</arg>
                            <arg>-deprecation</arg>
                            <arg>-feature</arg>
                            <arg>-unchecked</arg>
                            <arg>-language:implicitConversions</arg>
                            <arg>-language:postfixOps</arg>
                        </args>
                    </configuration>
                </execution>
            </executions>
        </plugin>

        <plugin>
            <groupId>io.gatling</groupId>
            <artifactId>gatling-maven-plugin</artifactId>
            <version>${gatling-plugin.version}</version>

            <executions>
                <execution>
                    <goals>
                        <goal>test</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>

    </plugins>
</build>

Also, we need to add Gatling dependency itself:

<dependency>
    <groupId>io.gatling.highcharts</groupId>
    <artifactId>gatling-charts-highcharts</artifactId>
    <version>${gatling.version}</version>
    <scope>test</scope>
</dependency>

4. Load testing

Finally, after performing all configuration we are ready to write load test:

import com.typesafe.config.ConfigFactory
import io.gatling.core.Predef._
import io.gatling.http.Predef._

import scala.concurrent.duration._

class LoadScript extends Simulation {

  val config = ConfigFactory.load()
  val baseUrl = config.getString("baseUrl")
  val dataFile = config.getString("dataFile")

  val dataFeeder = ssv(dataFile).circular

  val httpConfig = http
    .baseUrl(baseUrl)
    .contentTypeHeader("application/json")
    .acceptHeader("application/json")
    .shareConnections

  val basicLoad = scenario("LOAD_TEST")
    .feed(dataFeeder)
    .exec(BasicLoad.start)

  setUp(
    basicLoad.inject(
      rampConcurrentUsers(0) to (200) during (10 seconds),
      constantConcurrentUsers(200) during (50 seconds)
    ).protocols(httpConfig)
  )

}

object BasicLoad {

  val start =
    exec(
      http("Register greeting")
        .post("/greetings")
        .body(StringBody(
          """
            |{
            |  "name": "${name}",
            |  "greeting": "${greeting}"
            |}
            |""".stripMargin)).asJson
        .check(status is 200)
        .check(jsonPath("$.id").saveAs("id"))
    )
    .exec(
      http("Get greeting by id")
        .get("/greetings")
        .queryParam("id", "${id}")
        .check(status is 200)
    )
}

To run script, just execute:

$ mvn gatling:test -Dgatling.skip=false -Dgatling.simulationClass=LoadScript

5. Debugging Load Script

When you write Gatling load tests, sooner or later you’ll need some facilities to debug and figure WTF is going on.

There are several practical ways to figure out, if something goes wrong in your Gatlign script:

  • Using good old println inside one of exec blocks:

        .exec { session =>
          println(session)
          session
        }
  • Using logger configuration:

    <?xml version="1.0" encoding="UTF-8"?>
    <configuration>
    
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} [%-5level] %logger{15} - %msg%n%rEx</pattern>
            </encoder>
            <immediateFlush>false</immediateFlush>
        </appender>
    
        <!-- uncomment and set to DEBUG to log all failing HTTP requests -->
        <!-- uncomment and set to TRACE to log all HTTP requests -->
        <logger name="io.gatling.http.engine.response" level="DEBUG" />
    
        <root level="WARN">
            <appender-ref ref="CONSOLE" />
        </root>
    
    </configuration>

6. Results

When script completes, we can see the results:

  • In Gatling Maven plugin output:

    ================================================================================
    ---- Global Information --------------------------------------------------------
    > request count                                     435202 (OK=435202 KO=0     )
    > min response time                                      0 (OK=0      KO=-     )
    > max response time                                    537 (OK=537    KO=-     )
    > mean response time                                    49 (OK=49     KO=-     )
    > std deviation                                         34 (OK=34     KO=-     )
    > response time 50th percentile                         43 (OK=43     KO=-     )
    > response time 75th percentile                         63 (OK=63     KO=-     )
    > response time 95th percentile                        111 (OK=111    KO=-     )
    > response time 99th percentile                        162 (OK=162    KO=-     )
    > mean requests/sec                                7134.459 (OK=7134.459 KO=-     )
    ---- Response Time Distribution ------------------------------------------------
    > t < 800 ms                                        435202 (100%)
    > 800 ms < t < 1200 ms                                   0 (  0%)
    > t > 1200 ms                                            0 (  0%)
    > failed                                                 0 (  0%)
    ================================================================================
    
    Reports generated in 0s.
    Please open the following file: .../target/gatling/loadscript-20191030221405125/index.html
  • In nicely prepared report:

    $ xdg-open target/gatling/loadscript*/index.html
    greetings get

7. Conclusion

In this post we learned, how to setup load testing of your service.

If you followed along, probably, you noticed, that it may be daunting sometimes, but results are rewarding.

Oleksii Zghurskyi