Writing realistic Gatling tests

Isolate process, configure virtual users, use feeders, checks and looping

In this tutorial, we assume that you have already gone through the Introduction to the Recorder section and that you have a basic simulation to work with. We will apply a series of refactorings to introduce more advanced concepts and Domain Specific Language constructs.

Step 1: Isolate processes

Presently our Simulation is one big monolithic scenario.

So first let us split it into composable business processes. This way, you’ll be able to easily reuse some parts and build complex behaviors without sacrificing maintenance.

In our scenario we have three separated processes:

  • search: search models by name
  • browse: browse the list of models
  • edit: edit a given model

Here, we’re storing those chains into attributes in the same class, but you could as well store them in constants (static final fields in Java, object attributes in Scala and Kotlin, move them into a different class, etc.

     
ChainBuilder search =
  // let's give proper names, as they are displayed in the reports
  exec(http("Home")
    .get("/"))
    .pause(7)
    .exec(http("Search")
      .get("/computers?f=macbook"))
    .pause(2)
    .exec(http("Select")
      .get("/computers/6"))
    .pause(3);

ChainBuilder browse = null; // TODO

ChainBuilder edit = null; // TODO
val search =
  // let's give proper names, as they are displayed in the reports
  exec(http("Home")
    .get("/"))
    .pause(7)
    .exec(http("Search")
    .get("/computers?f=macbook"))
    .pause(2)
    .exec(http("Select")
    .get("/computers/6"))
    .pause(3)

val browse: ChainBuilder = TODO()

val edit: ChainBuilder = TODO()
val search =
  // let's give proper names, as they are displayed in the reports
  exec(http("Home")
    .get("/"))
    .pause(7)
    .exec(http("Search")
      .get("/computers?f=macbook"))
    .pause(2)
    .exec(http("Select")
      .get("/computers/6"))
    .pause(3)

val browse = ???

val edit = ???

We can now rewrite our scenario using these reusable business processes:

     
ScenarioBuilder scn = scenario("Scenario Name")
  .exec(search, browse, edit);
val scn = scenario("Scenario Name")
  .exec(search, browse, edit)
val scn = scenario("Scenario Name")
  .exec(search, browse, edit)

Step 2: Configure virtual users

So, this is great, we can load test our server with… one user! Let’s increase the number of users.

Let’s define two populations of users:

  • regular users: they can search and browse computer models.
  • admin users: they can search, browse and also edit computer models.

Translating into a scenario this gives:

     
ScenarioBuilder users = scenario("Users")
  .exec(search, browse);
ScenarioBuilder admins = scenario("Admins")
  .exec(search, browse, edit);
val users = scenario("Users")
  .exec(search, browse)
val admins = scenario("Admins")
  .exec(search, browse, edit)
val users = scenario("Users")
  .exec(search, browse)
val admins = scenario("Admins")
  .exec(search, browse, edit)

To increase the number of simulated users, all you have to do is to change the configuration of the simulation as follows:

     
{
  setUp(users.injectOpen(atOnceUsers(10)).protocols(httpProtocol));
}
init {
  setUp(users.injectOpen(atOnceUsers(10)).protocols(httpProtocol))
}
setUp(users.inject(atOnceUsers(10)).protocols(httpProtocol))

Here we set only 10 users, because we don’t want to flood our test web application. Please, be kind and don’t crash our server! ;-)

If you want to simulate 3000 users, you might not want them to start at the same time. Indeed, real users are more likely to connect to your web application gradually.

Gatling provides rampUsers to implement this behavior. The value of the ramp indicates the duration over which the users will be linearly started.

In our scenario let’s have 10 regular users and 2 admins, and ramp them over 10 seconds so we don’t hammer the server:

     
{
  setUp(
    users.injectOpen(rampUsers(10).during(10)),
    admins.injectOpen(rampUsers(2).during(10))
  ).protocols(httpProtocol);
}
init {
  setUp(
    users.injectOpen(rampUsers(10).during(10)),
    admins.injectOpen(rampUsers(2).during(10))
  ).protocols(httpProtocol)
}
setUp(
  users.inject(rampUsers(10).during(10)),
  admins.inject(rampUsers(2).during(10))
).protocols(httpProtocol)

Step 3: Use dynamic data with Feeders and Checks

We have set our simulation to run a bunch of users, but they all search for the same model. Wouldn’t it be nice if every user could search a different model name?

We need dynamic data so that all users don’t play exactly the same scenario, and so that we don’t end up with a behavior completely different from the live system (due to caching, JIT etc.). This is where Feeders will be useful.

Feeders are data sources containing all the values you want to use in your scenarios. There are several types of Feeders, the most simple being the CSV Feeder: this is the one we will use in our test.

First let’s create a file named search.csv and place it in the user-files folder.

This file contains the following lines:

searchCriterion,searchComputerName
Macbook,MacBook Pro
eee,ASUS Eee PC 1005PE

Let’s then declare a feeder and use it to feed our users with the above data:

     
FeederBuilder.Batchable<String> feeder =
  csv("search.csv").random();// 1, 2

ChainBuilder search = exec(http("Home")
  .get("/"))
  .pause(1)
  .feed(feeder) // 3
  .exec(http("Search")
    .get("/computers?f=#{searchCriterion}") // 4
    .check(
      css("a:contains('#{searchComputerName}')", "href")
        .saveAs("computerUrl") // 5
    )
  )
  .pause(1)
  .exec(http("Select")
    .get("#{computerUrl}")) // 6
  .pause(1);
val feeder = csv("search.csv").random() // 1, 2

val search = exec(http("Home")
  .get("/"))
  .pause(1)
  .feed(feeder) // 3
  .exec(http("Search")
    .get("/computers?f=#{searchCriterion}") // 4
    .check(
      css("a:contains('#{searchComputerName}')", "href") // 5
        .saveAs("computerUrl")
    )
  )
  .pause(1)
  .exec(http("Select")
    .get("#{computerUrl}")) // 6
  .pause(1)
val feeder = csv("search.csv").random // 1, 2

val search = exec(http("Home")
  .get("/"))
  .pause(1)
  .feed(feeder) // 3
  .exec(http("Search")
    .get("/computers?f=#{searchCriterion}") // 4
    .check(
      css("a:contains('#{searchComputerName}')", "href") // 5
        .saveAs("computerUrl")
    )
  )
  .pause(1)
  .exec(http("Select")
    .get("#{computerUrl}")) // 6
  .pause(1)

Explanations:

  1. First we create a Feeder from a csv file with the following columns: searchCriterion, searchComputerName.
  2. As the default Feeder strategy is queue, we will use the random strategy for this test to avoid feeder starvation.
  3. Every time a user reaches the feed step, it picks a random record from the Feeder. This user has two new Session attributes named searchCriterion, searchComputerName.
  4. We use Session data through Gatling Expression Language to parameterize the search.
  5. We use a CSS selector check (also parameterized with Gatling Expression Language to capture a part of the HTML response, here a hyperlink, and save it in the user Session under the name computerUrl.
  6. We use the previously saved hyperlink to get a specific page.

Step 4: Looping

In the browse process we have a lot of repetition when iterating through the pages. We have four times the same request with a different query param value. Can we change this to not violate the DRY principle?

First we will extract the repeated exec block to a function. Indeed, Simulation’s are plain classes, so we can use all the power of the language if needed:

     
private static ChainBuilder gotoPage(int page) {
  return exec(http("Page " + page)
    .get("/computers?p=" + page))
    .pause(1);
}

ChainBuilder browse =
  exec(
    gotoPage(0),
    gotoPage(1),
    gotoPage(2),
    gotoPage(3),
    gotoPage(4)
  );
fun gotoPage(page: Int) =
  exec(http("Page $page")
    .get("/computers?p=$page"))
    .pause(1)

val browse =
  exec(
    gotoPage(0),
    gotoPage(1),
    gotoPage(2),
    gotoPage(3),
    gotoPage(4)
  )
def gotoPage(page: Int) =
  exec(http("Page " + page)
    .get("/computers?p=" + page))
    .pause(1)

val browse =
  exec(
    gotoPage(0),
    gotoPage(1),
    gotoPage(2),
    gotoPage(3),
    gotoPage(4)
  )

We can now call this function and pass the desired page number. But we still have repetitions, it’s time to introduce another builtin structure:

     
ChainBuilder browse =
  repeat(5, "n").on( // 1
    exec(http("Page #{n}").get("/computers?p=#{n}")) // 2
      .pause(1)
  );
val browse =
  repeat(5, "n").on( // 1
    exec(http("Page #{n}").get("/computers?p=#{n}")) // 2
      .pause(1)
  )
val browse =
  repeat(5, "n") { // 1
    exec(http("Page #{n}").get("/computers?p=#{n}")) // 2
      .pause(1)
  }

Explanations:

  1. The repeat builtin is a loop resolved at runtime. It takes the number of repetitions and, optionally, the name of the counter that’s stored in the user’s Session.
  2. As we set the counter name we can use it in Gatling EL and access the nth page.

Step 5: Check and failure management

Up until now we have only used check to extract some data from the html response and store it in the session. But check is also handy to check properties of the response. By default Gatling checks if the http response status is 20x or 304.

To demonstrate failure management we will introduce a check on a condition that fails randomly:

     
ChainBuilder edit =
  exec(http("Form").get("/computers/new"))
    .pause(1)
    .exec(http("Post")
      .post("/computers")
      .formParam("name", "computer xyz")
      .check(status().is(session ->
        200 + java.util.concurrent.ThreadLocalRandom.current().nextInt(2) // 2
      ))
    );
val edit =
  exec(http("Form").get("/computers/new"))
    .pause(1)
    .exec(http("Post")
      .post("/computers")
      .formParam("name", "computer xyz")
      .check(status().shouldBe { session ->
        200 + java.util.concurrent.ThreadLocalRandom.current().nextInt(2)
      })
    )
val edit =
  exec(http("Form").get("/computers/new"))
    .pause(1)
    .exec(http("Post")
      .post("/computers")
      .formParam("name", "computer xyz")
      .check(status.is(session =>
        200 + java.util.concurrent.ThreadLocalRandom.current.nextInt(2) // 2
      ))
    )

Explanations:

  1. First we import ThreadLocalRandom, to generate random values.
  2. We do a check on a condition that’s been customized with a lambda. It will be evaluated every time a user executes the request and randomly return 200 or 201. As response status is 200, the check will fail randomly.

To handle this random failure we use the tryMax and exitHereIfFailed constructs as follow:

     
ChainBuilder tryMaxEdit = tryMax(2).on( // 1
  exec(edit)
).exitHereIfFailed(); // 2
val tryMaxEdit = tryMax(2).on( // 1
  exec(edit)
).exitHereIfFailed() // 2
val tryMaxEdit = tryMax(2) { // 1
  exec(edit)
}.exitHereIfFailed // 2

Explanations:

  1. tryMax tries a given block up to n times. Here we try a maximum of two times.
  2. If all tries fail, the user exits the whole scenario due to exitHereIfFailed.

That’s all Folks!

Edit this page on GitHub