Introduction to Locust

Locust is a Python-based, open-source load testing tool that enables developers to write test scenarios in pure Python code. Unlike GUI-heavy tools like JMeter, Locust (as discussed in Gatling: High-Performance Load Testing with Scala DSL) provides a code-first approach with powerful distributed testing capabilities and real-time web-based monitoring.

Basic Load Test

# locustfile.py
from locust (as discussed in [K6: Modern Load Testing with JavaScript for DevOps Teams](/blog/k6-modern-load-testing)) import HttpUser, task, between

class WebsiteUser(HttpUser):
    wait_time = between(1, 3)  # Wait 1-3 seconds between tasks
    host = "https://api.example.com"

    @task(3)  # Weight: 3x more likely than other tasks
    def view_products(self):
        self.client.get("/api/products")

    @task(1)
    def view_product_detail(self):
        product_id = 123
        self.client.get(f"/api/products/{product_id}")

    @task(2)
    def add_to_cart(self):
        self.client.post("/api/cart", json={
            "product_id": 456,
            "quantity": 1
        })

    def on_start(self):
        # Login before starting tasks
        response = self.client.post("/api/auth/login", json={
            "username": "test@example.com",
            "password": "password123"
        })
        self.token = response.json()["token"]
        self.client.headers.update({"Authorization": f"Bearer {self.token}"})

Advanced Scenarios

Sequential User Flow

from locust import HttpUser, task, SequentialTaskSet, between

class UserBehavior(SequentialTaskSet):
    def on_start(self):
        self.product_id = None
        self.cart_id = None

    @task
    def browse_homepage(self):
        self.client.get("/")

    @task
    def search_products(self):
        response = self.client.get("/api/products?category=electronics")
        products = response.json()
        if products:
            self.product_id = products[0]["id"]

    @task
    def view_product(self):
        if self.product_id:
            self.client.get(f"/api/products/{self.product_id}")

    @task
    def add_to_cart(self):
        if self.product_id:
            response = self.client.post("/api/cart", json={
                "product_id": self.product_id,
                "quantity": 1
            })
            self.cart_id = response.json()["cart_id"]

    @task
    def checkout(self):
        if self.cart_id:
            self.client.post(f"/api/checkout/{self.cart_id}", json={
                "payment_method": "credit_card",
                "shipping_address": "123 Main St"
            })

    @task
    def stop(self):
        self.interrupt()  # Stop task sequence

class EcommerceUser(HttpUser):
    wait_time = between(2, 5)
    tasks = [UserBehavior]

Data-Driven Testing

import csv
from locust import HttpUser, task, between
from itertools import cycle

class DataDrivenUser(HttpUser):
    wait_time = between(1, 2)

    def on_start(self):
        # Load test data
        with open('test_users.csv', 'r') as f:
            reader = csv.DictReader(f)
            self.user_data = cycle(list(reader))

    @task
    def login_with_test_data(self):
        user = next(self.user_data)
        response = self.client.post("/api/login", json={
            "username": user['username'],
            "password": user['password']
        })

        if response.status_code == 200:
            self.token = response.json()["token"]

Distributed Load Testing

Master Configuration

# Run master node
# locust -f locustfile.py --master --expect-workers=4

# Run worker nodes
# locust -f locustfile.py --worker --master-host=localhost

Docker Compose Setup

# docker-compose.yml
version: '3'

services:
  master:
    image: locustio/locust
    ports:
      - "8089:8089"
    volumes:
      - ./:/mnt/locust
    command: -f /mnt/locust/locustfile.py --master --expect-workers=4

  worker:
    image: locustio/locust
    volumes:
      - ./:/mnt/locust
    command: -f /mnt/locust/locustfile.py --worker --master-host=master
    deploy:
      replicas: 4

Kubernetes Deployment

# locust-master.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: locust-master
spec:
  replicas: 1
  template:
    spec:
      containers:
      - name: locust
        image: locustio/locust
        args: ["-f", "/locust/locustfile.py", "--master"]
        ports:
        - containerPort: 8089
        - containerPort: 5557
        volumeMounts:
        - name: locust-scripts
          mountPath: /locust

---
# locust-worker.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: locust-worker
spec:
  replicas: 10
  template:
    spec:
      containers:
      - name: locust
        image: locustio/locust
        args: ["-f", "/locust/locustfile.py", "--worker", "--master-host=locust-master"]
        volumeMounts:
        - name: locust-scripts
          mountPath: /locust

Custom Metrics and Reporting

from locust import HttpUser, task, events
import time

# Custom metric tracking
request_times = []

@events.request.add_listener
def on_request(request_type, name, response_time, response_length, exception, **kwargs):
    request_times.append(response_time)

    if response_time > 1000:  # Alert on slow requests
        print(f"Slow request detected: {name} took {response_time}ms")

@events.test_stop.add_listener
def on_test_stop(environment, **kwargs):
    # Calculate percentiles
    if request_times:
        sorted_times = sorted(request_times)
        p50 = sorted_times[len(sorted_times) // 2]
        p95 = sorted_times[int(len(sorted_times) * 0.95)]
        p99 = sorted_times[int(len(sorted_times) * 0.99)]

        print(f"\nPerformance Summary:")
        print(f"P50: {p50}ms")
        print(f"P95: {p95}ms")
        print(f"P99: {p99}ms")

class MonitoredUser(HttpUser):
    @task
    def monitored_request(self):
        start_time = time.time()
        response = self.client.get("/api/data")
        elapsed = (time.time() - start_time) * 1000

        # Custom validation
        if response.status_code == 200:
            data = response.json()
            if len(data) < 10:
                events.request.fire(
                    request_type="GET",
                    name="/api/data",
                    response_time=elapsed,
                    response_length=len(response.content),
                    exception=Exception("Insufficient data returned")
                )

Locust vs JMeter Comparison

FeatureLocustJMeter
LanguagePythonGUI/XML
Learning CurveLow (if Python known)Medium-High
Test as CodeYes (native)Limited (requires plugins)
Distributed TestingBuilt-inBuilt-in
Real-time UIWeb-based, modernJava Swing, dated
Version ControlExcellent (Python files)Poor (XML files)
Resource UsageLowerHigher (Java)
Protocol SupportHTTP/WebSocket (extensible)Extensive built-in
CI/CD IntegrationExcellentGood
Scripting FlexibilityExcellent (Python)Limited (Groovy/BeanShell)
CommunityGrowingLarge, established
LicenseMITApache 2.0

CI/CD Integration

GitHub Actions

# .github/workflows/load-test.yml
name: Load Test

on:
  schedule:
    - cron: '0 2 * * *'  # Daily at 2 AM
  workflow_dispatch:

jobs:
  load-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install locust

      - name: Run Load Test
        run: |
          locust -f locustfile.py \
            --headless \
            --users 100 \
            --spawn-rate 10 \
            --run-time 5m \
            --host https://api.example.com \
            --html report.html \
            --csv results

      - name: Upload Results
        uses: actions/upload-artifact@v3
        with:
          name: load-test-results
          path: |
            report.html
            results_*.csv

      - name: Check Performance Thresholds
        run: |
          python check_thresholds.py results_stats.csv

Threshold Validation Script

# check_thresholds.py
import csv
import sys

def check_thresholds(stats_file):
    thresholds = {
        'avg_response_time': 200,  # ms
        'max_response_time': 2000,  # ms
        'failure_rate': 0.01  # 1%
    }

    with open(stats_file, 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            if row['Name'] == 'Aggregated':
                avg_response = float(row['Average Response Time'])
                max_response = float(row['Max Response Time'])
                failure_rate = float(row['Failure Count']) / float(row['Request Count'])

                if avg_response > thresholds['avg_response_time']:
                    print(f"FAIL: Average response time {avg_response}ms exceeds threshold")
                    sys.exit(1)

                if max_response > thresholds['max_response_time']:
                    print(f"FAIL: Max response time {max_response}ms exceeds threshold")
                    sys.exit(1)

                if failure_rate > thresholds['failure_rate']:
                    print(f"FAIL: Failure rate {failure_rate:.2%} exceeds threshold")
                    sys.exit(1)

    print("PASS: All thresholds met")
    sys.exit(0)

if __name__ == '__main__':
    check_thresholds(sys.argv[1])

Best Practices

1. Realistic User Simulation

from locust import HttpUser, task, between
import random

class RealisticUser(HttpUser):
    wait_time = between(1, 5)

    @task(10)
    def browse(self):
        # Simulate browsing behavior
        pages = ['/products', '/about', '/contact']
        self.client.get(random.choice(pages))

    @task(3)
    def search(self):
        keywords = ['laptop', 'phone', 'tablet', 'watch']
        self.client.get(f"/search?q={random.choice(keywords)}")

    @task(1)
    def purchase(self):
        # Only 10% of users purchase
        if random.random() < 0.1:
            self.client.post("/api/orders", json={
                "items": [{"id": random.randint(1, 100), "qty": 1}]
            })

2. Proper Error Handling

from locust import HttpUser, task
from locust.exception import RescheduleTask

class ResilientUser(HttpUser):
    @task
    def api_call(self):
        with self.client.get("/api/data", catch_response=True) as response:
            if response.status_code == 429:  # Rate limited
                response.failure("Rate limited")
                raise RescheduleTask()  # Retry later

            elif response.status_code == 500:
                response.failure("Server error")

            elif response.elapsed.total_seconds() > 2:
                response.failure("Request too slow")

            else:
                response.success()

3. Environment-Specific Configuration

import os
from locust import HttpUser, task

class ConfigurableUser(HttpUser):
    host = os.getenv("TARGET_HOST", "http://localhost:3000")
    wait_time = between(
        int(os.getenv("MIN_WAIT", 1)),
        int(os.getenv("MAX_WAIT", 3))
    )

    @task
    def make_request(self):
        self.client.get(os.getenv("ENDPOINT", "/api/data"))

Conclusion

Locust provides a modern, Python-based approach to load testing that excels in code-first workflows, version control integration, and distributed testing. Its simplicity and flexibility make it ideal for developers familiar with Python who want to define performance tests as code.

Choose Locust when:

  • Team is comfortable with Python
  • Tests as code is preferred
  • Modern, web-based UI desired
  • Easy version control needed
  • Distributed testing required

Choose JMeter when:

  • Extensive protocol support needed
  • Team prefers GUI-based test creation
  • Large existing JMeter infrastructure
  • Non-HTTP protocols heavily used

For modern development teams practicing DevOps and infrastructure as code, Locust offers the perfect blend of simplicity, power, and maintainability for load testing (as discussed in Artillery Performance Testing: Modern Load Testing with YAML Scenarios).