Module 2: The K6 Script And Virtual Users

8. Mini-project: a k6 script and its Python model

Overview

This lesson is your graduation from module 2. Across seven lessons you took apart the k6 script piece by piece —the default function, http.post, check, sleep, options— and understood the VU model. Now you bring them all together in a single end-to-end deliverable: you write a complete k6 script to quote at /quote (content, with its summary) and its executable equivalent in Python —N VUs with ThreadPoolExecutor, status and price check, iteration and error counting— and run it for real against the canonical Reservo API, seeing the two faces of the same world side by side.

The deliverable has four parts, and all four matter: (1) the complete and correct k6 script (content), with default, http.post, check, sleep, and options; (2) the k6 summary that script would produce, read (content); (3) the Python generator that models the same VUs and its real run against the API, with measured iterations, checks, and errors; and (4) a reflection on the mapping —which k6 line corresponds to which Python line, and how their summaries resemble and differ—. That fourth part ties what you wrote to what you measured: the point of the module isn't just to have a script, but to understand the model it describes.

Connection to the module: here the arc closes. Lessons 2 to 6 gave you the pieces; lesson 7, how to read what they produce. This lesson puts you to producing the whole journey with your own hands, in order, as you would on the first day you write a load test for a real API. The k6 script and summary go as labeled content (correct, not run here); the Python generator is actually run, and its output —20 VUs, 408 iterations, 98.04% of checks, 8 errors— was measured in this environment with Python 3.14.0 against the canonical API. When you finish, you enter module 3 —the metrics in depth— with the script and the VU model already internalized.

The flight simulator and the real flight

Think of it this way. A pilot in training masters two things that reflect each other. First, the simulator: a complete model of the aircraft where they practice each maneuver, with its instruments and its readings —all faithful to reality, even though the plane never leaves the hangar—. Second, the real flight: getting into the aircraft and making the trip for real, measuring real altitude and speed. The competent pilot understands both and, above all, understands how they correspond: they know the simulator's stick is the same as the plane's, that the simulated altimeter reads the same as the real one. The simulator doesn't replace the flight; it models it faithfully, so that when you fly for real, you already know what to expect.

Your mini-project is exactly this. The k6 script is the simulator: a complete and faithful model of the load test —with its default, its checks, its summary— even though in this environment k6 doesn't "take off" (it isn't installed). The Python generator is the real flight: it raises VUs for real, hits the API for real, measures iterations and errors for real. And the fourth part of the deliverable —the reflection on the mapping— is what makes you a competent pilot and not just someone who pushed buttons: understanding that http.post is urllib.request, that check is a counted comparison, that both summaries read the same world. Mastering both faces and their correspondence is truly knowing what a load test is.

The k6 script models the test faithfully (the simulator); the Python generator runs it for real (the real flight). The value of the mini-project isn't just in having both, but in understanding how they correspond line by line. That's knowing what a load test is, not just writing it.

What you'll build

A mini-project with three files: the canonical API (the target), the k6 script (the simulator), and the Python generator (the real flight).

reservo-load/
├── reservo_api.py    # the canonical Reservo API (the load target)
├── quote_test.js     # the k6 script (CONTENT: k6 is not installed)
└── loadgen.py        # the Python generator that models the VUs (IS RUN)

Follow the steps in order; each one builds on the previous.

Step 1 — The canonical API (the load target)

This is the Reservo server. It starts from the module-1 canonical one and we declare a variant (same target, with three additions relative to M1): it validates the input more strictly (tier and hours within range, malformed JSON → 400); it names its rate table ROOM_RATES_CENTS and exposes the rate_cents field in /rooms (where M1 uses HOURLY_CENTS/hourly_cents); and it generates the booking_id with a sequential counter (bk-000001, bk-000002, …) so each booking has a unique id —the same format module 6's correlation will reuse, different from M1's descriptive bk_Focus_basic_3—. The price logic doesn't change: hourly rate (Focus 2500, Studio 4000, Boardroom 8000 cents), integer pro discount (*80//100), and it uses port 0 so the operating system assigns a free port (so it won't collide if something else is running):

# reservo_api.py — the canonical Reservo API (load target).
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json, itertools

ROOM_RATES_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
VALID_TIERS = {"basic", "pro"}
_booking_counter = itertools.count(1)

def price_cents(room, tier, hours):
    """Price in cents (int). Integer pro discount: *80//100."""
    total = ROOM_RATES_CENTS[room] * hours
    if tier == "pro":
        total = total * 80 // 100
    return total

class ReservoHandler(BaseHTTPRequestHandler):
    def log_message(self, *args):  # silence the per-request log
        pass

    def _send_json(self, status, payload):
        body = json.dumps(payload).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == "/rooms":
            rooms = [{"room": n, "rate_cents": r} for n, r in ROOM_RATES_CENTS.items()]
            self._send_json(200, {"rooms": rooms})
        else:
            self._send_json(404, {"error": "not_found"})

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length) if length else b"{}"
        try:
            data = json.loads(raw or b"{}")
        except json.JSONDecodeError:
            return self._send_json(400, {"error": "invalid_json"})

        room, tier, hours = data.get("room"), data.get("tier"), data.get("hours")
        if room not in ROOM_RATES_CENTS or tier not in VALID_TIERS:
            return self._send_json(400, {"error": "invalid_room_or_tier"})
        if not isinstance(hours, int) or not (1 <= hours <= 12):
            return self._send_json(400, {"error": "invalid_hours"})

        cents = price_cents(room, tier, hours)
        if self.path == "/quote":
            self._send_json(200, {"price_cents": cents})
        elif self.path == "/book":
            bid = f"bk-{next(_booking_counter):06d}"
            self._send_json(200, {"booking_id": bid, "confirmed": True, "price_cents": cents})
        else:
            self._send_json(404, {"error": "not_found"})

def main():
    # Port 0: the OS assigns a free port. We write it to port.txt.
    server = ThreadingHTTPServer(("127.0.0.1", 0), ReservoHandler)
    _, port = server.server_address
    with open("port.txt", "w") as f:
        f.write(str(port))
    print(f"Reservo API at http://127.0.0.1:{port}")
    server.serve_forever()

if __name__ == "__main__":
    main()

Start it in a terminal —python3 reservo_api.py— and leave it running. It'll write the assigned port to port.txt, which the generator will read.

Step 2 — The k6 script (the simulator, content)

This is the k6 deliverable: a complete script that brings together the module's five pieces. It's shown as content —correct and verified against k6's documentation, but k6 isn't installed in this environment, so we don't run it here—:

// quote_test.js — load test of /quote in Reservo.
// SHOWN AS CONTENT: k6 is not installed in this environment.
// It would run with:  k6 run quote_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

// (1) options: the cast sheet. 20 VUs for 10 seconds.
export const options = {
  vus: 20,
  duration: '10s',
};

const BASE_URL = 'http://localhost:8000';

// (2) default: the script each VU repeats in a loop.
export default function () {
  // (3) http.post with JSON body and Content-Type header.
  const payload = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
  const params = { headers: { 'Content-Type': 'application/json' } };
  const res = http.post(`${BASE_URL}/quote`, payload, params);

  // (4) check: status 200 and correct price (7500 for Focus/basic/3h).
  check(res, {
    'status is 200': (r) => r.status === 200,
    'price is 7500': (r) => r.json('price_cents') === 7500,
  });

  // (5) sleep: half a second of think time between iterations.
  sleep(0.5);
}

The module's five pieces, in one file: options (lesson 6), default (lesson 2), http.post with body and headers (lesson 3), check (lesson 4), and sleep (lesson 5). If you had k6 installed, you'd run it with k6 run quote_test.js.

Step 3 — The k6 summary (content, read)

This is the summary quote_test.js would produce with 20 VUs / 10 s and sleep(0.5). Reference content, correct per k6's documentation, not run here:

     scenarios: (100.00%) 1 scenario, 20 max VUs, 10s max duration (incl. graceful stop):
              * default: 20 looping VUs for 10s (gracefulStop: 30s)

     ✓ status is 200
     ✓ price is 7500

     checks.........................: 100.00% ✓ 800       ✗ 0
     data_received..................: 82 kB   8.0 kB/s
     data_sent......................: 79 kB   7.7 kB/s
     http_req_duration..............: avg=2.8ms  min=0.3ms med=2.1ms max=115ms p(90)=4.3ms p(95)=6.0ms
     http_req_failed................: 0.00%   ✓ 0         ✗ 400
     http_reqs......................: 400     39.9/s
     iteration_duration.............: avg=0.5s   min=0.5s  med=0.5s  max=0.62s p(90)=0.5s  p(95)=0.5s
     iterations.....................: 400     39.9/s
     vus............................: 20      min=20      max=20
     vus_max........................: 20      min=20      max=20

running (0m10.0s), 00/20 VUs, 400 complete and 0 interrupted iterations
default ✓ [======================================] 20 VUs  10s

Read it with what you learned in lesson 7: vus: 20 (the concurrent), iterations: 400 (≈ vus × duration / think = 20 × 10 / 0.5 = 400 laps), http_reqs: 400 (one request per lap), checks: 800 (two per lap, all green). The http_req_duration block is there, with its avg and its percentiles, waiting for module 3.

Step 4 — The Python generator (the real flight)

And this is the deliverable that is run: the generator that models k6's VUs with ThreadPoolExecutor. Each worker is a VU that repeats default_fn() in a loop, does the same check (status 200 and correct price), and counts iterations, checks, and errors:

# loadgen.py — models k6's VUs with a ThreadPoolExecutor and IS RUN.
# Usage: python loadgen.py <base_url> <vus> <duration_s> [think_ms]
import sys, json, time, threading, random
import urllib.request, urllib.error
from concurrent.futures import ThreadPoolExecutor

BASE_URL, VUS, DURATION_S = sys.argv[1], int(sys.argv[2]), float(sys.argv[3])
THINK_MS = int(sys.argv[4]) if len(sys.argv) > 4 else 0

ROOM_RATES = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
TIERS = ["basic", "pro"]

def expected_price(room, tier, hours):
    total = ROOM_RATES[room] * hours
    if tier == "pro":
        total = total * 80 // 100
    return total

lock = threading.Lock()
c = {"iterations": 0, "status_ok": 0, "status_bad": 0,
     "price_ok": 0, "price_bad": 0, "http_errors": 0}

def default_fn(rng):
    """A VU's script: quote and verify (like default() in k6)."""
    room, tier, hours = rng.choice(list(ROOM_RATES)), rng.choice(TIERS), rng.randint(1, 8)
    payload = json.dumps({"room": room, "tier": tier, "hours": hours}).encode()
    req = urllib.request.Request(f"{BASE_URL}/quote", data=payload,
        headers={"Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=5) as res:
            status, body = res.status, json.loads(res.read())
    except Exception:
        with lock:
            c["iterations"] += 1; c["http_errors"] += 1
            c["status_bad"] += 1; c["price_bad"] += 1
        return
    ok_status = status == 200                                    # check 1
    ok_price = body.get("price_cents") == expected_price(room, tier, hours)  # check 2
    with lock:
        c["iterations"] += 1
        c["status_ok"] += ok_status; c["status_bad"] += (not ok_status)
        c["price_ok"] += ok_price;   c["price_bad"] += (not ok_price)
    if THINK_MS:
        time.sleep(THINK_MS / 1000.0)                           # think time (sleep)

def vu_loop(vu_id, deadline):
    rng = random.Random(vu_id)
    while time.perf_counter() < deadline:                       # the VU loop
        default_fn(rng)

def main():
    start = time.perf_counter(); deadline = start + DURATION_S
    with ThreadPoolExecutor(max_workers=VUS) as pool:           # VUS workers = VUS VUs
        for vu_id in range(1, VUS + 1):
            pool.submit(vu_loop, vu_id, deadline)
    elapsed = time.perf_counter() - start

    passed = c["status_ok"] + c["price_ok"]
    total = passed + c["status_bad"] + c["price_bad"]
    pct = passed / total * 100 if total else 0
    print(f"  vus............: {VUS}")
    print(f"  iterations.....: {c['iterations']}   ({c['iterations']/elapsed:.1f}/s)")
    print(f"  checks.........: {pct:.2f}%   ({passed} of {total})")
    print(f"    status is 200....: {c['status_ok']} ok / {c['status_bad']} fail")
    print(f"    price is correct.: {c['price_ok']} ok / {c['price_bad']} fail")
    print(f"  http_errors....: {c['http_errors']}")

if __name__ == "__main__":
    main()

With the server running (Step 1) and its port in port.txt, run it with 20 VUs, 10 seconds, and a 500 ms think time:

python3 loadgen.py "http://127.0.0.1:$(cat port.txt)" 20 10 500

What to expect. With sleep(0.5), each VU does ~2 iterations per second; 20 VUs × 10 s × 2 ≈ 400 iterations. Under 20 concurrent VUs against a local server, it's normal for some connection errors to appear —the operating system rejects some connection under the burst—, and that's real data, not a failure of the exercise. Real output in this environment:

  vus............: 20
  iterations.....: 408   (39.7/s)
  checks.........: 98.04%   (800 of 816)
    status is 200....: 400 ok / 8 fail
    price is correct.: 400 ok / 8 fail
  http_errors....: 8

Read it calmly, because it's the real flight:

  • vus: 20 and iterations: 408 — 20 concurrent users completed 408 laps in 10 seconds. The formula predicted ~400 (20 × 10 / 0.5); the real one was 408. The VU model, measured.
  • checks: 98.04% (800 of 816) — 816 total checks (408 iterations × 2), of which 800 passed. It's not 100%, and that's the interesting part: under real load, something failed.
  • http_errors: 8 — eight iterations failed to connect. Under 20 VUs hitting localhost at once, the OS rejected 8 connections. Those 8 iterations failed both their checks (status and price), which explains the 8 fail in each criterion and why the checks dropped below 100%.
  • The honest lesson. None of the single-digit VU runs (lessons 2-6) showed errors; this one, with 20 VUs, did. That's the essence of a load test: behavior changes under concurrency. With few users everything is green; as you raise the load, the errors appear that in production would be real users seeing a broken screen. Finding that point —and measuring it— is what all of this exists for.

The reflection: the mapping between the two faces

The fourth part of the deliverable is writing, in your own words, how the k6 script and the Python generator correspond. This table is the guide; complete it mentally (or in writing) by verifying each row in the two files you built:

PieceIn quote_test.js (k6, content)In loadgen.py (Python, executed)
Castoptions = { vus: 20, duration: '10s' }arguments VUS=20, DURATION_S=10
A VUa virtual user that loopsa ThreadPoolExecutor worker in vu_loop
The loopk6 wraps default (implicit)while time.perf_counter() < deadline
The scriptexport default function () {...}default_fn(rng)
The requesthttp.post(url, payload, params)urllib.request.urlopen(req)
The bodyJSON.stringify({...})json.dumps({...}).encode()
The headerparams.headers['Content-Type']headers={"Content-Type": ...}
Status check'status is 200': (r) => r.status === 200ok_status = status == 200
Price check'price is 7500': (r) => r.json('price_cents') === 7500ok_price = body[...] == expected_price(...)
Think timesleep(0.5)time.sleep(THINK_MS / 1000)
Summarychecks/iterations/vus blockthe printed checks/iterations/vus lines

And note the honest differences: k6's summary carries percentiles (p(90), p(95)) and separates network metrics (http_req_waiting, http_req_sending) that the Python generator doesn't yet compute —that's k6 machinery, and the metrics in depth are module 3—. The generator reports what this module needs: how many VUs, how many iterations, how many checks, how many errors. Both summaries read the same world; k6's reads it with more instruments.

Rubric for your mini-project

Your deliverable is complete if it meets these four parts:

  • (1) The k6 script (content). Does it have the five pieces —options with vus/duration, default, http.post with JSON body and header, check with status and price, sleep— correct and in place? Is the http.post in the order (url, body, params)? Does the body go with JSON.stringify and the Content-Type header?
  • (2) The k6 summary (content), read. Can you locate and explain vus, iterations, http_reqs, and checks, and say why iterations isn't equal to vus? Do you recognize that http_req_duration exists but is module 3's?
  • (3) The Python generator (executed). Does it run against the canonical API and produce real output with vus, iterations, checks, and http_errors? Does it use ThreadPoolExecutor with max_workers = vus (one worker per VU)? Does it do both checks (status and price)?
  • (4) The reflection. Can you map at least five pieces between the two faces (request, check, think time, cast, loop) and name one honest difference between the summaries?

If all four are there, you master the anatomy of the script and the VU model —the whole goal of the module—.

Common mistakes

Running the generator without starting the API first. What happens: someone runs loadgen.py without having launched reservo_api.py, and all iterations fail with a connection error (http_errors sky-high). Why it happens: there's no server listening on the port. How to detect it: http_errors equals iterations and the checks stay at 0%. How to fix it: start the API in one terminal and leave it running; in another, run the generator reading the port from port.txt. It's the two-terminal rule: server up, generator after.

Reading an old port from port.txt. What happens: someone restarts the API (which takes a new port, because it uses port 0) but the generator points to a previous port that no longer listens. Why it happens: port.txt was left from a previous run or was read before the new server rewrote it. How to detect it: high http_errors despite having "an" API running. How to fix it: make sure to read port.txt after starting the current API ($(cat port.txt) at the same moment you run the generator). With port 0, the port changes on each start.

Interpreting the errors under load as a failure of the exercise. What happens: someone sees the 8 http_errors with 20 VUs and thinks they did something wrong. Why it happens: the few-VU runs were 100% green, so a failure feels like their own error. How to detect it: the errors appear only on raising the concurrency, not with 1-5 VUs. How to fix it: understand that that's the result, not a bug. A load test exists precisely to reveal that the system behaves differently under concurrency. The 8 errors are valid data: under 20 VUs, ~2% of the connections didn't succeed. Measuring that is the job.

Exercises

Exercise 1 — Change the cast. Modify the generator to run with 10 VUs for 6 seconds and a 1000 ms think time. (a) How many approximate iterations do you expect? (b) Do you think you'll see http_errors, compared with the 20 VUs run? Justify.

See solution
  • (a) With sleep(1), each VU does ~1 iteration/s: 10 VUs × 6 s ≈ ~60 iterations. (The command would be python3 loadgen.py "http://127.0.0.1:$(cat port.txt)" 10 6 1000.)
  • (b) Probably fewer or no errors than with 20 VUs. Two reasons: there are half the concurrent VUs (10 vs 20), so fewer simultaneous connections pressuring the OS; and the think time is larger (1000 ms vs 500 ms), which spaces the requests out even more. Less concurrency and more pause = lower probability of rejected connections. That relationship —more VUs and less think time produce more stress— is exactly what a load test explores.

Exercise 2 — From the script to the summary. For the quote_test.js script with options = { vus: 20, duration: '10s' } and sleep(0.5), and with no errors, predict three lines of the k6 summary: (a) iterations; (b) http_reqs; (c) checks.

See solution
  • (a) iterations: ~400 — with sleep(0.5), each VU does ~2 laps/s: 20 × 10 × 2 = 400.
  • (b) http_reqs: ~400 — the script makes one request (http.post) per iteration, so requests ≈ iterations.
  • (c) checks: 100.00% ✓ 800 ✗ 0two checks per iteration (status and price): 400 × 2 = 800, all green if there are no errors.

(In the real Python run with the same configuration there were 408 iterations and 8 errors, so the checks were 800 of 816 = 98.04%. The k6 summary shown assumes a run with no errors; reality under 20 VUs introduced 8. Both are valid: one is the ideal model, the other the real measurement.)

Exercise 3 — Justify the bridge. Your reflection must explain why the Python generator is a faithful model of k6's VUs and not just "something similar." Choose three pieces of the k6 script and explain, for each, what models it in Python and why the correspondence is exact (not approximate).

See solution

Three exact correspondences (any of these works):

  • The VU and its loop. In k6, a VU repeats default in a loop k6 wraps. In Python, each ThreadPoolExecutor worker runs vu_loop, which is literally while time.perf_counter() < deadline: default_fn(). The correspondence is exact: N workers = N VUs, each repeating the script until the duration. The loop that in k6 is hidden, in Python is in plain sight, but it's the same loop.
  • The request with body and headers. http.post(url, JSON.stringify(payload), { headers: {...} }) in k6 sends a POST with JSON body and Content-Type. In Python, urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": ...}, method="POST") sends exactly the same: the same method, the same serialized body, the same header. The server receives identical bytes; it can't tell who sent them.
  • The check. check(res, { 'status is 200': (r) => r.status === 200 }) evaluates a condition on the response and counts it. In Python, ok_status = status == 200 followed by adding to status_ok/status_bad does the same: it evaluates the condition and keeps the count. The semantics —verify without aborting and count— are identical.

The correspondence is exact because both speak the same HTTP protocol against the same API and apply the same logic (same body, same price criterion with integer pro discount). It's not "similar": it's the same world, measured with two tools.

Summary and next step

In this mini-project you brought the whole module together in a four-part deliverable: a complete k6 script for /quote (content, with the five pieces —options, default, http.post, check, sleep—), its summary read (content), a Python generator that models the same VUs with ThreadPoolExecutor and its real run against the canonical API, and a reflection on the mapping between both faces. The real run —20 VUs, 408 iterations, 98.04% of checks, 8 errors— taught the most honest lesson of the guide so far: the system behaves differently under load. With few VUs everything was green; with 20, real connection errors appeared. Finding and measuring that change is the whole reason a load test exists.

With this you close module 2. You know how to read and write a k6 script understanding each piece, explain the VU model and the difference between VUs and iterations, read a summary, and —above all— model and measure that behavior yourself with Python against a real API. The simulator and the flight, and how they correspond.

What comes next, module 3, opens the metrics in depth: that http_req_duration block we've only located so far becomes the protagonist. You'll learn why the average lies and the percentiles (p90/p95/p99) rule, what throughput/RPS is, and how to read the error rate —and the Python generator will start computing real p95 and RPS with statistics, bringing its summary even closer to k6's—. The "how fast" we saved here is the heart of the next module.

Resources