Module 6: Checks Groups And Realistic Scenarios

8. Mini-project: a realistic Reservo scenario

Overview

This lesson is your graduation from module 6. Across seven lessons you built, piece by piece, everything that makes a load test resemble real use: the correctness check (verify status, value, and shape), the group() (organize and measure per step), the parametrization (vary the data so you don't hit a hot route), the correlation (extract a value and reuse it in the next step), and the think time with jitter (the human rhythm). Now you bring them all together in a single end-to-end deliverable: a realistic scenario of Reservo —quote → book → confirm— parametrized, with checks at each step and the booking_id really correlated, run against the canonical API, plus its k6 equivalent as content.

The deliverable has four parts, and all four matter: (1) the complete k6 script of the scenario (content), with group, check, SharedArray, and the correlation; (2) the canonical API with the GET /booking/<id> endpoint declared in this module; (3) the Python generator that runs the three-step scenario and its real run against the API, reporting the checks rate and the per-group metrics; and (4) a reflection on the mapping —which k6 piece corresponds to which Python line, and how the five module pieces appear in the scenario—. That fourth part ties what you wrote to what you measured: the point of the module isn't to have a script, but to understand the scenario it describes.

Connection to the module: here the arc closes. Lessons 2 through 7 gave you the pieces; this one puts you to producing the complete scenario with your own hands, as you would on the first day you write a realistic load test for a real API. The k6 script goes as labeled content (correct, not run here); the Python generator is actually run, and its output —8 VUs, 252 iterations, 100% of checks, 2016 of 2016— was measured in this environment with Python 3.14.0 against the canonical API. When you finish, you enter module 7 —analyze results and run in CI— with a realistic scenario already built and measured.

The dress rehearsal before opening night

Think of it this way. Before opening a play, the company does a dress rehearsal: the complete performance, from start to finish, with costumes, lights, and props, exactly as opening night will be. You don't rehearse an isolated scene or recite the script sitting down; you run the whole play, in order, to see if the pieces fit when they touch together —if the actress reaches her mark on time, if the scene change flows, if the second act's dialogue leans well on what happened in the first—. The dress rehearsal is where you discover what the piecemeal rehearsals don't reveal: the integration problems, the ones that only appear when everything runs together.

Your mini-project is that dress rehearsal. In the previous lessons you rehearsed each piece separately —the checks in lesson 2, the groups in lesson 4, the parametrization in lesson 5, the correlation in lesson 6, the think time in lesson 7—. Now you run the whole play: a scenario where a VU quotes a varied room (parametrization), verifies the price (correctness check), takes that price and books (correlation 1), verifies the booking (check), takes the id and confirms (correlation 2), verifies the confirmation (check), all organized in steps (groups) and with realistic pauses (think time with jitter). Running it complete is what proves the pieces fit when they touch together —that the chain of custody holds up under load, with varied data, across the 252 iterations—.

The mini-project is the dress rehearsal: run the complete realistic scenario —parametrization + checks + correlation + groups + think time— from start to finish, in order, under load. It's not rehearsing an isolated piece, it's seeing that they all fit when they touch together. That's what proves you have a scenario, not a collection of requests.

What you'll build

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

reservo-scenario/
├── reservo_api.py         # the canonical API + GET /booking/<id> (the load target)
├── scenario.js            # the scenario's k6 script (CONTENT: k6 is not installed)
└── scenario_loadgen.py    # the Python generator that runs the scenario (IS RUN)

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

Step 1 — The canonical API with GET /booking/<id>

It's the Reservo server, with the canonical endpoints (/rooms, /quote, /book) plus the GET /booking/<id> declared in this module, which saves each booking and returns it by its id. It uses port 0 (the operating system assigns a free port) and a high request_queue_size to withstand bursts:

# reservo_api.py (key fragment) - the canonical API + GET /booking/<id>.
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json, itertools, threading

ROOM_RATES_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
VALID_TIERS = {"basic", "pro"}
_booking_counter = itertools.count(1)
_bookings = {}                          # in-memory store: booking_id -> booking
_store_lock = threading.Lock()

def price_cents(room, tier, hours):
    total = ROOM_RATES_CENTS[room] * hours
    return total * 80 // 100 if tier == "pro" else total   # integer pro discount

class ReservoHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def log_message(self, *a): 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()]
            return self._send_json(200, {"rooms": rooms})
        if self.path.startswith("/booking/"):          # GET /booking/<id>: look up booking
            bid = self.path[len("/booking/"):]
            with _store_lock:
                booking = _bookings.get(bid)
            if booking is None:
                return self._send_json(404, {"error": "booking_not_found"})
            return self._send_json(200, booking)
        return 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":
            return self._send_json(200, {"price_cents": cents})
        if self.path == "/book":
            quoted = data.get("price_cents")            # correlation: quoted price
            if quoted is not None and quoted != cents:
                return self._send_json(409, {"error": "price_mismatch", "price_cents": cents})
            bid = f"bk-{next(_booking_counter):06d}"
            booking = {"booking_id": bid, "confirmed": True, "room": room,
                       "tier": tier, "hours": hours, "price_cents": cents}
            with _store_lock:
                _bookings[bid] = booking
            return self._send_json(200, booking)
        return self._send_json(404, {"error": "not_found"})

def main():
    ThreadingHTTPServer.request_queue_size = 512        # withstands bursts of connections
    server = ThreadingHTTPServer(("127.0.0.1", 0), ReservoHandler)  # port 0
    _, 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 scenario's k6 script (content)

This is the k6 deliverable: the complete scenario with the module's five pieces —SharedArray (parametrization), group (organization), check (correctness), correlation (price and id), and sleep with jitter (think time)—. It's shown as content, correct and verified against k6's documentation, but k6 isn't installed here:

// scenario.js - realistic quote -> book -> confirm scenario.
// SHOWN AS CONTENT: k6 is not installed in this environment.
// It would run with:  k6 run scenario.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';

export const options = {
  vus: 8,
  duration: '5s',
  thresholds: {
    checks: ['rate>0.99'],              // the verdict (M5): >99% of checks must pass
  },
};

// (Parametrization) the dataset, loaded once and shared.
const dataset = new SharedArray('reservations', function () {
  return [
    { room: 'Focus',     tier: 'basic', hours: 3, expected: 7500 },
    { room: 'Focus',     tier: 'pro',   hours: 3, expected: 6000 },
    { room: 'Studio',    tier: 'basic', hours: 2, expected: 8000 },
    { room: 'Studio',    tier: 'pro',   hours: 4, expected: 12800 },
    { room: 'Boardroom', tier: 'basic', hours: 1, expected: 8000 },
    { room: 'Boardroom', tier: 'pro',   hours: 6, expected: 38400 },
  ];
});

const BASE_URL = 'http://localhost:8000';
const params = { headers: { 'Content-Type': 'application/json' } };

function thinkTime(min, max) {           // (think time) pause with jitter
  return Math.random() * (max - min) + min;
}

export default function () {
  const row = dataset[Math.floor(Math.random() * dataset.length)];  // parametrized data
  let price, bookingId;

  // (group + check) Step 1: quote
  group('quote', function () {
    const body = JSON.stringify({ room: row.room, tier: row.tier, hours: row.hours });
    const res = http.post(`${BASE_URL}/quote`, body, params);
    check(res, {
      'status is 200': (r) => r.status === 200,
      'price is correct': (r) => r.json('price_cents') === row.expected,
    });
    price = res.json('price_cents');     // (correlation 1) extracts the price
  });

  sleep(thinkTime(0.5, 1.5));            // think time with jitter between steps

  // (group + check + correlation) Step 2: book
  group('book', function () {
    const body = JSON.stringify({ room: row.room, tier: row.tier,
                                  hours: row.hours, price_cents: price });  // uses the price
    const res = http.post(`${BASE_URL}/book`, body, params);
    check(res, {
      'status is 200': (r) => r.status === 200,
      'booking is confirmed': (r) => r.json('confirmed') === true,
      'booking_id present': (r) => String(r.json('booking_id')).startsWith('bk-'),
    });
    bookingId = res.json('booking_id');  // (correlation 2) extracts the id
  });

  sleep(thinkTime(0.5, 1.5));

  // (group + check + correlation) Step 3: confirm
  group('confirm', function () {
    const res = http.get(`${BASE_URL}/booking/${bookingId}`);   // uses the id
    check(res, {
      'status is 200': (r) => r.status === 200,
      'id matches': (r) => r.json('booking_id') === bookingId,
      'price matches quote': (r) => r.json('price_cents') === price,
    });
  });
}

The module's five pieces, in one file: SharedArray (lesson 5), group (lesson 4), check (lesson 2), the price and id correlation (lesson 6), and sleep with jitter (lesson 7). Plus the threshold on checks (module 5, reused in lesson 3) that gives the verdict. If you had k6 installed, you'd run it with k6 run scenario.js.

Step 3 — The Python generator (the one that runs)

And this is the deliverable that does run: the generator that runs the three-step scenario with ThreadPoolExecutor (one worker per VU), parametrized data, real price and id correlation, checks at each step, and think time with jitter. Its structure is the one you saw in lessons 5, 6, and 7:

# scenario_loadgen.py (core) - one iteration of the quote->book->confirm scenario.
def iteration(rng):
    row = rng.choice(DATASET)                       # (parametrization) varied data
    room, tier, hours = row["room"], row["tier"], row["hours"]
    want = expected_price(room, tier, hours)

    # ---- group: quote ----  (correctness check + extract price)
    s1, b1 = post_json(f"{BASE_URL}/quote", {"room": room, "tier": tier, "hours": hours})
    price = b1.get("price_cents")
    record("quote: status is 200", s1 == 200)
    record("quote: price is correct", price == want)

    # ---- group: book ----  (correlation 1: price -> book; extract booking_id)
    s2, b2 = post_json(f"{BASE_URL}/book",
                       {"room": room, "tier": tier, "hours": hours, "price_cents": price})
    booking_id = b2.get("booking_id")
    record("book: status is 200", s2 == 200)
    record("book: confirmed is true", b2.get("confirmed") is True)
    record("book: booking_id present", bool(booking_id) and str(booking_id).startswith("bk-"))

    # ---- group: confirm ----  (correlation 2: booking_id -> URL)
    s3, b3 = get_json(f"{BASE_URL}/booking/{booking_id}")
    record("confirm: status is 200", s3 == 200)
    record("confirm: id matches", b3.get("booking_id") == booking_id)
    record("confirm: price matches quote", b3.get("price_cents") == price)

    think(rng, THINK_MS)                             # (think time with jitter)

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

python3 scenario_loadgen.py "http://127.0.0.1:$(cat port.txt)" 8 5 150

What to expect. With a 150 ms think time (with jitter), 8 VUs should produce on the order of hundreds of iterations, with 8 checks per iteration (2 from the quote + 3 from the booking + 3 from the confirmation), all green if the chain of custody holds up. Real output in this environment:

----------------------------------------------------------------
  Scenario quote->book->confirm  ->  8 VUs / 5s / think 150ms
----------------------------------------------------------------
  vus............: 8
  real duration..: 5.18s
  iterations.....: 252   (48.6/s)
  checks.........: 100.00%   (2016 of 2016)
    quote: status is 200..............:  252 ok / 0    fail
    quote: price is correct...........:  252 ok / 0    fail
    book: status is 200...............:  252 ok / 0    fail
    book: confirmed is true...........:  252 ok / 0    fail
    book: booking_id present..........:  252 ok / 0    fail
    confirm: status is 200............:  252 ok / 0    fail
    confirm: id matches...............:  252 ok / 0    fail
    confirm: price matches quote......:  252 ok / 0    fail
  http_errors....: 0
  per group (avg latency, n calls):
    group 'quote  '.....: n=252  avg=1.35ms
    group 'book   '.....: n=252  avg=0.78ms
    group 'confirm'.....: n=252  avg=0.63ms

Read it as the dress rehearsal that went well:

  • iterations: 252 (48.6/s) — 8 concurrent VUs completed 252 three-step run-throughs in ~5 seconds, with a realistic 150 ms think time (plus the jitter). Each run-through with possibly different data from the dataset.
  • checks: 100.00% (2016 of 2016) — 8 checks per iteration × 252 iterations = 2016 checks, all green. The whole play held: each quote gave the correct price, each booking was confirmed with a real booking_id, and each confirmation found that id with that price.
  • confirm: id matches: 252 ok / 0 fail and confirm: price matches quote: 252 ok / 0 fail — the proof that the correlation held up under load. In the 252 iterations, with 8 VUs stepping on each other, each one extracted its own booking_id (all different) and its own price, and reused them correctly. The chain of custody didn't cross or break even once.
  • The per-group breakdown (quote 1.35 ms, book 0.78 ms, confirm 0.63 ms, 252 each) confirms the three steps ran in each iteration, with the quote as the heaviest (lesson 4).
  • http_errors: 0 — with a high request_queue_size and 8 VUs, the server withstood all the connections. (Under more load, connection errors could appear, and that would be real data, as you saw in module 2's mini-project.)

Step 4 — The reflection: the five pieces in the scenario

The fourth part of the deliverable is writing how the module's five pieces appear in the scenario, and how the two faces (k6 and Python) correspond. This table is the guide:

Module pieceIn scenario.js (k6, content)In scenario_loadgen.py (Python, executed)
Parametrization (L5)SharedArray + dataset[Math.floor(...)]DATASET + rng.choice(DATASET)
Group (L4)group('quote', () => {...})group_calls["quote"] (per-group latency)
Correctness check (L2)check(res, { 'price is correct': ... })record("quote: price is correct", price == want)
Correlation 1: price (L6)price = res.json('price_cents')/book's bodyprice = b1.get("price_cents")/book's body
Correlation 2: id (L6)bookingId = res.json('booking_id') → URLbooking_id = b2.get("booking_id") → URL
Think time with jitter (L7)sleep(thinkTime(0.5, 1.5))think(rng, THINK_MS) with uniform(0.5, 1.5)
Verdict (M5, L3)thresholds: { checks: ['rate>0.99'] }a gate on the checks rate (exit 0/1)

And note the honest differences: the k6 summary would carry percentiles (p(95)) and a group_duration with avg/min/max/percentiles per group, while the Python generator reports the average latency per group (the in-depth metrics were module 3). Both run the same scenario against the same API; the k6 one instruments it with more metrics. The essential —parametrization, checks, correlation, groups, think time— is identical on both faces.

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 —SharedArray, group per step, correctness check, correlation of the price and the id, sleep with jitter— correct and in place? Does the correlation extract the value from one response and reuse it in the next (price in /book's body, id in /booking/<id>'s URL)? Is there a threshold on checks that gives the verdict?
  • (2) The canonical API. Does it run with the canonical endpoints (/rooms, /quote, /book) plus the declared GET /booking/<id>? Does /quote return 7500 for Focus/basic/3h and 6000 for Focus/pro/3h? Does /book save the booking and /booking/<id> return it?
  • (3) The Python generator (executed). Does it run the three-step scenario against the canonical API and produce real output with the checks rate, the per-criterion breakdown, and the per-group metrics? Does it parametrize the data (pick from the dataset), correlate the price and the id for real, and do checks at each step? Are the id matches and price matches quote checks green (proof that the correlation worked)?
  • (4) The reflection. Can you locate the module's five pieces in the scenario (parametrization, group, check, correlation, think time) and map at least five between the two faces? Do you name an honest difference between the summaries?

If all four are there, you master the construction of a realistic scenario —the whole goal of the module—.

Common mistakes

Running the generator without starting the API first (or with an old port). What happens: scenario_loadgen.py is run without reservo_api.py up, or pointing to a port from a previous run (remember port 0 changes on each start). How to detect it: sky-high http_errors and checks at 0%. How to fix it: start the API in one terminal, leave it running, and in another run the generator reading port.txt at the same moment ($(cat port.txt)). Server up, generator after.

An id matches in red: the correlation broke. What happens: the scenario runs but the confirm: id matches check (or the confirm step's status is 200) fails in many iterations. Why it happens: the booking_id isn't being extracted well —maybe it was hardcoded, or read from the wrong response, or stored inside the group and lost—. How to detect it: the confirm step gives 404 or an id that doesn't match. How to fix it: verify you extract the id from /book's response (b2.get("booking_id")) and use it in step 3's URL. It's lesson 6's correlation; the green check is its proof.

Reporting only "252 iterations" without the checks rate or the think time. What happens: someone summarizes their run with the iterations and that's it. Why it happens: the iteration feels like "the result." How to detect it: if your report doesn't say how many checks passed or with what think time you ran, it's missing the essentials. How to fix it: report the checks rate (100%, 2016/2016), the per-step breakdown, the per-group metrics, and the context (VUs and think time). A scenario is judged by whether it did the right thing (checks) and at what pace (VUs + think time), not just by how many times it ran.

Exercises

Exercise 1 — Change the cast and predict. You're going to run the generator with 4 VUs for 4 seconds and a 300 ms think time. (a) More or fewer iterations than the 8 VUs / 5 s / 150 ms run, and why? (b) How many checks per iteration do you expect and why? (c) Do you expect id matches to stay green?

See solution
  • (a) Quite a few fewer. There are half the VUs (4 vs 8), less time (4 s vs 5 s), and double the think time (300 ms vs 150 ms), which spaces out the iterations more. All three factors reduce the iterations. (The command would be python3 scenario_loadgen.py "http://127.0.0.1:$(cat port.txt)" 4 4 300.)
  • (b) 8 checks per iteration: 2 from the quote step (status, price), 3 from the booking (status, confirmed, id present), and 3 from the confirmation (status, id matches, price matches).
  • (c) Yes. The correlation doesn't depend on the load: each iteration extracts its own booking_id and reuses it. As long as the API is healthy and the id is extracted well, id matches stays green with any number of VUs.

Exercise 2 — Locate the five pieces. In the scenario.js script, point out the line (or construct) that corresponds to each of the module's five pieces: (a) parametrization, (b) group, (c) correctness check, (d) id correlation, (e) think time with jitter.

See solution
  • (a) Parametrization: const dataset = new SharedArray(...) and const row = dataset[Math.floor(Math.random() * dataset.length)].
  • (b) Group: group('quote', function () {...}) (and its siblings group('book', ...), group('confirm', ...)).
  • (c) Correctness check: any check(res, { 'price is correct': (r) => r.json('price_cents') === row.expected }).
  • (d) Id correlation: bookingId = res.json('booking_id') in the booking step, reused in `${BASE_URL}/booking/${bookingId}` in the confirmation.
  • (e) Think time with jitter: sleep(thinkTime(0.5, 1.5)) between the groups.

Exercise 3 — Interpret the dress rehearsal. The real run gave checks: 100.00% (2016 of 2016) with http_errors: 0 and the per-group breakdown (quote 1.35 ms, book 0.78 ms, confirm 0.63 ms). Write, in three or four sentences, the verdict you'd give this test: what it proved, what it didn't prove, and what you'd do next.

See solution

A reasonable verdict: the test passed the dress rehearsal. With 8 concurrent VUs and parametrized data, the quote → book → confirm scenario held across the 252 iterations with 100% of checks —the price always correct, the booking always confirmed, the booking_id correlation intact (id and price match in all 252)— and with no connection errors. The pieces fit when they touch together.

What it didn't prove: how it behaves under high load (8 VUs is little; with a ramp profile to hundreds of VUs —module 4— connection errors or queued latencies could appear), nor does it set an automatic verdict (for that you need the threshold on checks running in a pipeline). What I'd do next: raise the load with stages (M4), put the threshold as a gate, and take it to CI so it runs on every deploy (M7). This realistic scenario is the base on which those steps are built.

Summary and next step

In this mini-project you brought the whole module together in a four-part deliverable: a k6 script of the scenario (content, with the five pieces —SharedArray, group, check, price and id correlation, sleep with jitter— plus the verdict threshold), the canonical API with the declared GET /booking/<id>, a Python generator that runs the three-step scenario and its real run against the API, and a reflection on the mapping. The real run —8 VUs, 252 iterations, 100% of checks (2016/2016), 0 errors— was the dress rehearsal that went well: with id matches and price matches quote green across the 252 iterations, it proved the correlation's chain of custody holds up under load, with varied data, when all the pieces touch together.

With this you close module 6. You know how to build a load test that resembles real use: verify correctness (not just the response) with checks, organize the steps with groups, vary the data with parametrization so you don't hit a hot route, chain the steps with correlation (quote → book → confirm), and give it a human rhythm with think time and jitter. The collection of isolated requests became a scenario.

What comes next, module 7, is the "after" of having a scenario that runs: analyzing its results and taking it to CI. You'll learn to read a run's trend, to export the metrics (--out json), to detect a performance regression by comparing two runs, and to set up the scenario in a GitHub Actions pipeline (content) with the threshold as a gate that fails the deploy. The realistic scenario you built here is exactly what that pipeline will run.

Resources