Module 8: Project Load Test Reservo

2. The quote→book scenario with checks

Overview

The heart of any load script is the default function: the script each virtual user repeats in a loop. In this lesson we build that script for Reservo, and not a single request but a realistic two-step flow: quote (POST /quote) and then book (POST /book) —what a real client does: first asks the price, then confirms—. Each step carries its correctness check()s (status 200, correct price_cents, confirmed: true, booking_id present), the data is parametrized (different rooms, tiers, and hours on each iteration), and between iterations goes a sleep() with jitter so the traffic doesn't arrive in lockstep. We write the scenario in k6 (content) and run it for real in Python, seeing its checks pass at 100%.

Connection to the module: this is the first of the capstone's four pieces —the scenario the following lessons will wrap in a profile (M4, lesson 3), some thresholds (M5, lesson 4), and a staged run (lesson 5)—. It brings together two modules: the script's anatomy and http.post (M2) and the check()s, the correlation, the parametrized data, and the realistic think time (M6). Here we don't re-explain what a check is or how http.post works; we use them to assemble the complete quote→book flow. If you need to review the mechanics, M2 and M6 teach it; this lesson integrates it.

The client who asks the price before booking

Think about how you really book a room in Reservo. You don't land on a "book" button and pay blindly. First you ask the price: you pick the room, the tier, and the hours, and the system tells you "that's $75.00." Only then, if the price convinces you, do you confirm the booking and the system returns a confirmation number. It's two steps, in order, and the second depends on the first: you book what you quoted.

An honest load test imitates that flow, not a single piece of it. Hitting only /quote a thousand times measures the pricing engine, but doesn't measure what the user experiences —which is two chained calls—. The quote→book scenario raises VUs that do both things in order, like real clients: they quote, and with what they quoted, they book. That's the difference between measuring an API and measuring the path the user travels through it. And since the user waits a few seconds between seeing the price and deciding, the scenario also includes that think time —with a bit of randomness, because not all clients take the same time—.

The scenario in k6 (content)

Here's the capstone's default function: the complete quote→book scenario, with its checks and its think time. Remember: labeled content, correct and faithful to k6's documentation, not run here (k6 isn't installed).

// CONTENT (not run here): k6 is not installed.
// Reference: grafana.com/docs/k6 (http, check, sleep).
import http from 'k6/http';
import { check, sleep } from 'k6';

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

// Parametrized data: each iteration picks a room, a tier, and some hours.
const ROOMS = ['Focus', 'Studio', 'Boardroom'];
const RATES = { Focus: 2500, Studio: 4000, Boardroom: 8000 }; // cents/hour
const TIERS = ['basic', 'pro'];

function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }

// The expected price, for the correctness check (integer pro discount).
function expectedPrice(room, tier, hours) {
  let total = RATES[room] * hours;
  if (tier === 'pro') total = Math.floor((total * 80) / 100);
  return total;
}

export default function () {
  const room = pick(ROOMS);
  const tier = pick(TIERS);
  const hours = Math.floor(Math.random() * 8) + 1; // 1..8
  const want = expectedPrice(room, tier, hours);
  const params = { headers: { 'Content-Type': 'application/json' } };

  // Step 1 — quote.
  const quoteBody = JSON.stringify({ room, tier, hours });
  const quote = http.post(`${BASE_URL}/quote`, quoteBody, params);
  check(quote, {
    'quote status is 200': (r) => r.status === 200,
    'quote price is correct': (r) => r.json('price_cents') === want,
  });

  // Step 2 — book the same order (correlation: you book what you quoted).
  const bookBody = JSON.stringify({ room, tier, hours });
  const book = http.post(`${BASE_URL}/book`, bookBody, params);
  check(book, {
    'book status is 200': (r) => r.status === 200,
    'book is confirmed': (r) => r.json('confirmed') === true,
    'book has booking_id': (r) => typeof r.json('booking_id') === 'string',
  });

  // Think time with jitter: each VU waits between 0.5 s and 1.0 s (not in lockstep).
  sleep(0.5 + Math.random() * 0.5);
}

Read it with what you already know from M2 and M6, noticing the four decisions that make it a scenario and not a single request:

  • The two-step flow. The VU does http.post to /quote and then to /book, in order. It's the user's path, not an isolated endpoint. Each HTTP request counts toward the test's metrics, so each iteration generates two requests.
  • The correctness check()s. Five checks in total: two on the quote (status 200 and correct price_cents) and three on the booking (status 200, confirmed: true, booking_id present). A check does not abort the iteration (that's a threshold, M5/M6): it records whether the response was correct and moves on. Under load we want to know not only whether the server responded fast, but whether it responded correctly —a 200 with a wrong price is a silent failure only a check catches—.
  • The parametrized data. Each iteration picks room, tier, and hours at random, and the price check uses expectedPrice(...) to know what to expect from that order. Always hitting the same Focus/basic/3h would measure a single route; varying the data exercises the three rooms and the two tiers, closer to real traffic.
  • The sleep with jitter. sleep(0.5 + Math.random() * 0.5) makes each VU wait between half a second and one second, different on each iteration. Without the jitter, all VUs would quote, book, and sleep at the same time, in synchronized waves (lockstep) that don't look like real traffic. The jitter desynchronizes the users, as in life.

The correlation here is simple —we book the same order we quoted— but the pattern is the one you'd use with real data: extract a value from the first response (a booking_id, a token, a cart id) and use it in the next. In Reservo the book doesn't need the quote result to work, but the scenario respects the user's order: quote first, book after.

The scenario run in Python

And this is the scenario that does run: the same quote→book flow, written in Python, run a few times against the canonical API to see it work before scaling it to load. Each iteration makes the two requests and evaluates the five checks:

# scenario_demo.py — the quote->book scenario with checks, a few iterations.
# RUNS against Reservo. Usage: python3.14 scenario_demo.py <base_url> [n]
import json, sys, urllib.request

BASE_URL = sys.argv[1]
N = int(sys.argv[2]) if len(sys.argv) > 2 else 6
HOURLY_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}


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


def post(path, payload):
    data = json.dumps(payload).encode()
    req = urllib.request.Request(BASE_URL + path, data=data,
                                 headers={"Content-Type": "application/json"}, method="POST")
    with urllib.request.urlopen(req, timeout=10) as res:
        return res.status, json.loads(res.read())


# Parametrized data: different rooms / tiers / hours (M6).
ORDERS = [
    ("Focus", "basic", 3), ("Focus", "pro", 3), ("Studio", "basic", 2),
    ("Boardroom", "pro", 4), ("Studio", "pro", 1), ("Boardroom", "basic", 5),
]

total_checks = passed = 0
for room, tier, hours in ORDERS[:N]:
    want = expected_price(room, tier, hours)
    st_q, body_q = post("/quote", {"room": room, "tier": tier, "hours": hours})   # quote
    st_b, body_b = post("/book", {"room": room, "tier": tier, "hours": hours})    # book
    checks = {
        "quote status 200": st_q == 200,
        "quote price ok": body_q.get("price_cents") == want,
        "book status 200": st_b == 200,
        "book confirmed": body_b.get("confirmed") is True,
        "book has id": isinstance(body_b.get("booking_id"), str),
    }
    total_checks += len(checks); passed += sum(checks.values())
    mark = "OK " if all(checks.values()) else "XX "
    print(f"{mark}{room:<9}/{tier:<5}/{hours}h  quote={body_q.get('price_cents')}  "
          f"want={want}  book={body_b.get('booking_id')}  confirmed={body_b.get('confirmed')}")
print(f"checks: {passed}/{total_checks} ({passed/total_checks*100:.2f}%)")

With the API running, we launch it:

What to expect — the six iterations quote and book; each price_cents matches the expected one (the anchor numbers and their variants), each booking is confirmed with a booking_id, and the checks land at 100%. Real output against Reservo:

$ python3.14 scenario_demo.py http://127.0.0.1:PORT 6
OK Focus    /basic/3h  quote=7500  want=7500  book=bk_102372  confirmed=True
OK Focus    /pro  /3h  quote=6000  want=6000  book=bk_102373  confirmed=True
OK Studio   /basic/2h  quote=8000  want=8000  book=bk_102374  confirmed=True
OK Boardroom/pro  /4h  quote=25600  want=25600  book=bk_102375  confirmed=True
OK Studio   /pro  /1h  quote=3200  want=3200  book=bk_102376  confirmed=True
OK Boardroom/basic/5h  quote=40000  want=40000  book=bk_102377  confirmed=True
checks: 30/30 (100.00%)

Read it slowly, because it's the scenario working:

  • The anchor numbers and their variants. Focus/basic/3h → 7500, Focus/pro/3h → 6000 (the guide's two anchors), Boardroom/pro/4h → 25600 (8000 × 4 = 32000, × 80 // 100 = 25600), Studio/pro/1h → 3200 (4000 × 80 // 100). Each quote matches its want: the price check passes because the server's logic and the scenario's compute the same thing.
  • The correlation in action. Each iteration quoted and then booked the same order, receiving a different booking_id (bk_102372, bk_102373, ...) and confirmed=True. The two-step flow ran in order, six times.
  • checks: 30/30 (100.00%). Five checks per iteration × six iterations = 30 checks, all green. With the API healthy and without load, the correctness is perfect. The interesting question —does it stay at 100% under load?— is what the following lessons measure when scaling this same scenario to dozens of concurrent VUs.

This is the usual mapping between the two faces: k6's http.post is Python's urllib.request; k6's check(res, {...}) is the boolean evaluation that here counts in checks; k6's sleep with jitter is the think time the staged generator will add in lesson 5. Same scenario, two languages.

Common mistakes

Measuring a single request instead of the user's flow. What happens: a scenario is written that only does POST /quote a thousand times, and it's reported as "Reservo's load." Why it happens: a single request is easier to write. How to detect it: if your default has a single http.post, you aren't measuring the user's path (who quotes and books). How to fix it: model the flow —the steps in order a real client travels—. A one-step scenario measures an endpoint; a multi-step one measures the experience.

Verifying only the status and not the correctness. What happens: the scenario checks status === 200 and takes any 200 as good, even if the price is wrong. Why it happens: the status is the first thing that comes to mind. How to detect it: if your checks don't compare price_cents against the expected, a 200 with a broken price passes as good. How to fix it: add the correctness check (price_cents === want, confirmed === true). Under load, a stressed server can return 200 with corrupt data; only a content check catches it. "Fast and with status 200" is not the same as "correct."

Forgetting the jitter and sending the traffic in lockstep. What happens: a fixed sleep(1) is used, and all VUs act in perfectly synchronized waves. Why it happens: a fixed sleep is the first thing one writes. How to detect it: if all your VUs have the same exact think time, they generate artificial peaks every second that don't look like real traffic. How to fix it: add jittersleep(0.5 + Math.random() * 0.5) in k6, time.sleep(random.uniform(0.5, 1.0)) in Python—. Real traffic is desynchronized; the jitter imitates it.

Exercises

Exercise 1 — Predict the checks. For the Studio/pro/2h order, say what price_cents the scenario expects and how many of the five checks would pass if the server responds {"price_cents": 6400} on /quote and {"booking_id": "bk_000123", "confirmed": true, "price_cents": 6400} on /book.

See solution
  • Expected price: Studio = 4000/h, 2h = 8000; pro: 8000 × 80 // 100 = 6400. The scenario expects want = 6400.
  • The five checks: quote status 200 ✓, quote price ok (6400 === 6400) ✓, book status 200 ✓, book confirmed (true) ✓, book has booking_id ("bk_000123" is a string) ✓. All five pass (5/5).

The server responded correctly: the price matches the expected and the booking was confirmed with an id. If /quote had returned 6401, the second check would fail (4/5), even though the status was still 200 —exactly the silent failure the correctness check catches—.

Exercise 2 — Why two requests per iteration. The scenario does POST /quote and POST /book on each iteration. (a) If you run 1000 iterations, how many HTTP requests does the test generate? (b) How many checks? (c) Why does this distinction matter when reading the metrics?

See solution
  • (a) 1000 × 2 = 2000 HTTP requests (one to /quote, another to /book per iteration).
  • (b) 1000 × 5 = 5000 checks (two on the quote, three on the booking).
  • (c) Because http_reqs (requests) is not equal to iterations. In a one-step scenario, requests ≈ iterations; in this two-step one, requests = 2 × iterations. When reading the RPS or the error rate you have to know that each iteration weighs two requests —otherwise you misinterpret the throughput—. The error rate, moreover, is measured over the requests (2000), not over the iterations.

Exercise 3 — Add a step to the flow. You want the scenario, after booking, to verify the booking with a third step GET /rooms (to simulate the user returning to the list). Describe how you'd add it to k6's default and what check you'd put on it, and say how many requests per iteration the scenario would have then.

See solution

After the check(book, {...}), I'd add a third request and its check:

const rooms = http.get(`${BASE_URL}/rooms`);
check(rooms, {
  'rooms status is 200': (r) => r.status === 200,
  'rooms has 3 rooms': (r) => r.json('rooms').length === 3,
});

The scenario would then have three requests per iteration (/quote, /book, /rooms), so 1000 iterations would generate 3000 HTTP requests. The think time (sleep with jitter) would go at the end, after the third step. Each new step you add to the flow adds its requests and its checks to the total —modeling the user's complete path costs more requests, but measures what really matters—.

Summary and next step

In this lesson you built the heart of the capstone: the default function with the quote→book scenario. Not a single request, but the two-step flow a real client travels —POST /quote and then POST /book—, with a correctness check() on each step (status, price_cents, confirmed, booking_id), parametrized data (variable rooms, tiers, and hours), and a sleep() with jitter to desynchronize the VUs. You wrote it in k6 (content) and ran it for real in Python: six iterations, the correct anchor numbers, and checks 30/30 (100%).

You integrated two modules: the script's anatomy and http.post (M2), and the check()s, the correlation, the parametrized data, and the realistic think time (M6). Before moving on you should be able to: write a multi-step scenario with its checks; explain why the correctness is verified and not just the status; and say how many requests and checks an iteration of this scenario generates.

What comes next, in lesson 3, is to wrap this scenario in a load shape: the smoke→load→stress profile with stages (M4). The scenario says what each VU does; the profile says how many VUs there are and when —from a soft warm-up to a peak that pushes the system until you see where it bends—.

Resources

  • k6 — check() — the official reference for the check that verifies the response's correctness without aborting the iteration; the scenario's five checks come from here.
  • k6 — The Response object and res.json() — how the check reads price_cents, confirmed, and booking_id from the response body. The piece that makes the correctness check possible.
  • k6 — sleep() — the think time between iterations; with Math.random() you add the jitter that desynchronizes the VUs.
  • urllib.request — Python documentation — the standard library's HTTP client the executed scenario uses to do POST /quote and POST /book. The equivalent of k6's http.post.