Gatling: Loops, Conditions and Pauses
This blog post is a guide to help you write Gatling scripts in order to load test web applications efficiently. It follows our second Gatling Simulation scripts parameterization article.
We will continue to load test a fake e-commerce, and so we are going to improve our Virtual User to make it browse the store in a more humanly way. To do it we will cover several topics:
- Loops to make it browse several articles of each category,
- Conditions to change its behavior depending on dynamic parameters,
- Pauses to simulate a real user think-time.
We start where the previous blog post ended, with a simulation script that uses a CSV feeder and a Regular Expression extractor to visit dynamic pages of the pet store: Download Sample Script.
Loops¶
In computer science, a loop is a control flow statement for specifying iteration, which allows code to be executed repeatedly. Various keywords are used to specify this statement in Gatling Simulations:
- For Each to iterate over the values of a Set,
- Repeat to execute HTTP requests a given number of times,
- During to repeatedly execute code for a certain duration,
- As well as several other loops.
Warning:
Gatling Simulations are written using the Scala programming language but use a dedicated DSL. You must use specific DSL components like the
.forEach()
or.doIfOrElse()
for loops and conditions instead of nativeif
orforeach
expressions.
We saw in the previous blog post how to extract values from a CSV File using a Gatling Feeder. Now let's start with the For Each loop to iterate over the values of this CSV Feeder.
For Each Loop¶
Previously we loaded the CSV File as a Feeder: val csvFeeder = csv("two/categories.csv").random
.
It's some kind of iterator that puts the values one by one in the session when we use the .feed
keyword.
Now want to loop over the complete categories.csv file values.
And the .foreach
DSL component takes a Sequence in parameter, not a Feeder.
So we need to load the complete file records with the readRecords
statement:
val csvRecords = csv("three/categories.csv").readRecords
Then, for more clarity, we declare separate variables to store the requests to the Category page and to the Product page:
val categoryRequest = http("Catalog ${categoryId}")
.get("/actions/Catalog.action")
.queryParam("viewCategory", "")
.queryParam("categoryId", "${categoryId}")
.check(regex("""productId=(.*)"""").findRandom.saveAs("productId"))
val productRequest = http("Product ${productId}")
.get("/actions/Catalog.action")
.queryParam("viewProduct", "")
.queryParam("productId", "${productId}")
We declared the categoryRequest such that it needs a categoryId value in the session:
The "${categoryId}"
syntax uses Expression Language to directly fetch the value from the session.
Let's see how we inject the category IDs:
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.exec(productRequest)
}
Here the .foreach
statement takes the csvRecords variable in parameter.
This sequence is loaded only once when the test starts and stored in a variable.
The second parameter is the name of the current value. As the value is stored in the Gatling session, you can load it with the syntax session("category")
or more easily with Expression Language "${category}"
.
Note:
You may also want to loop over a dynamic value. Remember that everything that is dynamic in Gatling is stored in the Session.
In such case you would pass the key of the values Set where it is stored in the Session. For instance, if a previous request execution has saved a Set of values with
.check(regex("""categoryId=(.*)"""").findAll.saveAs("categoryIds")
, you can iterate over it with theforeach("categoryIds", "categoryId") {}
statement.
The readRecords
operator load the entire CSV file as a Sequence of Maps (one map per line).
Our categories.csv file only contains one column categoryId. So the generated maps only contain one entry with the key categoryId and value changing from DOGS, CATS, etc.
Another dedicated Gatling keyword - flattenMapIntoAttributes
- extracts this categoryId
entry in the Gatling Session, allowing us to use it directly within Expression Language thereafter: "${categoryId}"
.
The complete script for this For Each DSL component is downloadable here.
Repeat Loop¶
Now that we loop over the categories, it would be nice to iterate over the products.
For the sake of this tutorial we will do it using the .repeat
loop.
Currently, only one product ID is extracted from the server response of the Category page.
To extract all the product IDs, we must configure the Regular Expression extractor with the .findAll
option:
val categoryRequest = http("Catalog ${categoryId}")
.get("/actions/Catalog.action")
.queryParam("viewCategory", "")
.queryParam("categoryId", "${categoryId}")
.check(regex("""productId=(.*)"""").findAll.saveAs("productIds"))
We also changed the .saveAs
statement to store the extracted value in the session "productId**s**" entry instead of "productId" since it is now a list of IDs.
Let's have a look at the .repeat
syntax:
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.repeat(session => session("productIds").as[List[Any]].size, "productIndex"){
exec(session => {
val productIds = session("productIds").as[List[Any]]
val productIndex = session("productIndex").as[Int]
session.set("productId", productIds(productIndex))
})
.exec(productRequest)
}
}
for
in Java: the first parameter is the number of iterations and the second one is the counter name (the value is automatically injected in the Session).
We can see in the sample code above that an exec(session => {})
statement is used to:
- Get the product Ids list from the session
val productIds = session("productIds").as[List[Any]]
, - Get the repeat loop productIndex counter current value
val productIndex = session("productIndex").as[Int]
, - Put the current product ID in the session:
session.set("productId", productIds(productIndex))
.
This statement only modifies the session. No HTTP request is sent here. But it is followed by the execution of the productRequest that get the proper Product page using the productId. The complete script is available here.
Using Expression Language¶
Having to manipulate the Session is a bit cumbersome here. There is a simpler way to do it using advanced Expression Language!
Simply remove the exec(session => {})
statement from the repeat loop and update the productRequest to directly use the productIndex:
val productRequest = http("Product ${productIds(productIndex)}")
.get("/actions/Catalog.action")
.queryParam("viewProduct", "")
.queryParam("productId", "${productIds(productIndex)}")
"${productIds(productIndex)}"
returns the element of the productIds at the position productIndex (starting from 0 like in any other programming language).
The updated script is available here.
There are many possibilities with EL:
Sample | Description |
---|---|
"${productIds.size()}" |
Returns the number of elements in the productIds list. |
"${productIds.random()}" |
Returns a random element of the productIds list. |
"${productId.exists()}" |
Returns true if the productId key is present in the session (Use "${productId.isUndefined()}" to check that it is NOT present). |
"${productIds(2)}" |
Just like the example above but we can also use a static index. |
"${category.categoryId}" |
If Category is a map, return the value for the given categoryId key |
During Loop¶
Until now we iterated over a sequence of values (ids or indexes alike). The During loop allows you to iterate for a specified amount of time.
The syntax is during(duration, counterName, exitASAP) { exec(...) }
:
- The first parameter duration is expressed like
1500 milliseconds
or5
for 5 seconds, - The second parameter counterName is optional and is the name of the counter (Integer incremented by one for each iteration) that is injected in the Session,
- The last parameter exitASAP defaults to true meaning that the loop will exit as soon as the duration is reached, even if several actions are to be executed to complete the current iteration (that can be an issue if the execution of all requests in the loop is required by actions coming after the during loop).
Let's update our script to use such loop instead of the repeat (Complete Script:
val productRequest = http("Product ${productId}")
.get("/actions/Catalog.action")
.queryParam("viewProduct", "")
.queryParam("productId", "${productId}")
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.during(100 milliseconds, "productCounter") {
exec(session => {
val productIds = session("productIds").as[List[Any]]
val productCounter = session("productCounter").as[Integer]
val productIndex = productCounter % productIds.size
session.set("productId", productIds(productIndex))
})
.exec(productRequest)
}
}
Here we loop during 100 milliseconds and set the counter name to productCounter. The first exec updates the Session by computing the current product ID using the counter and the modulo of the product IDs list size: we will iterate over each product sequentially during 100 milliseconds.
Note:
Our during loop only executes for 100ms because no pauses are configured on the scenario. Using a longer loop duration would generate too many requests for an easy debugging of the script. Once pauses or pacing are added, the duration of the loop should be increased accordingly.
What if we want to go to a random product page on each iteration? There are two solutions here.
The first solution is to shuffle the productIds list beforehand using a transform
:
val categoryRequest = http("Catalog ${categoryId}")
.get("/actions/Catalog.action")
.queryParam("viewCategory", "")
.queryParam("categoryId", "${categoryId}")
.check(regex("""productId=(.*)"""").findAll.transform(productIds => util.Random.shuffle(productIds)).saveAs("productIds"))
Here we update the categoryRequest to apply transform(productIds => util.Random.shuffle(productIds))
on the extracted list.
The drawback of this solution is that the shuffling is only done once. So the Virtual User will loop over the same sequence.
The second best solution is to update the productId computing part to use a random number generator (Download script here:
exec(session => {
val productIds = session("productIds").as[List[Any]]
val productIndex = util.Random.nextInt(productIds.size)
session.set("productId", productIds(productIndex))
})
util.Random.nextInt(max)
returns a random Integer that is equal or superior to 0 and strictly inferior to max.
Other Loops¶
Several other loops are available in Gatling:
asLongAs(condition, counterName, exitASAP) { exec(...) }
iterates over the loop as long as the condition is true (sort of equivalent of the while loop but the condition can be evaluated for each contained request with the exitASAP parameter),doWhile(condition, counterName) { exec(...) }
same as the asLongAs but the condition is evaluated after the execution of all the contained requests,asLongAsDuring(condition, duration, counterName) { exec(...) }
mix of the asLongAs and during loops,doWhileDuring(condition, duration, counterName) { exec(...) }
mix of the doWhile and during loops,forever(counterName) { exec(...) }
iterates forever.
Conditions¶
In computer science, a conditional statement is performs different actions depending on whether a specified boolean condition evaluates to true or false. Various keywords are used to specify this statement in Gatling Simulations:
- doIfOrElse to execute some actions when the condition is true and some other actions when it is false,
- doSwitchOrElse to switch the sub-chain execution based on a key equivalence evaluation,
- As well as several other conditions.
For the purpose of this tutorial, we are going to simulate a different user behavior based on the category visited. To do so we are going to create two execution chains.
The first one simulates a really interested visitor that will look at each product of the current category. So we create a foreach loop that sequentially make a request to each product:
val sequentialProducts = foreach(session => session("productIds").as[List[Any]], "productId") {
exec(productRequest)
}
The second one simulates a less assiduous visitor that only checks a random product and leaves. It is done by extracting one random product Id from the list of productIds present in the session before executing one single productRequest.
val randomProduct = exec(session => {
val productIds = session("productIds").as[List[Any]]
val productIndex = util.Random.nextInt(productIds.size)
session.set("productId", productIds(productIndex))
})
.exec(productRequest)
If Or Else¶
Let's start by simulating the behavior of someone looking for a dog. He opens the Dogs category page and look at every pet available. Out of curiosity he will check one random pet from every other category.
The corresponding script (download here) is as follows:
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.doIfOrElse(session => session("categoryId").as[String].equals("DOGS")) {
sequentialProducts
} {
randomProduct
}
}
The doIfOrElse statement takes a function in parameter that must return a boolean: (session: Session) => boolean
.
This function evaluates a condition using dynamic information from the session and returns true or false.
Here it checks if the categoryId is equal to DOGS.
If the value is true then the sequentialProducts execution chain declared previously is executed.
The randomProduct one is executed otherwise.
Note:
The execution chain between the first pair of curly braces
{}
is executed when the condition is true. The one between the second pair is executed when its false. It is not mandatory to declare the execution chains in dedicated variables. It is just cleaner IMHO. For instance, you could have writtendoIfOrElse(session => session("categoryId").as[String].equals("DOGS")) { exec(http("True request")...) } { exec(http("False request")...) }
.
Switch Statement¶
Now we simulate the behavior of a visitor that is looking for a pet for his children. He is not decided between a dog or a cat. So he browses all dogs and then all cats from the store. Out of curiosity he will check one random pet from every remaining category.
Here is the corresponding script (download here):
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.doSwitchOrElse("${categoryId}")(
"DOGS" -> sequentialProducts,
"CATS" -> sequentialProducts
) (
randomProduct
)
}
This time we use a doSwitchOrElse statement. It takes a string in parameter that is evaluated as the current Category ID thanks to Expression Language: "${categoryId}"
.
The first pair of parenthesis (not curly braces here!) contains the pairs of matching values/execution chains.
The second pair of parenthesis contains the execution chain of actions that will be executed if none of the keys matched the current value.
Other Conditions¶
Several other conditional statements are available in Gatling DSL:
doIf("${myCondition}") { exec(...) }
executes the chain of actions between curly braces only if the session's myCondition entry is true (the condition can also be a function that takes the Session as a parameter and returns a boolean like we saw in the doIfOrElse condition),doIfEquals("${actualValue}", "expectedValue") { exec(...) }
same as above but the session's actualValue must be equal to the expectedValue for the action chain to be executed (doIfEqualsOrElse
is also available),doSwitch
just like the doSwitchOrElse but nothing is executed if no key matching is found,randomSwitch
uses percentages and a randomly generated number to select the executed action chain (randomSwitchOrElse
,uniformRandomSwitch
androundRobinSwitch
also exist).
Pauses¶
Both conditions and loops helped us create a realistic load testing scenario. But real users think before they click! As we did not add any form of think-time when writing our scripts, executing them will simulate far too many request for a realistic load (given a fixed number of concurrent users).
This chapter explains the various possibilities offered by Gatling to simulate pauses:
- How to configure static pauses on the scenario,
- How to parameterize pauses on the scenario or globally,
- How to use pacing.
Scenario Pauses Configuration¶
Let's start by updating our script to add a fixed pause statement after each request (Download Script):
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.pause(5)
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.pause(1500 milliseconds)
.foreach(session => session("productIds").as[List[Any]], "productId") {
exec(productRequest)
.pause(1 seconds, 2 seconds)
}
}
The .pause()
Gatling DSL component takes a duration in parameter:
.pause(5)
waits for 5 seconds (default duration unit),.pause(1500 milliseconds)
waits for 1500ms,.pause(1 seconds, 2 seconds)
waits between one and two seconds (random ranged duration).
That's perfect for simulating realistic users at runtime, but it's annoying to have to wait for the longer script executing when debugging ...
Pauses Parameterization¶
The idea is to give different parameters to our script (environment variables) when running/debugging it in order to configure the delays.
If you run Gatling directly, this can by done by updating the JAVA_OPTS environment variable: JAVA_OPTS="-DDELAY=500"
.
Directly on the Scenario¶
The first option to parameterize think-times is to uses variables in directly in the .pause
statements of the scenario.
But first we need to inject the environment variable into our script.
Integer env variables can be retrieved with the following syntax:
val delay = Integer.getInteger("DELAY", 500)
val doubleDelay = 2*delay
val delay = Integer.getInteger("DELAY", 500)
fetches the DELAY environment variable and places its value in the delay val.
If the env variable is not defined, the 500 default value is used.
val doubleDelay = 2*delay
simply computes the double of this delay.
Using these injected parameters in the script is pretty simple. We just need to replace the static values by our created values delay and doubleDelay:
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.pause(delay milliseconds)
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.pause(delay milliseconds)
.foreach(session => session("productIds").as[List[Any]], "productId") {
exec(productRequest)
.pause(delay milliseconds, doubleDelay milliseconds)
}
}
All pauses are now using durations in milliseconds. Passing 0 as the DELAY environment variable will completely deactivate think times. Updating it allows us to configure the delays on demand.
The complete script is available here.
Note:
If you need dynamic pauses, you can use a session function in parameter:
.pause(session => session("dynamicPause").as[Duration])
.
Globally on the SetUp¶
The second option to parameterize think-times is to do it on the simulation setUp
.
This time the DELAY env variable is injected as a Long value, with the following syntax (don't forget the .toLong
statement at the end, used to convert a Java Long into a Scala Long value):
val delay = java.lang.Long.getLong("DELAY", 500L).toLong
Using a Long is mandatory because the .customPauses(session => delay)
DSL component requires a Long value:
setUp(scn.inject(atOnceUsers(1)))
// .disablePauses
.customPauses(session => delay)
.protocols(httpProtocol)
It takes a function in parameter, with the following signature: (session: Long) => Long
.
This lets you return dynamic pauses depending on the Gatling session state.
Passing 0 as the DELAY environment variable will also completely deactivate think times here.
A quicker option is to use the .disablePauses
setting on the setUp.
The complete script is available here.
Note:
To avoid synchronicity issues during your load tests it's a good idea to introduce randomness in your script pauses. Gatling has dedicated configurations that can be put on the
setUp
: exponentialPauses, normalPausesWithStdDevDuration, normalPausesWithPercentageDuration, and uniformPausesPlusOrMinusPercentage.
Pacing¶
To complete this chapter about think-times we are about to talk about Pacing.
Pacing usually refers to the time between the iterations of your virtual users.
This is unlike the think-time (.pause()
) which refers to the delay between individual actions.
Pacing allows the load test to be even more realistic and simulate the time gap between two user sessions.
In Gatling, the pacing is not configured at the iteration level but inside a loop, any loop.
Let's update our script to use the dedicated keyword pace(duration)
inside a 10 seconds duration loop (Download script):
val scn = scenario("PetStoreSimulation")
.exec(http("Homepage").get("/actions/Catalog.action"))
.pause(500 milliseconds)
.foreach(csvRecords, "category") {
exec(flattenMapIntoAttributes("${category}"))
.exec(categoryRequest)
.pause(500 milliseconds)
.during(10 seconds) {
pace(1 seconds)
.exec(session => {
val productIds = session("productIds").as[List[Any]]
val productIndex = util.Random.nextInt(productIds.size)
session.set("productId", productIds(productIndex))
})
.exec(productRequest)
}
}
You can see that there is no pause after the .exec(productRequest)
statement in the script above.
Notes:
Use a forever loop that encapsulate your scenario if you want to apply iteration pacing.
The
pace()
DSL component can be configured with durations like the.pause()
one: You can pass it a fixed number (default time unit is seconds), a duration like 100 milliseconds, a range of durations, etc.
Conclusion¶
We have only scratched the surface of what Gatling is capable of. So keep posted as other blog posts are coming to help you master Gatling scripting language.
Feel free to share this guide if you found it useful!