QA How-To
Gatling Java DSL Tutorial for Testers
Gatling Java DSL tutorial for testers: build a local API load test with checks, token correlation, CSV feeders, arrival profiles, assertions, and reports.
24 min read | 3,117 words
TL;DR
Clone Gatling's Java Maven starter, run a local API, and add simulations that check responses, correlate an auth token, and feed CSV data. Run smoke and open-model load profiles, then use assertions and the HTML report to judge the result.
Key Takeaways
- Use the official Java Maven starter and its wrapper to keep SDK and plugin versions aligned.
- Start with one virtual user and check both HTTP status and JSON content.
- Save login data with JSONPath and reuse it through the virtual user's session.
- Put CSV feeder files on the test classpath and choose reuse behavior deliberately.
- Distinguish arrival rate from request rate when reading an open-model report.
- Gate CI on Gatling assertions and archive the HTML report with run parameters.
A Gatling Java DSL tutorial should end with a test you can run, inspect, and fail on purpose. Here you will use Gatling's Java SDK to exercise a small local catalog API, first with one virtual user and then with a controlled arrival rate. You will check HTTP responses, carry a login token through a virtual user's session, feed product IDs from CSV, and set run-level acceptance criteria.
The local API is part of the exercise. It removes remote service availability, shared accounts, and changing test data from the first run. After you understand the script, point the same patterns at an environment you own and choose traffic and thresholds from that system's requirements. If you want a broader map before coding, read Gatling basics for testers.
TL;DR
| Piece | What it proves | Where you inspect it |
|---|---|---|
| Request check | An individual HTTP response matches its contract | OK/KO counts and request details |
| Saved token | A later request uses data from an earlier response | Authenticated request succeeds |
| CSV feeder | Virtual users can select input records | Both product paths appear in server traffic |
| Injection profile | Users start according to the chosen model | Active users and requests per second charts |
| Global assertion | The whole run meets an acceptance rule | Maven exit code and assertion summary |
Gatling is an HTTP load generator, not a browser that executes page JavaScript. The official HTTP protocol reference makes that scope clear. Use browser tooling separately if the question is rendering or client-side interaction time.
What You Will Build
- A Python standard-library server with health, product, login, and authenticated order endpoints.
- A Java
Simulationthat makes a checked catalog request through Gatling's HTTP DSL. - A second scenario that extracts a login token and sends it in an authorization header.
- A CSV-backed journey with a smoke profile and a short open-model load profile.
- An HTML report and a failing process exit when the run violates its assertions.
The credentials and token in this local server are demonstration values. They never grant access to a real service. Each virtual user gets its own Gatling Session, so the saved token belongs to that user's journey. The server returns fixed data and supports concurrent requests, which keeps early failures attributable to the script or the local environment.
Prerequisites
Use a 64-bit JDK 17 and Python 3.12 for these commands. Gatling's Java installation guide supports JDK 17 and documents the Maven starter. The starter includes a Maven Wrapper; run its ./mvnw instead of guessing a Maven or Gatling plugin version. The exact Gatling dependency versions come from the starter's checked-out pom.xml. If your organization pins a different released Gatling version, keep the SDK and plugin versions compatible with that project.
java -version
python3.12 --version
git --version
Expect Java to report major version 17 and Python to report 3.12. On Windows, use mvnw.cmd for each Maven command and py -3.12 if that is your Python launcher. The examples below use a POSIX shell. You also need Git, a terminal that can reach the Maven dependency repository on the first build, and an unused loopback port 8089. curl is used only for quick server checks.
Do not run a meaningful load against a public demo host or a production API without its owner's agreement. The numbers in this tutorial are illustrative local settings, not a capacity claim or recommended service-level objective. For workload vocabulary and test planning, see the load testing guide.
Step 1: Set Up the Gatling Java DSL Tutorial Project
Clone Gatling's maintained Java Maven demo. It supplies a working pom.xml, wrapper, and source layout, so the commands do not depend on a fabricated dependency version. The clone is your scratch project for this walkthrough, separate from your application repository.
git clone https://github.com/gatling/gatling-maven-plugin-demo-java.git
cd gatling-maven-plugin-demo-java
./mvnw -version
./mvnw -q test-compile
Verify: the wrapper prints a Maven version and Java 17, then test-compile exits successfully. On the first invocation, Maven downloads dependencies and may take longer. If it fails before Java compilation, inspect network and repository access before changing any simulation code. The official Java demo repository contains the layout used here.
Place the Java files in src/test/java/tutorial/. Their package tutorial; declaration must match that directory. Place feeder data in src/test/resources/, where Gatling loads it from the classpath. We will select each simulation by its fully qualified class name, avoiding the interactive picker when the demo and your new classes coexist. Do not name a simulation class TestSomething: ordinary Maven test tools can mistake it for a unit test, as Gatling's simulation guide explains.
Before adding traffic, decide what one user should do: fetch a product, log in, and fetch an order. That sequence is a business journey, while the arrival profile controls how often new users start it. Keeping those concepts separate makes the script easier to review and gives the report meaningful request names. A scenario with only a successful TCP connection would miss an incorrect JSON body or broken authorization flow.
Step 2: Start a Predictable Local HTTP API
Create local-api/server.py in the cloned project. Python's ThreadingHTTPServer handles concurrent virtual users without adding a web framework. This server deliberately has no database or mutable inventory, so a second run starts with the same product IDs. Its /login response contains a token that the next step must capture.
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
class Handler(BaseHTTPRequestHandler):
def send_json(self, status, value):
payload = json.dumps(value).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
if self.path == "/health":
self.send_json(200, {"status": "ok"})
elif self.path in ("/products/1", "/products/2"):
product_id = int(self.path.rsplit("/", 1)[1])
self.send_json(200, {"id": product_id, "name": "Item " + str(product_id)})
elif self.path == "/orders":
if self.headers.get("Authorization") != "Bearer local-token":
self.send_json(401, {"error": "unauthorized"})
else:
self.send_json(200, {"items": [{"id": 101, "productId": 1}]})
else:
self.send_json(404, {"error": "not found"})
def do_POST(self):
if self.path != "/login":
self.send_json(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length", "0"))
try:
data = json.loads(self.rfile.read(length))
except (ValueError, UnicodeDecodeError):
self.send_json(400, {"error": "invalid JSON"})
return
if data == {"username": "qa", "password": "demo"}:
self.send_json(200, {"token": "local-token"})
else:
self.send_json(401, {"error": "invalid credentials"})
if __name__ == "__main__":
ThreadingHTTPServer(("127.0.0.1", 8089), Handler).serve_forever()
Run mkdir -p local-api, save the file, then start it in a second terminal from the project root. Leave that process running while you execute Gatling in the first terminal.
python3.12 local-api/server.py
Verify: from the first terminal, curl -i http://127.0.0.1:8089/health should return 200 and {"status": "ok"}. curl -i http://127.0.0.1:8089/orders should return 401. The second response is intentional: an unauthenticated order request is a useful negative control. If the server says the address is already in use, stop the other process bound to 8089 or change both the server port and every baseUrl in the Java examples.
This tiny service is a test fixture, not a performance benchmark. Its Python handler, local socket, and your laptop's scheduler affect observed latency. Use it to prove that the Gatling mechanics work; use a representative test environment to answer capacity questions. The distinction matters when someone tries to turn a small local p95 into an application-wide performance promise.
Step 3: Send and Check the First Request
Create src/test/java/tutorial/BasicCatalogSimulation.java. A Simulation supplies one HTTP protocol, one scenario, and one user injection. The status check tests the response code, while the JSONPath checks make sure the expected product data arrived. Without explicit checks, a request can look successful at the transport level while returning the wrong business payload.
package tutorial;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
public class BasicCatalogSimulation extends Simulation {
HttpProtocolBuilder protocol = http
.baseUrl("http://127.0.0.1:8089")
.acceptHeader("application/json");
ScenarioBuilder catalog = scenario("Read one product")
.exec(http("Get product 1")
.get("/products/1")
.check(status().is(200))
.check(jsonPath("$.id").is("1"))
.check(jsonPath("$.name").is("Item 1")));
{
setUp(catalog.injectOpen(atOnceUsers(1)))
.protocols(protocol)
.assertions(global().failedRequests().count().is(0L));
}
}
./mvnw gatling:test -Dgatling.simulationClass=tutorial.BasicCatalogSimulation
Verify: Gatling should show one started user, one completed user, one OK request, zero KO requests, and a successful global assertion. Look under target/gatling/ for a timestamped run directory and index.html. If the process exits with a connection error, check the separate server terminal before changing a check. If the server is up but the $.name check fails, inspect its JSON contract and the expected value.
The HTTP request name, Get product 1, becomes the report grouping key. Keep names stable across runs so comparisons remain meaningful. atOnceUsers(1) is a smoke run, not a load test: it establishes that the compiled DSL, endpoint, and response checks agree. The Gatling checks reference describes how failed checks mark requests KO and how JSONPath can extract values for later steps.
Step 4: Correlate a Login Response with an Order Request
Create src/test/java/tutorial/AuthenticatedJourneySimulation.java. This scenario first posts credentials to the local fixture, saves $.token into the current virtual user's session, and interpolates that value in the next request's Authorization header. Gatling's expression syntax is #{authToken}. The StringBody text is JSON, and .asJson() sets appropriate JSON headers.
package tutorial;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
public class AuthenticatedJourneySimulation extends Simulation {
HttpProtocolBuilder protocol = http.baseUrl("http://127.0.0.1:8089");
ScenarioBuilder journey = scenario("Login then read orders")
.exec(http("Login")
.post("/login")
.asJson()
.body(StringBody("{\"username\":\"qa\",\"password\":\"demo\"}"))
.check(status().is(200))
.check(jsonPath("$.token").saveAs("authToken")))
.exec(http("Read authorized orders")
.get("/orders")
.header("Authorization", "Bearer #{authToken}")
.check(status().is(200))
.check(jsonPath("$.items[0].id").is("101")));
{
setUp(journey.injectOpen(atOnceUsers(1)))
.protocols(protocol)
.assertions(global().failedRequests().count().is(0L));
}
}
./mvnw gatling:test -Dgatling.simulationClass=tutorial.AuthenticatedJourneySimulation
Verify: the console and report should contain two OK requests, Login and Read authorized orders, and no KO requests. To check that authorization is doing real work, compare this result with the earlier unauthenticated curl response of 401. The token is a local fixture value; do not copy real secrets into simulation source or a CSV feeder. For a real identity service, source credentials from an approved secret mechanism and avoid logging them.
A saved check value lives in Gatling's Session, which is specific to one virtual user. Another user cannot accidentally inherit the token through a shared Java field. This is why correlation belongs in a check followed by expression substitution, instead of a mutable static variable. If login fails, the next request may also fail because authToken was never saved; diagnose the first KO request rather than the last exception. The session expression language reference documents the #{...} form.
Step 5: Feed Product IDs and Define Acceptance Criteria
Create src/test/resources/productIds.csv with two rows. The header becomes the session attribute name productId; each row supplies a value. A circular feeder reuses those rows when more than two virtual users arrive. That is appropriate for read-only catalog IDs, but dangerous for unique signup emails or order IDs. Use a different feeder strategy and enough distinct records for state-changing traffic.
productId
1
2
Now create src/test/java/tutorial/FeederJourneySimulation.java. It combines product lookup, correlation, pacing, and run-level assertions. profile=smoke is the default; the load branch is selected explicitly in the next step. The 3,000 ms percentile threshold is an illustrative local guardrail. Replace it with a threshold from your actual service objective when moving to a real environment.
package tutorial;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
public class FeederJourneySimulation extends Simulation {
HttpProtocolBuilder protocol = http.baseUrl("http://127.0.0.1:8089");
ScenarioBuilder journey = scenario("Catalog and orders")
.feed(csv("productIds.csv").circular())
.exec(http("Get selected product")
.get("/products/#{productId}")
.check(status().is(200))
.check(jsonPath("$.id").exists()))
.pause(1)
.exec(http("Login")
.post("/login")
.asJson()
.body(StringBody("{\"username\":\"qa\",\"password\":\"demo\"}"))
.check(status().is(200))
.check(jsonPath("$.token").saveAs("authToken")))
.exec(http("Read authorized orders")
.get("/orders")
.header("Authorization", "Bearer #{authToken}")
.check(status().is(200))
.check(jsonPath("$.items[0].id").is("101")));
{
String profile = System.getProperty("profile", "smoke");
int users = Integer.getInteger("users", 10);
int seconds = Integer.getInteger("seconds", 10);
double rate = Double.parseDouble(System.getProperty("rate", "2"));
if (users < 1 || seconds < 1 || rate <= 0) {
throw new IllegalArgumentException("users, seconds, and rate must be positive");
}
PopulationBuilder population = "load".equals(profile)
? journey.injectOpen(
rampUsers(users).during(seconds),
constantUsersPerSec(rate).during(seconds))
: journey.injectOpen(atOnceUsers(1));
setUp(population)
.protocols(protocol)
.assertions(
global().failedRequests().count().is(0L),
global().responseTime().percentile3().lt(3000));
}
}
./mvnw gatling:test -Dgatling.simulationClass=tutorial.FeederJourneySimulation
Verify: the smoke profile should show one user and three OK requests. The percentile assertion should pass on an idle local machine; if it fails, examine the report before widening the threshold. The default percentile3 is the 95th percentile unless changed in Gatling configuration, per the assertions reference. A sample of three requests is too small to make a useful latency claim. This run proves the wiring, not the statistical stability of a percentile.
The .pause(1) call inserts one second of think time after product browsing. That prevents a single user from issuing the three calls in a zero-delay burst. Pacing belongs in the journey because real users do not instantly complete every transition. A fixed one-second pause is only a starting example; measure representative user behavior before using it for capacity planning. The feeder reference explains classpath file placement and circular behavior.
Step 6: Run a Small Open-Model Load Profile
Keep the Python server running. Use the same FeederJourneySimulation class and choose profile=load. rampUsers(10).during(10) distributes ten new users over ten seconds. After that, constantUsersPerSec(2).during(10) starts two users each second for ten seconds. Each user executes the whole three-request journey, so users per second and requests per second are different quantities.
./mvnw gatling:test -Dgatling.simulationClass=tutorial.FeederJourneySimulation -Dprofile=load -Dusers=10 -Dseconds=10 -Drate=2
Verify: expect about 30 started users, because the ramp contributes 10 and the steady phase contributes about 20. Each completed user makes three checked requests, so the request count should be about 90 if none aborts. The exact timing distribution and reported rates can vary; inspect the injection chart and the Get selected product, Login, and Read authorized orders rows rather than forcing one precise throughput number. The run should exit successfully only when both global assertions pass.
An open workload models arrivals independently of response time. If responses slow, overlapping users can rise even when arrivals remain fixed. That differs from a closed model that maintains a target number of concurrent users. The Gatling injection guide documents injectOpen, rampUsers, and constantUsersPerSec. Choose a model that matches the question you are testing; a queueing public API often calls for an arrival-rate model, while a fixed pool of logged-in workers may call for a concurrency model. The Gatling scenario design guide explores that decision in more depth.
Watch the injector too. A local server and Gatling running on one laptop share CPU, memory, and networking. If latency rises sharply, inspect both processes before calling it server saturation. Raise load in small, controlled increments in a dedicated environment, and preserve a comparable baseline. Do not claim a capacity limit from this fixture, which performs almost no application work.
Step 7: Review the Gatling Java DSL Tutorial Report and CI Exit Code
Gatling creates a new directory beneath target/gatling/ for each run. Open the newest report's index.html and read the request-level OK/KO breakdown, percentile table, response-time distribution, and active-user chart. Start with failed checks: a 401 on Read authorized orders points toward token extraction or header substitution, while a 404 on Get selected product points toward feeder data or the request path.
find target/gatling -name index.html -print
./mvnw -B gatling:test -Dgatling.simulationClass=tutorial.FeederJourneySimulation -Dprofile=smoke
Verify: find prints at least one HTML report path. The batch-mode Maven command exits zero when checks and assertions pass. Run echo $? immediately afterward in a POSIX shell to see that exit status. In CI, use the Maven command as a job step and retain target/gatling/ as a build artifact according to your CI platform's artifact mechanism. You need no external report service to get a local HTML result.
Prove that the acceptance criteria can fail: stop the Python server, rerun the smoke command, and expect a nonzero exit with a failed-request assertion. Restart the server before continuing. This deliberate failure distinguishes a real gate from a report that merely prints red rows while allowing a green pipeline. Do the check only against this local fixture, not by taking down a shared service. The Maven plugin guide documents the gatling:test goal, simulation selector, and batch-mode behavior.
For release decisions, archive the commit, Gatling version from pom.xml, workload parameters, server build, environment details, and report together. A chart without its traffic model is hard to reproduce. Compare like with like: the same endpoints, feeder data, injection shape, and machine class. If the test objective is a p95 latency target, set that target explicitly with the service owner and run long enough to collect a useful sample.
Interview Questions and Answers
Q: What is the difference between a request check and a simulation assertion? A check evaluates one response and can extract data into a session. An assertion evaluates aggregate run metrics and can fail the build. In this tutorial, status().is(200) checks one HTTP call while global().failedRequests().count().is(0L) gates the complete run.
Q: Why use injectOpen for this example? The sample describes arrival rate: users start during a ramp and then at a steady rate. If response time grows, concurrent users may grow too. A closed workload would instead control active-user count.
Q: What does saveAs("authToken") do? It stores the checked JSONPath result in the current virtual user's Gatling session. A subsequent #{authToken} expression reads that value for the authorization header. It avoids sharing a mutable token across users.
Q: Why is the circular CSV feeder safe here? Both rows are read-only product IDs, and reuse cannot create duplicate state. A signup or purchase journey needs unique records or a cleanup strategy. Otherwise feeder exhaustion or collisions can masquerade as application failures.
Q: Why is one passing smoke user insufficient for a performance conclusion? One user validates script wiring and endpoint contracts, but it does not exercise queueing or produce a stable latency distribution. Run a representative profile for a suitable duration in an isolated environment before interpreting response-time percentiles.
Q: How do you diagnose a 401 after a successful login? Check whether $.token matched the login response, whether the next request actually sent Bearer #{authToken} after interpolation, and whether the service expects that token format. Start with the first failed request and protect credentials in logs.
Common Mistakes
- Treating HTTP 200 as a complete contract: check key JSON fields, authorization behavior, and negative cases that matter to the journey.
- Confusing users per second with requests per second: each arriving user here performs three requests, so report rates cannot be interpreted as arrival rates without the scenario definition.
- Using a tiny local p95 as an SLO: the fixture and injector share one machine; adopt a service-owned threshold in a representative environment.
- Putting state in shared Java fields: Gatling virtual users need isolated session data, especially for tokens, IDs, and per-user values.
- Reusing a destructive feeder record: circular mode is fine for read-only product IDs, but it can cause duplicate account or order errors.
- Ignoring failed checks because HTML was generated: inspect Maven's exit status and keep an assertion that gates the run.
Troubleshooting
- Problem:
Connection refusedfor every request -> Startpython3.12 local-api/server.pyin a second terminal and verify/healthwithcurl. Check that Java'sbaseUrland the Python listener both use port 8089. - Problem: Maven cannot find a simulation class -> Confirm the file is under
src/test/java/tutorial/, begins withpackage tutorial;, and the-Dgatling.simulationClassvalue includestutorial.. Run./mvnw -q test-compileto surface Java errors first. - Problem:
No attribute named productId-> PutproductIds.csvinsrc/test/resources/, keep the header exactlyproductId, and call.feed(...)before the request that uses#{productId}. - Problem: Authenticated order request returns 401 -> Check the login response and the
$.tokenextraction, then confirm the header saysBearer #{authToken}. The local negativecurlresult should remain 401 without that header. - Problem: The percentile assertion fails on a busy laptop -> Inspect server and injector CPU, failed requests, and the response-time distribution. Repeat on an idle machine before deciding whether the sample threshold is useful; do not hide an actual regression by raising it blindly.
- Problem: Maven works interactively but CI fails -> Use
-Band the fully qualified simulation selector, allow dependency downloads or configure your repository mirror, and archivetarget/gatling/so the failing request and assertion remain visible.
Where To Go Next
Replace the local base URL with a controlled staging environment, then add service-specific checks and a workload based on observed traffic. Keep a single-user smoke profile in CI and run heavier tests in a scheduled or gated pipeline. Record the environment alongside every report. For a different protocol, the Gatling GraphQL load test tutorial shows how request bodies and checks change.
If you are choosing tools or shaping a learning path, compare JMeter and Gatling and use the performance testing roadmap to plan the next skills. The Java DSL here gives you a version-controlled script, explicit checks, and a process exit that automation can trust. Keep the scenario realistic before increasing the user count.
Conclusion
You now have a runnable Gatling Java DSL tutorial project: a local API, checked Java simulations, session-based token correlation, CSV input, an open arrival profile, and a report with build-gating assertions. Run the smoke profile whenever you edit the script. When it passes, move to a controlled environment and choose load levels and latency limits that reflect the service you actually need to assess.
Interview Questions and Answers
How do checks and assertions differ in Gatling?
Checks validate individual responses and can extract data into a virtual user's session. Assertions evaluate aggregate metrics after a simulation and can fail the process. I use checks to prove endpoint contracts and assertions to enforce run-level acceptance criteria.
How would you correlate a login token in the Java DSL?
I would add `jsonPath("$.token").saveAs("authToken")` to the login response check. A later request can send `.header("Authorization", "Bearer #{authToken}")`. I would confirm a missing header returns 401 so the correlation test proves useful behavior.
What does a circular CSV feeder imply for test data?
It reuses records after reaching the end of the file. That is suitable for repeatable read-only identifiers such as catalog products. It is risky for unique writes, where duplicate data can create artificial failures.
Why can concurrent users increase in an open workload even when arrival rate stays constant?
New users continue to start at the scheduled rate while existing journeys take longer to finish. Slower response time increases overlap. I would compare arrival rate, active users, response time, and injector health before attributing the change to the service.
What would you check first after an authenticated request becomes KO?
I would find the earliest failed request in that user's journey. Then I would inspect the login status and token extraction, followed by the outgoing authorization header and the service response. A later 401 often follows an earlier failed extraction.
Why is a one-user smoke run useful before a load run?
It verifies compilation, routing, response checks, feeder lookup, and correlation with a small failure surface. It cannot establish capacity or a stable latency percentile. Once it passes, I increase traffic in controlled stages.
How would you choose a response-time assertion for a real service?
I would start from a service-owned objective, specify the percentile and measurement window, and run in a representative environment. I would also gate on errors and retain the workload parameters. A threshold copied from a local fixture has no production meaning.
Frequently Asked Questions
Do I need Scala to write Gatling simulations?
No. Gatling provides a Java DSL with `Simulation`, `scenario`, `http`, checks, feeders, and injection builders. The official Java Maven starter compiles and runs Java classes directly.
Where do Gatling Java simulation files go in a Maven project?
Put them under `src/test/java` with a directory matching their Java package. This tutorial uses `src/test/java/tutorial/` and selects classes with `-Dgatling.simulationClass=tutorial.ClassName`.
Where should a CSV feeder file live?
Place it in `src/test/resources` and reference its classpath name, such as `csv("productIds.csv")`. The CSV header becomes a session attribute used by `#{productId}`.
What is the difference between `injectOpen` and a closed workload?
An open workload schedules new-user arrivals independently of response time. A closed workload controls concurrent users. Choose according to whether your target traffic is defined by arrivals or a fixed active population.
How does a Gatling run fail a CI job?
Add assertions to `setUp`, such as a zero failed-request count. Run `gatling:test` in batch mode and let the Maven process exit nonzero when an assertion fails. Retain the HTML report for diagnosis.
Can Gatling measure browser rendering time?
Gatling's HTTP protocol sends HTTP requests and records their responses; it does not execute a page's JavaScript or render CSS. Use browser performance tooling for client-side rendering metrics and Gatling for protocol-level traffic.
Why does the tutorial use a local Python API?
It gives every reader the same endpoints and response data without a shared external service. Its timing is useful for learning the test mechanics, but it is not a meaningful capacity benchmark for a production application.