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:
Setup simple reactive web-service
Configure Gatling for our service under test
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 greetingGET /greetings
endpoint — to fetch greeting byid
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 ofexec
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
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.