Last modified: Oct 24, 2026
Load Testing with Locust and pytest
Load testing shows how your app behaves under real traffic. It reveals slow endpoints, memory leaks, and crashes before users find them.
Locust is a Python load testing tool. You write user behavior in plain Python code. pytest is the most popular Python test runner. Together, they give you repeatable and readable performance tests.
This guide walks you through the setup, the core concepts, and a complete working example. It also covers common pitfalls and best practices.
Why Combine Locust and pytest?
Locust already ships with its own command line runner. So why add pytest?
pytest brings structure. You get fixtures, markers, assertions, and plugins. You can run load tests next to unit tests. You can also skip slow tests in CI and run them nightly.
pytest also gives you clean reporting. A failing load test becomes a failing test case. That makes performance part of your normal workflow.
Installing the Tools
Start with a virtual environment. Then install both packages.
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install locust pytest
Check the versions to make sure everything installed correctly.
locust --version
pytest --version
locust 2.29.1
pytest 8.1.1
Writing Your First Locust File
A Locust file defines users and the tasks they perform. Each user class extends HttpUser. Each task is a method decorated with @task.
Here is a simple example that hits a public test API.
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
# Random wait between 1 and 3 seconds between tasks
wait_time = between(1, 3)
@task(3)
def view_homepage(self):
# Weight 3: runs three times more often
self.client.get("/")
@task(1)
def view_about(self):
self.client.get("/about")
The @task decorator accepts a weight. Higher weights mean the task runs more often. This mimics real user behavior better than equal weights.
The wait_time attribute simulates think time. Without it, users hammer the server unrealistically.
Running Locust Without a Web UI
Locust has a web interface by default. For automation, use headless mode.
locust -f locustfile.py --headless -u 50 -r 5 -t 30s --host https://example.com
This runs 50 users, spawns 5 per second, and stops after 30 seconds. The output shows request counts, failure rates, and response time percentiles.
Type Name # reqs # fails Avg Min Max Median req/s
GET / 1200 0 45 10 210 40 39.8
GET /about 400 0 38 9 190 35 13.2
Aggregated 1600 0 43 9 210 38 53.0
Pay attention to the 95th percentile. Averages hide slow outliers. Percentiles show what real users experience.
Wrapping Locust in a pytest Test
Locust exposes a programmatic API. You can start a MasterRunner or LocalRunner inside a pytest test. This gives you assertions on the results.
Here is a minimal pattern.
import pytest
from locust import HttpUser, task
from locust.env import Environment
from locust.stats import stats_printer
from locust.log import setup_logging
setup_logging("INFO", None)
class ApiUser(HttpUser):
host = "https://example.com"
@task
def index(self):
self.client.get("/")
@pytest.fixture
def locust_env():
# Build an environment and runner for the test
env = Environment(user_classes=[ApiUser])
env.create_local_runner()
yield env
env.runner.quit()
def test_load(locust_env):
# Start 10 users and run for 10 seconds
locust_env.runner.start(10, spawn_rate=5)
locust_env.runner.greenlet.join(timeout=10)
locust_env.runner.stop()
stats = locust_env.stats.total
# Assert the failure rate stays below 1 percent
assert stats.fail_ratio < 0.01
# Assert the 95th percentile stays under 500 ms
assert stats.get_response_time_percentile(0.95) < 500
The fixture creates the runner. The test starts users, waits, and stops. Then it checks the aggregated stats.
The fail_ratio property returns failures divided by total requests. The get_response_time_percentile method returns the latency at a given percentile.
You can also assert on per-endpoint stats using locust_env.stats.entries. Each entry holds the name, method, and response time data.
Generating Realistic Test Data
Real load tests need real-looking payloads. Random usernames and emails make your test more meaningful. You can learn to generate test data with Faker in Python and plug it into your Locust tasks.
For example, a registration endpoint performs very differently with 10 users than with 10,000 unique users. Faker helps you avoid duplicates that hit cache and skew results.
If your API wraps an LLM, you should also test that path. Load testing AI endpoints is a hot topic. Read more about OpenAI function calling in Python to understand how those requests are shaped.
Testing with Assertions Inside Tasks
Locust does not fail a request on bad content by default. A 200 response with an error body still counts as success. You can fix that with the catch_response context manager.
from locust import HttpUser, task
class ApiUser(HttpUser):
@task
def check_health(self):
with self.client.get("/health", catch_response=True) as response:
if response.status_code != 200:
response.failure(f"Bad status: {response.status_code}")
elif "ok" not in response.text:
response.failure("Health check did not return ok")
else:
response.success()
The catch_response context manager lets you mark a request as failed or successful. This makes your load tests catch logic bugs, not just HTTP errors.
You can combine this with pytest assertions. Failures recorded by Locust will show up in the aggregated stats your test checks.
Parameterizing Load Tests
pytest parametrization works well here. You can run the same scenario with different user counts or durations.
import pytest
@pytest.mark.parametrize("users,duration", [(10, 5), (50, 10), (100, 10)])
def test_scaling(locust_env, users, duration):
locust_env.runner.start(users, spawn_rate=users // 5)
locust_env.runner.greenlet.join(timeout=duration)
locust_env.runner.stop()
stats = locust_env.stats.total
assert stats.fail_ratio == 0
This pattern reveals how response times grow as load increases. If latency jumps at 50 users, you have found your first bottleneck.
Common Pitfalls
Testing against production. Always use a staging environment. Load tests can take down real services.
Ignoring think time. Without wait_time, your test is a stress test, not a load test. Know which one you want.
Running from one machine. A single client machine becomes the bottleneck. Use distributed mode with --master and --worker flags for high loads.
Trusting averages. Always check percentiles. A 200 ms average with a 4-second p99 is a bad user experience.
Forgetting cleanup. Load tests create data. Add teardown steps or use a disposable database. This matters even more when you test data pipelines. If you produce and consume Kafka messages in Python, make sure your test topics are isolated.
Best Practices
Keep scenarios small and focused. One user class per flow. Name tasks clearly.
Tag tests with pytest markers like @pytest.mark.load. Then you can run them separately from unit tests.
pytest -m load --durations=10
The --durations=10 flag shows the slowest test phases. It helps you spot setup overhead in your load tests.
Track results over time. Store the JSON output from Locust and compare runs. A regression in p95 latency is easier to catch with history.
Finally, test text processing endpoints too. If your service tokenizes input, you can tokenization with NLTK in Python in a separate benchmark to isolate CPU cost from network cost.
Conclusion
Locust and pytest make a strong pair. Locust models real user behavior in Python. pytest adds structure, assertions, and reporting.
Start small. Write one user class. Run it headless. Then wrap it in a pytest test with a failure ratio and percentile assertion.
As your app grows, add parametrization and distributed workers. Watch the percentiles, not the averages. Your users will thank you.