k6 and Locust: Modern Load Testing Tools
k6 (Grafana k6)
k6 is a developer-centric load testing tool written in Go with JavaScript scripting. It has become the industry standard for teams practicing shift-left performance testing. Its key strengths are low resource footprint, native CI integration, and a scripting model that developers actually enjoy using.
Why k6 Wins for CI/CD
- Single binary. No JVM, no dependencies. Download and run.
- JavaScript scripting. Familiar syntax for most development teams.
- Threshold-based pass/fail. Define SLOs directly in the test -- if they fail, the exit code is non-zero, and CI fails.
- Built-in metrics export. Stream to Grafana, Datadog, Prometheus, or JSON.
- Scenarios engine. Model multiple traffic patterns in a single test file.
Complete k6 Example: E-Commerce Load Test
// k6-load-test.js -- Realistic e-commerce load test with multiple scenarios
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
// Custom metrics for business-specific SLOs
const errorRate = new Rate('errors');
const checkoutDuration = new Trend('checkout_duration');
// Traffic shaping: ramp up, sustain, spike, cool down
export const options = {
scenarios: {
// Steady-state browsing traffic (70% of users)
browse: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 }, // ramp up
{ duration: '5m', target: 100 }, // steady state
{ duration: '1m', target: 300 }, // spike
{ duration: '5m', target: 100 }, // back to steady
{ duration: '2m', target: 0 }, // ramp down
],
exec: 'browseProducts',
},
// API consumers hitting the search endpoint (30% of traffic)
api_search: {
executor: 'constant-arrival-rate',
rate: 50, // 50 requests per second
timeUnit: '1s',
duration: '15m',
preAllocatedVUs: 20,
maxVUs: 100,
exec: 'searchAPI',
},
},
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1500'], // SLO enforcement
errors: ['rate<0.01'], // <1% error rate
checkout_duration: ['p(95)<3000'], // checkout under 3s
},
};
export function browseProducts() {
group('Homepage', () => {
const res = http.get('https://store.example.com/');
check(res, {
'homepage status 200': (r) => r.status === 200,
'homepage loads under 2s': (r) => r.timings.duration < 2000,
});
errorRate.add(res.status >= 400);
});
sleep(Math.random() * 3 + 1); // realistic think time: 1-4 seconds
group('Product Page', () => {
const productId = Math.floor(Math.random() * 1000) + 1;
const res = http.get(`https://store.example.com/products/${productId}`);
check(res, {
'product page status 200': (r) => r.status === 200,
'has product title': (r) => r.body.includes('<h1'),
});
errorRate.add(res.status >= 400);
});
sleep(Math.random() * 5 + 2); // browsing think time: 2-7 seconds
}
export function searchAPI() {
const queries = ['laptop', 'phone', 'headphones', 'keyboard', 'monitor'];
const query = queries[Math.floor(Math.random() * queries.length)];
const res = http.get(`https://api.store.example.com/search?q=${query}`, {
headers: { 'Authorization': `Bearer ${__ENV.API_TOKEN}` },
});
check(res, {
'search returns 200': (r) => r.status === 200,
'search returns results': (r) => JSON.parse(r.body).results.length > 0,
'search under 300ms': (r) => r.timings.duration < 300,
});
errorRate.add(res.status >= 400);
}
Running k6
# Local execution with default output
k6 run k6-load-test.js
# With environment variables for secrets
k6 run -e API_TOKEN=secret123 k6-load-test.js
# Output results to Grafana Cloud k6
k6 cloud k6-load-test.js
# Output to JSON for CI analysis
k6 run --out json=results.json k6-load-test.js
# Run with a specific scenario only
k6 run --scenario browse k6-load-test.js
k6 Executor Types
Understanding executors is essential for modeling realistic traffic:
| Executor | Controls | Best For |
|---|---|---|
shared-iterations |
Total iterations across all VUs | Quick smoke tests |
per-vu-iterations |
Iterations per VU | Ensuring each VU runs N times |
constant-vus |
Fixed VU count | Steady-state testing |
ramping-vus |
VU count over time | Ramp-up/down patterns |
constant-arrival-rate |
Fixed request rate | SLO validation (RPS-based) |
ramping-arrival-rate |
Request rate over time | Finding the breaking point |
externally-controlled |
Via REST API | Dynamic load adjustment |
Locust (Python-Based)
Locust uses Python classes to define user behavior. It is the preferred choice for teams with strong Python skills, complex scenario logic, or when you need to share code with your existing Python test infrastructure.
Why Choose Locust
- Pure Python. Leverage existing libraries (requests, database clients, custom SDKs).
- Distributed by default. Built-in master/worker architecture for horizontal scaling.
- Real-time web UI. Monitor test progress and adjust parameters live.
- Event hooks. Deep customization of request lifecycle, error handling, and reporting.
Complete Locust Example
# locustfile.py -- Multi-persona e-commerce load test
from locust import HttpUser, task, between, tag, events
import random
import logging
logger = logging.getLogger(__name__)
class BrowsingUser(HttpUser):
"""Simulates casual browsing behavior -- 70% of traffic."""
wait_time = between(1, 5)
weight = 7 # 70% of simulated users
@task(5)
def view_homepage(self):
with self.client.get("/", name="Homepage", catch_response=True) as response:
if response.status_code != 200:
response.failure(f"Homepage returned {response.status_code}")
elif "Welcome" not in response.text:
response.failure("Homepage missing welcome text")
@task(3)
def view_product(self):
product_id = random.randint(1, 1000)
self.client.get(f"/products/{product_id}", name="/products/[id]")
@task(1)
@tag("checkout")
def add_to_cart(self):
self.client.post("/cart", json={"product_id": 42, "quantity": 1})
def on_start(self):
"""Runs once per simulated user at start."""
logger.info("BrowsingUser session started")
class APIUser(HttpUser):
"""Simulates API consumer traffic -- 30% of traffic."""
wait_time = between(0.1, 0.5)
weight = 3 # 30% of simulated users
@task
def search(self):
query = random.choice(["laptop", "phone", "headphones", "keyboard"])
self.client.get(f"/api/search?q={query}", name="API Search")
@task
def get_inventory(self):
product_id = random.randint(1, 100)
self.client.get(f"/api/inventory/{product_id}", name="API Inventory")
# Custom event listener for reporting
@events.request.add_listener
def on_request(request_type, name, response_time, response_length, exception, **kwargs):
if response_time > 5000:
logger.warning(f"Slow request: {name} took {response_time}ms")
Running Locust
# Local single-process run
locust -f locustfile.py --host=https://staging.example.com
# Headless mode for CI (no web UI)
locust -f locustfile.py --host=https://staging.example.com \
--headless -u 100 -r 10 --run-time 5m
# Distributed mode: start master
locust -f locustfile.py --master --host=https://staging.example.com
# Distributed mode: start workers (run on multiple machines)
locust -f locustfile.py --worker --master-host=192.168.1.100
# Filter by tag
locust -f locustfile.py --tags checkout --host=https://staging.example.com
k6 vs Locust: Decision Framework
When choosing between k6 and Locust, consider your team's context:
| Factor | Choose k6 | Choose Locust |
|---|---|---|
| Team language | JavaScript/TypeScript | Python |
| Primary use | CI/CD automated gates | Exploratory + CI |
| Protocol needs | HTTP, gRPC, WebSocket | HTTP + custom protocols via Python |
| Resource budget | Tight (k6 is extremely efficient) | Moderate |
| Real-time monitoring | Grafana integration | Built-in web UI |
| Distributed execution | k6-operator on K8s | Native master/worker |
| Scripting complexity | Simple to moderate | Moderate to complex |
| Existing test infra | Node.js ecosystem | Python ecosystem |
Hybrid Approach
Many mature teams use both tools for different purposes:
- k6 in CI for automated performance gates (threshold-based pass/fail)
- Locust for exploratory testing where the interactive web UI and Python flexibility shine
- k6 Cloud for large-scale tests when you need thousands of VUs across regions
The key is not which tool you pick -- it is that you pick one and integrate it into CI so that performance regressions are caught before production.