skip to content

In Gatling, why does calling .exec(...) on a stored chain have no effect unless you keep the value it returns?

level: middleimportance: must knowfreq 60%

answer

  1. Builders never change in place
  2. Every call hands back a new builder
  3. Discarding the return value discards the step
  4. Compose by rebinding, not by appending

basics

~20 s

Gatling's builders are immutable. Every exec, group or loop call returns a brand-new builder and leaves the receiver untouched, so a call whose result you discard is a no-op. Keep the returned value, or chain in one expression.

solid answer

~40 s

`ScenarioBuilder` and `ChainBuilder` are final, immutable types — their own API documentation says that every method returns a new occurrence and leaves the original unmodified. Internally each call builds a fresh list of action builders rather than appending to one. So `browse.exec(x);` on a line of its own compiles fine, does nothing, and is one of the classic Gatling bugs: the extra step is built and thrown away. You must write `browse = browse.exec(x)`, assign to a new value, or keep the calls in a single chained expression. The same rule holds for the `Session`, for request builders and for protocol builders — most Gatling SDK components are immutable by design.

code

java · 19 lines
java
import io.gatling.javaapi.core.*;

import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;

class ImmutabilityExample {
  {
    ChainBuilder browse = exec(http("Home").get("/"));

    // no-op: a new chain is built and immediately discarded
    browse.exec(http("Category").get("/books"));

    // correct: keep what exec handed back
    ChainBuilder browseThenCategory =
        browse.exec(http("Category").get("/books"));

    ScenarioBuilder shopper = scenario("Shopper").exec(browseThenCategory);
  }
}

go deeper

for a junior

Be ready to recall that exec returns a new builder, and that you must assign or chain the result for the step to exist.

for a middle

Be ready to explain the mechanism — a fresh action list per call — and to demonstrate the silent no-op it produces.

for a senior

Be ready to say how you catch this in review: unused-result warnings on test sources, and loops that fail to assign back to the builder.

for a principal

Be ready to argue why an immutable builder API is worth its ceremony in a shared test codebase, and where the cost lands.

Everything in Gatling's DSL is a value that describes a load test, and none of those values can be changed after construction. That single design decision explains a whole family of "my step never ran" bugs, and it is why interviewers use it to test whether a candidate really understands builder APIs. ## What immutability means here `ChainBuilder` and `ScenarioBuilder` are both `final` classes wrapping an immutable list of action builders. Their documentation states it plainly: *immutable, so all methods return a new occurrence and leave the original unmodified*. When you call `.exec(...)`, Gatling constructs a **new** builder whose action list is the old one plus the new steps. The receiver is not touched, and it cannot be. So these two lines are not equivalent: ```java ChainBuilder browse = exec(http("Home").get("/")); // (a) no-op — a second chain is built and immediately discarded browse.exec(http("Category").get("/books")); // (b) correct — the new chain is kept ChainBuilder browseThenCategory = browse.exec(http("Category").get("/books")); ``` After (a), `browse` still holds exactly one action. Nothing warns you: the expression is well typed, the compiler is satisfied, and the simulation runs — just without the category request. In a fifty-step scenario that is a genuinely hard bug to see by reading. ## The shape of the mistake The mistake almost always comes from expecting a **mutable collection** API. `list.add(x)` changes `list`; `browse.exec(x)` does not change `browse`. Programmers who have internalised `String` behaviour in Java or Kotlin already have the right instinct: `s.trim()` alone accomplishes nothing. The same trap shows up in a loop: ```java ChainBuilder chain = ChainBuilder.EMPTY; for (String path : paths) { chain = chain.exec(http(path).get(path)); // the assignment is essential } ``` Drop the `chain =` and the loop builds and discards a chain per iteration. ## Why Gatling chose this Three consequences follow from immutability, and all three are deliberate: 1. **Chains are safely shareable.** Because no caller can alter a `ChainBuilder`, one `browse` constant can be used by a shopper scenario, an admin scenario and a smoke scenario in the same simulation without any of them affecting the others. Reuse is free. 2. **Order of construction does not matter.** A chain built in a static initialiser is exactly the chain that will be used later; nothing between construction and `setUp` can mutate it out from under you. 3. **Definitions are separable from effects.** Gatling components are *definitions*, not effects. Building one has no side effect, so building one and throwing it away also has no side effect — which is precisely why the no-op above is silent. ## Helper methods that quietly do nothing The same bug hides very well inside a helper method: ```java // wrong: the caller's chain is left exactly as it was static void addLogout(ChainBuilder chain) { chain.exec(http("Logout").get("/logout")); } // right: hand the new chain back to the caller static ChainBuilder withLogout(ChainBuilder chain) { return chain.exec(http("Logout").get("/logout")); } ``` A `void` method that takes a builder is almost always wrong in Gatling, for exactly this reason. If a method's job is to extend a chain, its return type has to be the chain. The same applies to a method that takes a `ScenarioBuilder` and is supposed to add steps to it. ## Where else the rule applies | component | immutable? | consequence | |---|---|---| | `ChainBuilder` / `ScenarioBuilder` | yes | `.exec(...)` returns a new builder | | request builders such as `http("Home").get("/")` | yes | `.header(...)` returns a new request builder | | `Session` | yes | `session.set("k", "v")` returns a new session you must return | | protocol builders | yes | configured once, then shared across scenarios | Gatling's own glossary makes the general statement: most SDK components, `Session` included, are immutable, so you cannot update an existing instance — you only generate new instances carrying the change. ## How to spot it in review - Any statement whose whole body is a builder expression, on its own line, with the result unused. In Java and Kotlin this is what an IDE flags as a "result of the call is not used" warning; turn that warning on for test sources. - Any loop that touches a builder without assigning back to it. - Any helper method that takes a builder, calls `.exec(...)` on it and returns `void`. ## The interview answer in one line Gatling's builders are persistent values, so composing is always *rebinding*, never *appending*. If you did not keep what `exec` handed back, you did not add the step.

  • Does the same rule apply to a Gatling HTTP request builder?
    Yes. `http("Home").get("/")` produces an immutable request builder, so `.header("accept", "application/json")` returns a new builder and leaves the original alone. Gatling's glossary states the rule for SDK components generally, `Session` included: you never update an instance, you derive a new one.
  • Why can two Gatling scenarios share the same chain value without interfering?
    Because nothing can mutate it. Each scenario's `exec` copies the chain's action builders into its own new list, so the shared value is only ever read. That is what makes a library of named chains safe to reuse across every simulation in a project.

It behaves like a Java String. Calling s.trim() on its own line changes nothing; you have to keep the string it hands back.

saying these in an interview costs you the question

  • Treating .exec() like a mutating add() on a list
  • Expecting repeated calls on one variable to accumulate steps
  • Assuming a shared chain can be modified by one scenario
  • Blaming the engine when a discarded step never runs