06 · Performance Testing with Locust¶
Every test so far checks correctness — does the right thing happen? Locust checks capacity — how many users, requests, or transactions can the system handle before it slows down or falls over? It's written in Python, using the same mental model as the rest of this course: define user behavior as code, run it, read structured output.
1. Install¶
2. A locustfile¶
# locustfile.py
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
wait_time = between(0.1, 0.3) # pause between tasks, per simulated user
@task(3) # 3x more likely to run than view_page
def view_homepage(self):
self.client.get("/")
@task(1)
def view_page(self):
self.client.get("/page")
HttpUser models one simulated user; Locust spawns many of them concurrently,
each independently picking a @task (weighted by the number passed to the
decorator) and waiting wait_time between actions — a much closer simulation
of real traffic than firing requests in a tight loop.
3. Running it headlessly¶
locust -f locustfile.py --host https://example.com \
--headless -u 3 -r 1 --run-time 5s --csv=results
[INFO] Run time limit set to 5 seconds
[INFO] Ramping to 3 users at a rate of 1.00 per second
[INFO] --run-time limit reached, shutting down
Type Name # reqs # fails | Avg Min Max Med | req/s failures/s
GET / 9 0(0%) | 27 15 66 19 | 5.32 0.00
GET /page 5 5(100%) | 18 16 19 19 | 2.96 2.96
Aggregated 14 5(35.71%)| 23 15 66 19 | 8.28 2.96
Error report
# occurrences Error
5 GET /page: HTTPError('404 Client Error: Not Found for url: /page')
That's a real run against https://example.com — 3 users, ramped at 1/second,
for 5 seconds. /page genuinely doesn't exist on that domain, so Locust
reports exactly what it should: a 100% failure rate on that endpoint, isolated
from the 0% failure rate on /. This is the same signal a real load test
gives you against your own app: which specific endpoint degrades or breaks
under concurrent load, not just an aggregate number.
-u 3 -r 1 means "ramp up to 3 concurrent users, adding 1 per second."
--headless skips Locust's web UI (locust -f locustfile.py --host ...
without --headless starts a browser dashboard on localhost:8089 instead)
— use headless for CI, the web UI for exploratory runs where you want to
watch response times live and adjust user count on the fly.
4. Reading the numbers that matter¶
- req/s — throughput. Compare this across runs with increasing
-uto find where throughput stops scaling with added users — that's your system's practical ceiling. - Avg / Med / percentiles — median (p50) tells you the typical experience; p95/p99 tell you what your worst-served users experience, which matters more for SLAs than the average does.
- failures/s — a spike here under increasing load is usually the first sign of the system exhausting a resource (connection pool, thread pool, database connections) rather than CPU.
5. Simulating a realistic session, not isolated GETs¶
from locust import HttpUser, task, between
class ShopperUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
# runs once per simulated user, before any @task
response = self.client.post("/login", json={"user": "loadtest", "pw": "x"})
self.token = response.json().get("token", "")
@task
def browse_and_checkout(self):
headers = {"Authorization": f"Bearer {self.token}"}
self.client.get("/products", headers=headers)
self.client.post("/cart", json={"product_id": 1}, headers=headers)
self.client.post("/checkout", headers=headers)
on_start runs once when a simulated user "arrives" — the natural place to
log in and capture an auth token, exactly like a conftest.py fixture that
sets up state before a test body runs.
6. Testing-specific traps¶
Trap 1 — treating 0 failures as success without checking response
content. Locust marks a request "successful" based on HTTP status code by
default. An endpoint returning 200 OK with an error message in the JSON
body looks fine to Locust unless you explicitly validate it:
with self.client.get("/api/data", catch_response=True) as response:
if "error" in response.json():
response.failure("Got 200 but body contained an error")
Trap 2 — load-testing against a shared or production environment by
accident. --host takes whatever URL you give it — pointing a load test at
a shared staging environment other teams depend on, or worse, production, can
cause a real outage. Always confirm --host before running, and never load
test a system without the team that owns it knowing in advance.
Trap 3 — client-side bottleneck, not server-side. If req/s plateaus and
CPU on the client machine running Locust is pegged at 100%, you're measuring
Locust's own limits, not your server's. Locust supports distributing load
across multiple worker processes/machines (--worker/--master) for exactly
this reason.
Trap 4 — wait_time too aggressive or too lax. between(0.1, 0.3) above
simulates a very impatient user; real users pause several seconds between
actions. An unrealistically low wait_time inflates throughput numbers to
something that doesn't reflect real traffic patterns.
How It Actually Works¶
Load-testing tools like Locust don't send requests faster by looping tighter — they
achieve concurrency by running many lightweight "users" as greenlets (via gevent),
cooperatively scheduled on top of a small number of OS threads. Each simulated user
runs its task function, and whenever it hits a blocking I/O call (an HTTP request),
gevent's monkey-patched socket layer yields control back to the event loop instead
of blocking the whole process — this is how a single Locust worker process can
simulate thousands of "concurrent" users on modest hardware: they're not thousands of
OS threads (which would exhaust memory/context-switch overhead), they're thousands of
suspended coroutines waking up on I/O completion.
Requests-per-second and latency percentiles reported by a load test are computed from
raw per-request timestamps: each simulated user records (start_time, end_time,
status_code) for every request, and the aggregator bucket-sorts these into a
histogram to compute p50/p95/p99 — the reason p99 latency is reported separately
from the mean is that mean latency is dominated by the common-case fast requests and
can completely hide a tail of slow ones (e.g., ones hitting a lock, a GC pause, or a
cold cache) that matter enormously for real user experience but barely move the
average.
Cheat sheet¶
| Concept | Locust API |
|---|---|
| A simulated user | class MyUser(HttpUser): |
| Time between actions | wait_time = between(min, max) |
| An action, weighted | @task(weight) |
| Per-user setup (e.g. login) | def on_start(self): |
| Make a request | self.client.get/post(...) |
| Validate beyond status code | catch_response=True + response.failure(...) |
| Run without the web UI | --headless -u N -r RATE --run-time Ns |
| Machine-readable output | --csv=prefix produces prefix_stats.csv etc. |
| Distribute load | --master / --worker |
Exercise¶
- Write a locustfile with two
@tasks at different weights against a public test API of your choice (orhttps://example.com), and run it headlessly for 10 seconds at-u 5 -r 1. - Add
catch_response=Trueto one task and fail the request explicitly if the response takes longer than 1 second (if response.elapsed.total_seconds() > 1: response.failure(...)). - Re-run at
-u 20and compare req/s and the failures/s column against the-u 5run — write two sentences on what changed and why. - Add an
on_startmethod that performs a fake "login" request before the weighted tasks run, and confirm (via--csv) that it appears as its own row in the stats output. - Deliberately point
--hostat a URL with a path that doesn't exist (like/pageabove) for one task, and paste the exact Error report line Locust generates — this is what a real load test surfaces when a load-tested deploy has a broken route.