Module 8: Project Load Test Reservo

8. Project: the complete Reservo load test

Overview

This is the deliverable. In the seven previous lessons you built each piece; here you join them all into a complete load test of the Reservo API and deliver it. Four pieces that fit together: the complete k6 script (content) —the quote→book scenario with check(), sleep() with jitter, smoke→load→stress stages, and thresholds tied to the SLO—, the canonical API as the target, the executable run in Python with its metrics and its green and red gate (with real exit codes and the exported results.json), and the CI load.yml (content). You close with a rubric of what makes a load test good. And since it's the last lesson, you also close the guide: the arc of the eight modules and where to continue.

Connection to the module: this lesson is the synthesis. It adds nothing new; it assembles the four pieces from lessons 2 to 7 into a deliverable artifact and judges it against a rubric. It's the complete symphony we spoke of in lesson 1: each section playing at once, in order, forming a single piece. When you finish you'll have —and know how to explain— a load test from start to finish, and you'll have closed the guide's journey.

What you're going to deliver

A project with four files that form the complete load test:

reservo-load-test/
├── reservo_server.py       # the canonical Reservo API (the target)
├── quote_book_test.js      # the k6 script (CONTENT: k6 is not installed)
├── loadtest.py             # the executable run: scenario + stages + gate + export
└── .github/workflows/
    └── load.yml            # the CI (CONTENT): the threshold as the deploy's gate

We go through them in order, and at the end we run the test in its two faces (green and red) and judge it with the rubric.

Piece 1 — The complete k6 script (content)

This is the k6 deliverable: the scenario, the profile, and the thresholds from lessons 2, 3, and 4, in a single file. Labeled content, faithful to k6's documentation, not run here (k6 isn't installed):

// quote_book_test.js — the complete Reservo load test (CAPSTONE).
// CONTENT (not run here): k6 is not installed.
// It would run with:  k6 run quote_book_test.js --out json=results.json
// Reference: grafana.com/docs/k6
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://127.0.0.1:8000';

// Parametrized data (M6): rooms, rates (cents/hour), and tiers.
const ROOMS = ['Focus', 'Studio', 'Boardroom'];
const RATES = { Focus: 2500, Studio: 4000, Boardroom: 8000 };
const TIERS = ['basic', 'pro'];
function pick(a) { return a[Math.floor(Math.random() * a.length)]; }
function expectedPrice(room, tier, hours) {
  let total = RATES[room] * hours;
  if (tier === 'pro') total = Math.floor((total * 80) / 100); // integer pro discount
  return total;
}

export const options = {
  // PROFILE (M4): smoke -> load -> stress -> ramp-down.
  stages: [
    { duration: '30s', target: 5 },   // smoke
    { duration: '1m',  target: 5 },
    { duration: '1m',  target: 20 },  // load
    { duration: '3m',  target: 20 },
    { duration: '1m',  target: 80 },  // stress
    { duration: '3m',  target: 80 },
    { duration: '1m',  target: 0 },   // ramp-down
  ],
  // THRESHOLDS tied to the SLO (M5). A broken threshold -> k6 exits with 99 -> gate.
  thresholds: {
    http_req_duration: ['p(95)<200'],  // latency
    http_req_failed: ['rate<0.01'],    // availability
    checks: ['rate>0.99'],             // correctness
  },
};

// SCENARIO (M2/M6): quote -> book, with checks and think time with jitter.
export default function () {
  const room = pick(ROOMS);
  const tier = pick(TIERS);
  const hours = Math.floor(Math.random() * 8) + 1;
  const want = expectedPrice(room, tier, hours);
  const params = { headers: { 'Content-Type': 'application/json' } };

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

  const book = http.post(`${BASE_URL}/book`, JSON.stringify({ room, tier, hours }), 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',
  });

  sleep(0.5 + Math.random() * 0.5); // think time with jitter
}

The whole capstone in one file: the options with stages (M4) and thresholds (M5), and the default function with the quote→book scenario, its five correctness check()s (M2, M6), and the sleep() with jitter (M6). A team with k6 installed would run it with k6 run quote_book_test.js --out json=results.json.

Piece 2 — The canonical API (the target)

The target is the module-1 Reservo API, reused without changes, plus the /quote_cpu endpoint we reuse from M5 to be able to see the red gate. You already know it: GET /rooms, POST /quote{price_cents}, POST /book{booking_id, confirmed}, with the anchor numbers 7500 and 6000, money in integer cents, port 0. We don't repeat it here (it's in module 1); we just recall that it's what the other pieces hammer.

Piece 3 — The executable run with its gate (green and red)

And this is the piece that does run: loadtest.py, the generator that runs the quote→book scenario in stages against the API, measures p50/p95/p99, RPS, and error rate, evaluates the thresholds over the whole run, exports the metrics to JSON, and exits with an exit code. It's the executable mirror of the k6 script. We run it in its two faces.

The green gate (healthy build, /quote).

What to expect — with the fast endpoint, even the peak gives a p95 of tens of milliseconds; the aggregate stays well below the SLO; the three thresholds pass; exit 0. Real output:

$ python3.14 loadtest.py http://127.0.0.1:PORT /quote green.json
LOAD TEST — quote->book scenario against /quote
profile: smoke(5) -> load(20) -> stress(80) VUs
--------------------------------------------------------------------------
stage     VUs    reqs      RPS      p50      p95      p99   error   checks
--------------------------------------------------------------------------
smoke       5   23532   5881.7     0.79     1.20     1.42   0.00%  100.00%
load       20   26232   5242.1     3.61     5.94     7.37   0.00%  100.00%
stress     80   31020   5156.6    14.70    25.51    31.42   0.00%  100.00%
--------------------------------------------------------------------------

THRESHOLDS (evaluated over the whole run — like k6)
--------------------------------------------------------------------------
THRESHOLD                             MEASURED              RESULT
http_req_duration: p(95) < 200ms      p(95) = 21.72ms       PASS
http_req_failed:   rate < 1.00%       rate  = 0.00%         PASS
checks:            rate > 99.00%      rate  = 100.00%       PASS
--------------------------------------------------------------------------
metrics exported -> green.json
GATE: PASS  (exit code 0)
$ echo $?
0

The red gate (heavy pricing engine, /quote_cpu).

What to expect — the same test; under the stress the p95 crosses the SLO; the latency threshold fails; exit 1. Real output:

$ python3.14 loadtest.py http://127.0.0.1:PORT /quote_cpu red.json
LOAD TEST — quote->book scenario against /quote_cpu
profile: smoke(5) -> load(20) -> stress(80) VUs
--------------------------------------------------------------------------
stage     VUs    reqs      RPS      p50      p95      p99   error   checks
--------------------------------------------------------------------------
smoke       5    2326    579.7     8.68    14.35    17.06   0.00%  100.00%
load       20    2960    586.9    34.25    57.80    60.93   0.00%  100.00%
stress     80    3522    575.7   138.48   245.24   259.11   0.00%  100.00%
--------------------------------------------------------------------------

THRESHOLDS (evaluated over the whole run — like k6)
--------------------------------------------------------------------------
THRESHOLD                             MEASURED              RESULT
http_req_duration: p(95) < 200ms      p(95) = 229.19ms      FAIL
http_req_failed:   rate < 1.00%       rate  = 0.00%         PASS
checks:            rate > 99.00%      rate  = 100.00%       PASS
--------------------------------------------------------------------------
metrics exported -> red.json
GATE: FAIL  (exit code 1)
$ echo $?
1

There's the whole load test, in its two verdicts. Green with the healthy load: the healthy build meets the SLO comfortably, the gate passes, the deploy is authorized. Red when the pricing engine gets heavy: under smoke (p95 14.35) and load (p95 57.80) the system meets it, but the stress (p95 245.24) breaks the SLO, the aggregate (229.19 ms) crosses the 200, the latency threshold fails, and the gate blocks the deploy —with its real exit code—. The error stayed at 0% and the checks at 100% in both: the degradation was of latency under load, not of availability or correctness. Exactly what a stress test exists to catch.

And the record that remains: the exported green.json of the healthy run —the notebook a pipeline would upload as an artifact—:

$ cat green.json
{
  "scenario": "quote->book",
  "quote_path": "/quote",
  "slo": { "p95_ms": 200.0, "error_rate": 0.01, "checks_rate": 0.99 },
  "aggregate": {
    "reqs": 80784, "rps": 5378.4,
    "p50": 4.05, "p95": 21.72, "p99": 28.01,
    "error_rate": 0.0, "checks_rate": 1.0
  },
  "stages": [
    { "stage": "smoke",  "vus": 5,  "reqs": 23532, "p95": 1.2,   "error_rate": 0.0, "checks_rate": 1.0 },
    { "stage": "load",   "vus": 20, "reqs": 26232, "p95": 5.94,  "error_rate": 0.0, "checks_rate": 1.0 },
    { "stage": "stress", "vus": 80, "reqs": 31020, "p95": 25.51, "error_rate": 0.0, "checks_rate": 1.0 }
  ],
  "passed": true
}

(Abbreviated; the real file also carries rps, p50, and p99 per stage.) The SLO it was judged against, the aggregate and per-stage metrics, and the verdict ("passed": true). Everything you need after closing the terminal.

Piece 4 — The CI (content)

The fourth piece is lesson 7's .github/workflows/load.yml: it brings up the API, runs k6 with the thresholds as a gate (if a threshold fails, k6 run exits with 99 and the job goes red), and uploads the results.json as an artifact with if: always(). It triggers nightly, pre-release, and on-demand —not on every PR—. It goes as content, faithful to GitHub Actions; git/gh are never run here. The mechanism —the exit code that fails the step— you checked executed in lesson 7 with the local shell mirror.

The rubric of a good load test

Your delivery is complete —and it's good— if it meets these four criteria. They're not about style; they're what separates a test that protects from one that decorates:

#CriterionWhat it meansIn your delivery?
1Realistic profileThe load has shape (smoke → load → stress), not a single flat level. It models the expected traffic and pushes it to the peak to see where it bends. Think time with jitter, not lockstep.
2Thresholds tied to an SLOEach threshold (p95, error, checks) comes from a concrete business promise, not a random number. The threshold is a pass/fail, not an ornament.
3Correctness checksThe test verifies that the response is correct under load (status, price, confirmation), not just fast. A fast 200 with a broken price must fail.
4In CI, as a gateThe test runs by itself in a pipeline, and the threshold blocks the deploy with an exit code (no continue-on-error). It triggers at the right cadence (nightly/pre-release), not on every PR.

Review your delivery against the rubric:

  • Realistic profile ✓ — stages smoke→load→stress→ramp-down, with parametrized data (variable rooms/tiers/hours) and sleep with jitter. The executed run showed the p95 rising with the load (14 → 58 → 245 ms), exactly what a profile with shape reveals.
  • Thresholds tied to an SLO ✓ — p(95)<200 (the latency promise), rate<0.01 (the availability one), checks>0.99 (the correctness one). In lesson 4 you saw that the same p95 passes or fails depending on the SLO: the threshold is the promise, not a whim.
  • Correctness checks ✓ — five check()s per iteration (status 200, correct price, confirmed booking, booking_id present). They stayed at 100% under load, confirming that Reservo not only responded fast but correctly.
  • In CI, as a gate ✓ — the load.yml runs k6 with the thresholds as the gate; the exit code (99 in k6, 1 in the Python gate) blocks the deploy, as you checked with the local mirror. Triggered nightly/pre-release/on-demand.

If all four are there, your load test doesn't just measure: it protects. That was the goal of the whole guide.

What you do when the gate goes red (the boundary)

The test left you standing before a red gate: /quote_cpu's p95 crossed the SLO under load. Now what? The load test tells you that there's a performance problem and where to look (the pricing engine, CPU-saturated under concurrency), but fixing it —profiling the app, finding the slow function, optimizing the query, adding an index, putting in a cache— is the "after", and it's outside this guide. It's a different job: the load test finds the bottleneck; the optimization resolves it. The red gate is the beginning of that conversation, not the end. With what you know now, you'd recognize the symptom (flat RPS + rising p95 = throughput saturation) and where to start investigating; going deep into the optimization is the next step in your path.

The guide's arc: the eight modules

You close here a complete journey. It's worth seeing it whole, because each module was a link and now you have the chain:

  1. Why (M1). The difference between "does it work?" (correctness) and "does it hold up?" (load); the test types (smoke/load/stress/spike/soak); what k6 is and its place. The question everything else answers.
  2. The script and the VUs (M2). The anatomy of a k6 script —the default function, http.post, check, sleep, options— and the virtual user model. The what each user does.
  3. The metrics (M3). The latency and why the average lies against the percentiles (p95/p99); the throughput/RPS; the error rate. The instruments everything is read with.
  4. The profiles (M4). The stages and the three phases (ramp-up/steady/ramp-down); the shapes (constant, ramp, spike); the executors. The how many users and when.
  5. The thresholds (M5). The threshold that turns a metric into a pass/fail, the exit code that fails CI, and the SLO the number comes from. The verdict.
  6. The checks and scenarios (M6). Verifying the correctness under load with check(), the correlation, the parametrized data, and the realistic think time. The did it respond correctly, not just fast?
  7. The analysis and the CI (M7). Reading and exporting the result, detecting a regression, and automating the test in a pipeline with the threshold as a gate. The what do you do with the result and how do you automate it?
  8. The complete test (M8). Assembling all of the above into a whole Reservo load test, with its green and red gate, in CI, and a rubric. The integration.

From "why test the load?" to "here's the complete test, in a pipeline, blocking the deploy if the performance degrades." That's the arc, and you traveled it whole.

Where to continue

With the load mastered, there are two natural directions:

  • The other half of the pyramid: correctness. This guide proved that Reservo holds up; the correctness of what the user sees is another question, and it lives in the sibling guides of the testing ecosystem. e2e-testing-with-playwright-guide tests Reservo's flow through the browser (that a user who picks Focus/basic/3h sees $75.00 on the screen) —the CORRECTNESS of the UI, not the load—. And testing-fundamentals-and-tdd-guide covers the base of the pyramid: the unit tests and the TDD that hold up everything else. Load (here), E2E (Playwright), and unit/TDD (fundamentals) are the three layers a serious system tests.
  • The "after" of a red gate: optimization. When a threshold fails, the test told you where to look; the next job is to fix it —profile the application and the database, find the bottleneck (an N+1 query, a missing index, an expensive computation), and optimize it—. That's outside this guide, but now you know how to recognize when it's needed and where to start: a p95 that crosses the SLO under load, with the RPS stalled, pointing to a saturation that has to be resolved in the app.

Summary and close of the guide

In this last lesson you delivered the complete Reservo load test: the k6 script (content) with the quote→book scenario, the check()s, the sleep with jitter, the smoke→load→stress stages, and the thresholds tied to the SLO; the canonical API as the target; the executable run in Python with its green gate (healthy build /quote, aggregate p95 21.72 ms, exit 0) and red gate (heavy engine /quote_cpu, the stress takes the p95 to 245.24 ms, aggregate 229.19 ms, exit 1), with the exported results.json; and the CI load.yml (content) with the threshold as the gate. And you judged it with the rubric of a good load test —realistic profile, thresholds tied to an SLO, correctness checks, in CI—, which your delivery meets on all four.

With this you close the guide. You started by asking "does it hold up under load?" and you finish with a whole test that answers that question by itself, in a pipeline, blocking the deploy if the performance degrades. You know how to design the scenario, shape the load, choose the thresholds from the SLO, verify the correctness under stress, read the metrics with judgment, and automate it all with a gate. The load test stopped being a black box: it's a tool you understand end to end and can build for any API. What comes next —the correctness via E2E, the TDD base, the optimization of the "after"— are paths you now know how to take. Safe travels.

Resources

  • k6 — Get started (the complete script) — the official walkthrough that builds a script with http, check, sleep, options, stages, and thresholds, like the quote_book_test.js you delivered. The reference for the capstone's script.
  • e2e-testing-with-playwright-guide — the sibling guide that tests the correctness of Reservo's flow through the browser; the other half of the pyramid, where you continue to cover what the load doesn't test.
  • testing-fundamentals-and-tdd-guide — the base of the pyramid: the unit tests and the TDD that hold up the E2E and the load. The foundation everything else rests on.
  • Google SRE Book — Service Level Objectives — the criterion the capstone's thresholds are chosen with and with which it's decided whether the measured performance "is good"; the compass of the "after" when the gate goes red.