Testing, scala, gatling, rest technologies

RESTful service load testing using Gatling 2

In this post, I am going to show how to create load tests for a REST API application with the help of Gatling 2. This will be a step-by-step guide — starting from integrating Gatling 2 using SBT plugin, creating/configuring test scenarios, and all the way to running the Gatling tests.

Gatling 2 stress tool sends simultaneous requests following a specific scenario. The scenario consists of requests to the service and response checks, so we can emulate the service usage under a high load. At the end of a simulation, Gatling 2 provides users with a detailed HTML report (you can find one here). Using Gatling 2 will help to find out how reliable a service is and how well it performs. Also, it may help you to find and fix various performance issues before you encounter significant problems in production.

For the sake of demonstration, I am going to create a simulation for the service built in the "Building the REST service with Scala" article. You can see the service sources here. The Gatling SBT plugin will be used to run the tests with just one SBT command.

There are also other extensions which can be used to run the simulation, see the full list here.

We will create the tests in 4 steps:

  • Create SBT project and configure dependencies
  • Specify the SBT project configuration
  • Generate test data for the load test
  • Create a test scenario

Step 1. Create SBT project and configure dependencies

First, create the SBT project and specify the SBT version:

project/build.properties
1
sbt.version=0.13.5

Gatling 2 uses Akka actors, so we will add this dependency to build.sbt, also we will add akka-slf4j package for logging, and lift-json for JSON serialization:

build.sbt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import io.gatling.sbt.GatlingPlugin

name := "rest"

version := "1.0"

scalaVersion := "2.10.4"

val akkaVersion = "2.2.4"

libraryDependencies ++= Seq(
  "com.typesafe.akka" % "akka-actor_2.10" % akkaVersion,
  "com.typesafe.akka" % "akka-slf4j_2.10" % akkaVersion,
  "net.liftweb" % "lift-json_2.10" % "2.5.1"
)

resolvers ++= Seq(
  "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/"
)

scalacOptions ++= Seq("-Xmax-classfile-name", "100")

Then, following the Gatling SBT plugin setup instructions:

  • add the Gatling SBT plugin in plugins.sbt
project/plugins.sbt
1
2
3
4
5
resolvers ++= Seq(
  "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots"
)

addSbtPlugin("io.gatling" % "sbt-plugin" % "1.0-RC5")
  • import Gatling 2 and configure settings in the *.scala build file
project/TestBuild.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import sbt._
import sbt.Keys._

import io.gatling.sbt.GatlingPlugin._

object TestBuild extends Build {

  val libs = Seq(
    "io.gatling.highcharts" % "gatling-charts-highcharts" % "2.0.0-SNAPSHOT" % "test",
    "io.gatling" % "gatling-bundle" % "2.0.0-SNAPSHOT" % "test"
      artifacts Artifact("gatling-bundle", "zip", "zip", "bundle"),
    "io.gatling" % "test-framework" % "1.0-SNAPSHOT" % "test"
  )

  val root = Project("scala-rest", file("."))
    .settings(gatlingSettings: _*)
    .settings(resolvers +=
      "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots")
    .configs(Gatling)
    .settings(organization := "io.gatling.sbt.test")
    .settings(libraryDependencies ++= libs)
    .settings(scalaSource in Gatling :=
      new File(s"${System.getProperty("user.dir")}/src/test/gatling/scala"))
}

The scalaSource in Gatling determines the folder of the Gatling test sources. And the System.getProperty("user.dir") is used here to get a full path to the project root directory.

Step 2. Specify the SBT project configuration

Place the following configuration into the application.conf file:

src/test/resources/application.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
service {

    # Url to service host:
    host = "http://localhost:8080"

    # Endpoints base path:
    api_link = "/customer"
}

scenario {

    # Scenario repeat count:
    repeat_count = 1

    # Emulate the specific count of users for simulation:
    thread_count = 2

    # Percent of successful service responses
    # when the simulation is considered to be successful:
    percent_success = 100
}

# Test data:
data {
    first_names = ["Andrey", "Vlad", "Alexandra"]
    last_names = ["Litvinenko", "Belik", "Borte"]
    birthdays = ["1956-01-24", "1995-02-28", "1983-12-16"]
}

Let's create a trait which will help us to get the configuration data from the application.conf file.

src/test/gatling/com/sysgears/example/rest/SimulationConfig.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import akka.event.slf4j.SLF4JLogging
import com.typesafe.config.{ConfigException, ConfigFactory}
import scala.util.Try

trait SimulationConfig extends SLF4JLogging {

  /**
   * Application config object.
   */
  private[this] val config = ConfigFactory.load()

  /**
   * Gets the required string from the config file or throws
   * an exception if the string is not found.
   *
   * @param path path to string
   * @return string fetched by path
   */
  def getRequiredString(path: String) = {
    Try(config.getString(path)).getOrElse {
      handleError(path)
    }
  }

  /**
   * Gets the required int from the config file or throws
   * an exception if the int is not found.
   *
   * @param path path to int
   * @return int fetched by path
   */
  def getRequiredInt(path: String) = {
    Try(config.getInt(path)).getOrElse {
      handleError(path)
    }
  }

  /**
   * Gets the required string list from the config file or throws
   * an exception if the string list is not found.
   *
   * @param path path to string list
   * @return string list fetched by path
   */
  def getRequiredStringList(path: String) = {
    Try(config.getStringList(path)).getOrElse {
      handleError(path)
    }
  }

  private[this] def handleError(path: String) = {
    val errMsg = s"Missing required configuration entry: $path"
    log.error(errMsg)
    throw new ConfigException.Missing(errMsg)
  }

  /**
   * URL for test.
   */
  val baseURL = getRequiredString("service.host")

  /**
   * Endpoint link.
   */
  val customerLink = getRequiredString("service.api_link")

  /**
   * Scenario repeat count.
   */
  val repeatCount = getRequiredInt("scenario.repeat_count")

  /**
   * Count of users for simulation.
   */
  val threads = getRequiredInt("scenario.repeat_count")

  /**
   * Percent of successful service responses when
   * the simulation is considered to be successful.
   */
  val percentSuccess = Try(config.getInt("scenario.percent_success")).getOrElse(100)
}

Step 3. Generate test data for the load test

Let's add the Customer case class as a container for test data. The class should comply to the Customer data of the tested service:

src/test/gatling/com/sysgears/example/rest/data/Customer.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.Date

/**
 * Customer entity.
 *
 * @param id        unique id
 * @param firstName first name
 * @param lastName  last name
 * @param birthday  date of birth
 */
case class Customer(id: Option[Long],
                    firstName: String,
                    lastName: String,
                    birthday: Option[Date])

In order to separate test data configuration from the test scenario, let's add the base object for the class containing the test scenario:

src/test/gatling/com/sysgears/example/rest/data/CustomerTestData.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.text.SimpleDateFormat
import com.sysgears.example.rest.SimulationConfig
import scala.util.Random

object CustomerTestData extends SimulationConfig {

  val dateFormat = new SimpleDateFormat("yyyy-MM-dd")

  //test data
  val firstNames = getRequiredStringList("data.first_names")
  val lastNames = getRequiredStringList("data.last_names")
  val birthdays = getRequiredStringList("data.birthdays")

  def generateCustomer: Customer = {
    Customer(None,
      firstNames.get(Random.nextInt(firstNames.size())),
      lastNames.get(Random.nextInt(lastNames.size())),
      Some(dateFormat.parse(birthdays.get(Random.nextInt(birthdays.size()))))
    )
  }

  val customers = List.fill(threads)(generateCustomer)

  val nonExistentCustomerId = customers.size + 1
}

Step 4. Create a test scenario

Gatling simulation follows a specific template:

  • importing of io.gatling.core.Predef._ and io.gatling.http.Predef._
  • extending io.gatling.core.scenario.Simulation Gatling API base class
  • providing the actual scenario (requests to a tested service and response checks)
  • setting up the scenario settings

First, let's look at the sample class, which will help you understand the basic principles of creating the Gatling scenario class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// essential imports for simulation:
import io.gatling.core.Predef._
import io.gatling.http.Predef._

// simulation class:
class CustomerSimulation extends io.gatling.core.scenario.Simulation {

  //scenario
  val scn = scenario("Simulation name").repeat(repeatCount) {
    exec(
      http(session => "Request description")
        .post(fullEndpointLink) // full link to tested endpoint
        .check(status is rightStatus) // successful status
    )
  }

  // set up the scenario and threads (users) count:
  setUp(scn.inject(atOnceUsers(usersCount)))
}

Now let's create a simulation for the tested service, this simulation will:

  • Send CRUD requests to the service and check the response.
  • Configure an http base URL of the service.
  • Use JSONPath for processing JSON in responses.
  • Configure the scenario to use the specific number of simultaneous requests.
  • Configure the scenario to repeat it as many times as it was set in SimulationConfig.

Here is a full simulation code:

src/test/gatling/com/sysgears/example/rest/CustomerSimulation.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import net.liftweb.json.Serialization
import com.sysgears.example.rest.data.CustomerTestData._
import scala.util.Random

/**
 * Load test for the rest service.
 */
class CustomerSimulation extends io.gatling.core.scenario.Simulation
    with SimulationConfig {

  /**
   * http configuration.
   */
  val httpProtocol = http.baseURL(baseURL)

  /**
   * Set default formats for json parser.
   */
  implicit val formats = net.liftweb.json.DefaultFormats

  /**
   * Returns a random customer in JSON format.
   *
   * @return customer in JSON format
   */
  def randCustomer = {
    StringBody(Serialization.write(
      customers(Random.nextInt(customers.size))))
  }

  /**
   * Generates a random search query with random params.
   *
   * @return generated query
   */
  def randSearchQuery = {
    def ?+(s: String) = {
      if (Random.nextBoolean()) s + "&" else ""
    }
    val customer = customers(Random.nextInt(customers.size))

    "?" + ?+(s"firstName=${customer.firstName}") +
      ?+(s"lastName=${customer.lastName}") +
      ?+(s"birthday=${dateFormat.format(customer.birthday.get)}")
  }

  /**
   * Scenario for simulation.
   */
  val scn = scenario("Simulation for the customer service").repeat(repeatCount) {
    exec(
      http(session => "Post a customer")
        .post(customerLink)
        .body(randCustomer)
        .check(status is 201)
        .check(jsonPath("$.id").saveAs("id"))
    )
      .exec(
        http(session => "Search customers")
          .get(customerLink + randSearchQuery)
          .check(status is 200)
      )
      .exec(
        http(session => "Get customers")
          .get(customerLink)
          .check(status is 200)
      )
      .exec(
        http(session => "Get customer by id")
          .get("/customer/${id}")
          .check(status is 200)
      )
      .exec(
        http(session => "Put customer by id")
          .put("/customer/${id}")
          .body(randCustomer)
          .check(status is 200)
      )
      .exec(
        http(session => "Delete customer")
          .delete(customerLink + "/${id}")
          .check(status is 200)
      )
  }

  /**
   * Sets the scenario.
   */
  setUp(scn.inject(atOnceUsers(threads)))
    .protocols(httpProtocol)
    .assertions(global.successfulRequests.percent.is(percentSuccess)) //Check test result
}

We need to start the service before running the load tests. You can find the service jar with the config file in the tested_service directory of the repository, make sure to set up proper mysql parameters in the application.conf file in order for application to access your database server and don't forget to create an appropriate MySQL database. Now you can start the jar using the following command:

1
$ java -Dconfig.file=application.conf -jar rest-assembly-1.0.jar

And run Gatling simulations with the following command:

1
$ sbt gatling:test

After the simulation is finished, you can find the report in the target/gatling/{simulation-name} directory.

All the sources are available on GitHub repository.

See also:

Hope you find this helpful.

Looking to hire a software developer?
Don't hesitate to contact us.

Comments