Module 6: Checks Groups And Realistic Scenarios
4. `group()` to organize the steps (content)
Overview
A realistic scenario has several steps: quote, book, confirm. When you put them all in the VU's function, one after another, the script works —but the summary becomes a jumble. All the requests blend into a single http_req_duration metric, and when you see a high p95 you don't know from which step: was it the quote that got slow, or the booking? Is the bottleneck in computing the price or in writing the booking? With the steps mixed, the aggregate number hides exactly what you need to know. Here group() comes in: a way to wrap each step in a named block so the summary gives you metrics per step and the checks are tagged by group.
group() is organization, not behavior. It doesn't change what the VU does —the same requests, in the same order—; it changes how they're reported. By wrapping the quote step in group('quote', () => { ... }), k6 measures how long that block took (a group_duration metric tagged with group: quote) and tags the checks inside with that group. You do the same with book and confirm, and suddenly the summary stops saying "the scenario took X" and starts saying "quoting took X, booking took Y, confirming took Z". That disaggregation is gold when you're hunting the bottleneck: it tells you which step is the slow one, not just that something is.
Connection to the module: group() is the second piece of the realistic scenario. It's a k6 construct shown as content (k6 isn't installed). But the idea —measure per step, not just in aggregate— we actually run: the Python generator of the quote → book → confirm scenario already measures and reports the average latency per group, which is the equivalent of k6's group_duration. You'll see the three steps with their latency separately, measured in this environment with Python 3.14.0. The boundary: group() organizes the steps; chaining them with data flowing from one to the other is the correlation of lesson 6.
The chapters of an itemized receipt
Think of it with an invoice. Imagine you go to a mechanic's shop and they hand you an invoice that says, at the end, a single line: "Repair: 8,000 pesos". It's a correct number, but useless for understanding anything. How much was the labor? How much the parts? How much the inspection? If next time they charge you 12,000, you have no way to know what went up. Now imagine the same invoice itemized: "Diagnosis: 1,000. Parts: 5,000. Labor: 2,000." Same total, but now you see what it's made of, and if something changes, you know exactly which line moved.
group() turns your test's summary from a one-line invoice into an itemized one. Without groups, the summary says "the scenario took, in p95, 40 ms" —the total, no breakdown—. With groups, it says "quote: 15 ms, book: 18 ms, confirm: 7 ms" —the same total, but by item—. And when the next run shows a higher p95, you won't wonder "what got slow?": you'll read it directly in the line of the group that rose. Grouping is putting items on your scenario's invoice.
group()doesn't change what the VU does; it changes how it's reported. It wraps each scenario step in a named block and k6 measures that block separately (group_duration) and tags its checks with the group. It's the difference between a one-line invoice ("the scenario took X") and an itemized one ("quote X, book Y, confirm Z") —which is the one that lets you find the bottleneck.
group() in k6 (content)
This is what the three-step scenario looks like with group(). Each step —quote, book, confirm— goes wrapped in its named block, and each step's checks live inside its group:
// scenario_grouped.js - the 3-step scenario, organized with group().
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
import { check, group, sleep } from 'k6';
const BASE_URL = 'http://localhost:8000';
const params = { headers: { 'Content-Type': 'application/json' } };
export default function () {
let price, bookingId;
// --- Step 1: quote ---
group('quote', function () {
const body = JSON.stringify({ room: 'Studio', tier: 'pro', hours: 4 });
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') === 12800, // Studio/pro/4h
});
price = res.json('price_cents'); // used in step 2 (correlation, lesson 6)
});
sleep(1); // think time between steps
// --- Step 2: book ---
group('book', function () {
const body = JSON.stringify({ room: 'Studio', tier: 'pro', hours: 4, price_cents: 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,
});
bookingId = res.json('booking_id'); // used in step 3 (correlation, lesson 6)
});
sleep(1);
// --- Step 3: confirm ---
group('confirm', function () {
const res = http.get(`${BASE_URL}/booking/${bookingId}`);
check(res, {
'status is 200': (r) => r.status === 200,
'id matches': (r) => r.json('booking_id') === bookingId,
});
});
}
Take it apart:
group('quote', function () { ... }). The first argument is the group's name —the one you'll see in the summary—; the second, a function with that step's code. Everything that happens inside (the request, the checks) is tagged withgroup: quote.- The checks inside the group inherit the tag. In the summary,
status is 200from thequotegroup andstatus is 200from thebookgroup appear separately, each under its group. That way, two steps with a criterion of the same name aren't confused. group_duration. k6 measures how long eachgroup()block took and reports it as agroup_durationmetric tagged with the group's name. It's what lets you compare how longquotetook vsbookvsconfirm.sleep(1)between groups, not inside. The think time goes between steps (a user thinks between quoting and booking), so you put it outside thegroup()s, so the pause doesn't inflate any step'sgroup_duration.
An honest note: group() does not make the steps atomic or transactional. If step 2 fails, step 3 runs anyway (unless you avoid it with logic). group() only organizes the report; the flow logic you write yourself.
What it looks like in the k6 summary (content)
With groups, the k6 summary shows the group_duration broken down by group. This is what the relevant block would look like (reference content, faithful to k6's format, not run here):
█ quote
✓ status is 200
✓ price is correct
█ book
✓ status is 200
✓ booking is confirmed
█ confirm
✓ status is 200
✓ id matches
checks.........................: 100.00% ✓ 600 ✗ 0
group_duration.................: avg=8.1ms min=1.2ms med=6.4ms max=41ms p(90)=15ms p(95)=19ms
{ group:::quote }............: avg=9.7ms ...
{ group:::book }.............: avg=8.9ms ...
{ group:::confirm }..........: avg=5.6ms ...
http_req_duration..............: avg=2.7ms ...
What this summary gives you and the group-less one doesn't:
- The checks grouped by step (
█ quote,█ book,█ confirm), each with its criteria. At a glance you know which step has red checks, not just that "some check" failed. group_durationper group ({ group:::quote },{ group:::book },{ group:::confirm }). Here's the itemized invoice: how long each step took separately. Ifbookwere the slow one, you'd see it in its line.
The same idea, run in Python
The Python generator of the quote → book → confirm scenario already measures per group. Each step times its request and stores the latency in a per-group list; at the end it reports each one's average —the equivalent of k6's group_duration—:
# Inside each scenario step: time it and store per group.
t0 = time.perf_counter()
status, body = post_json(f"{BASE_URL}/quote", {...})
dt = (time.perf_counter() - t0) * 1000 # step latency in ms
with lock:
group_calls.setdefault("quote", []).append(dt) # a list of latencies per group
# ... same for "book" and "confirm"
# At the end, the average per group (k6's group_duration):
for g in ("quote", "book", "confirm"):
lat = group_calls[g]
print(f" group '{g}': n={len(lat)} avg={sum(lat)/len(lat):.2f}ms")
Let's run the healthy scenario, 4 VUs / 3 s / think 100 ms, and look at the per-group breakdown.
What to expect. The three groups should have the same number of calls (one per iteration) and an average latency per step —probably the quote a bit higher than confirming, because it computes the price—. Real output in this environment (the per-group block of the complete run):
iterations.....: 109 (35.2/s)
checks.........: 100.00% (872 of 872)
per group (avg latency, n calls):
group 'quote '.....: n=109 avg=1.06ms
group 'book '.....: n=109 avg=0.54ms
group 'confirm'.....: n=109 avg=0.47ms
Read it like the itemized invoice:
n=109in the three groups — each iteration ran the three steps once, so the three groups have 109 calls. It matches the 109 iterations.quote: avg=1.06ms,book: avg=0.54ms,confirm: avg=0.47ms— here's the per-step breakdown. The quote is the slowest of the three (1.06 ms), which makes sense: it's the one that receives a body, parses it, validates, and computes the price. Booking (0.54 ms) and confirming (0.47 ms) are lighter. Without groups, you'd see only an aggregate average and wouldn't know the quote step is the heaviest.- The lesson of disaggregation — the difference between 1.06 ms and 0.47 ms is small here (Reservo is very fast on
localhost), but the method is what matters: on a real API, if a step took 10× longer than the others, this table would tell you immediately. Measuring per group is what turns "the scenario is slow" into "the booking step is slow," which is an actionable clue.
Notice that the number that matters isn't the magnitude (milliseconds against a local server), but the ability to compare steps. That's exactly what group() gives you in k6 and what this breakdown gives you executed: the itemized invoice instead of the one-line one.
When to group and when not
group() is useful, but not free in attention: too many nested groups make the summary noisy. The practical rule:
- Group by logical scenario step —quote, book, confirm—, not by each line of code. A group should correspond to a user action worth measuring separately.
- Don't group what you won't compare. If a scenario has a single step,
group()adds nothing; the aggregate summary already is that step. - Don't put the think time inside the group. The
sleepgoes between groups, not inside, so the pause doesn't inflate thegroup_durationand distort the step's measurement. - Don't confuse
group()with transaction. Grouping doesn't make the steps fail or succeed together; it only reports them together. The logic of "if step 2 fails, don't do step 3" you write yourself.
Common mistakes
Putting the sleep (think time) inside the group(). What happens: someone puts the think-time pause inside the group('quote', ...) block, and the quote's group_duration comes out inflated by the second of sleep. Why it happens: it seems natural to put the whole "step" together. How to detect it: a group with a suspiciously high and round latency (≈ the sleep value). How to fix it: the think time goes between groups, outside them. The group_duration should measure only the step's work (the request), not the pause that follows.
Expecting group() to make the steps atomic. What happens: someone thinks that if a step fails inside a group, the following ones don't run. Why it happens: the word "group" sounds like "transaction." How to detect it: step 3 runs even though step 2 failed, producing cascading errors (for example, a GET /booking/undefined). How to fix it: understand that group() only organizes the report. If you need a failure to stop the flow, write it yourself (an if that skips the following steps when the previous one didn't give what was expected).
Nesting groups until the summary is unreadable. What happens: someone wraps each http.get and each check in its own group, and the summary fills with dozens of { group:::... } lines. Why it happens: "organizing" gets confused with "tagging everything." How to detect it: the summary has more groups than the scenario has real steps. How to fix it: one group per logical step (a user action), not per line. Fewer groups, well chosen, make the invoice readable; too many turn it back into noise.
Exercises
Exercise 1 — Group a two-step scenario. Write (in k6, as content) a scenario that first lists the rooms (GET /rooms) and then quotes one (POST /quote), each step in its own group() with a status 200 check. Put the think time in the correct place.
See solution
export default function () {
group('list rooms', function () {
const res = http.get(`${BASE_URL}/rooms`);
check(res, { 'status is 200': (r) => r.status === 200 });
});
sleep(1); // think time BETWEEN steps, outside the groups
group('quote', function () {
const body = JSON.stringify({ room: 'Focus', tier: 'basic', hours: 3 });
const res = http.post(`${BASE_URL}/quote`, body, params);
check(res, { 'status is 200': (r) => r.status === 200 });
});
}
Each step in its group; the sleep(1) between them, inside neither, so it doesn't inflate the group_duration.
Exercise 2 — Read the per-group invoice. In the real run, the breakdown was quote: avg=1.06ms, book: avg=0.54ms, confirm: avg=0.47ms, with n=109 in all three. (a) Why do all three have n=109? (b) Which step is the heaviest and why does it make sense? (c) If on a real API book showed avg=95ms and the other two avg=3ms, what would that tell you?
See solution
- (a) Because each iteration runs the three steps exactly once, so the 109 iterations produce 109 calls in each group.
- (b) The quote (
quote, 1.06 ms) is the heaviest. It makes sense: it's the one that receives and parses a JSON body, validates room/tier/hours, and computes the price. Booking and confirming are lighter operations against the in-memory store. - (c) That the bottleneck is in the booking step:
booktakes ~30× longer than the others. On a real API that would point to writing the booking (a slow database, a missing index, a lock). The per-group invoice turns "the scenario is slow" into "the booking step is slow," which is an actionable clue —that's whatgroup()is for—.
Exercise 3 — Group or no group? For each case, say whether group() adds value or not, and why. (a) A scenario with a single step: quote. (b) A four-step scenario: login, search room, quote, book. (c) Wrapping each of the five checks of a single step in its own group.
See solution
- (a) Adds nothing. With a single step, the aggregate summary already is that step. Grouping it only adds a redundant line.
- (b) Adds value. Four distinct logical steps: grouping each gives a
group_durationper step and lets you see which of the four is the bottleneck. It's the ideal case forgroup(). - (c) Adds nothing (and gets in the way). Five checks of the same step aren't five steps; they're five criteria of one action. Putting them in five groups inflates the summary without gaining information. The five checks go inside a single
group()(the step's), not each in its own.
Summary and next step
In this lesson you organized the scenario with group(). It doesn't change what the VU does —the same requests, in the same order—; it changes how it's reported: it wraps each step (quote, book, confirm) in a named block, and k6 measures that block separately (group_duration) and tags its checks by group. It's the difference between a one-line invoice ("the scenario took X") and an itemized one ("quote X, book Y, confirm Z") —the one that lets you find the bottleneck—. You measured it executed: the real per-group breakdown (quote 1.06 ms, book 0.54 ms, confirm 0.47 ms, with 109 calls each) showed the quote is the heaviest step, something an aggregate average would have hidden.
Before moving on you should be able to: wrap a scenario's steps in group() with clear names; read a per-group group_duration and use it to locate the slow step; put the think time between groups and not inside; and say when grouping adds value and when it gets in the way.
Lesson 5 attacks another lack of realism. Until now, although the scenario has several steps, they all request the same data (Studio/pro/4h in the example). A real user requests different rooms. You'll learn to parametrize the data —vary room/tier/hours with a list, a SharedArray in k6— so you don't hit a single hot route, and you'll see measured the difference between always requesting the same (one row, one price) and requesting varied data (six rows, five prices).
Resources
- Groups and tags in k6 — the reference for
group(): how it tags metrics and checks per step and producesgroup_duration. The exact source of this lesson. - k6 built-in metrics —
group_duration— the catalog wheregroup_durationlives, the per-group metric that breaks down how long each step took. What the per-group report measures. time.perf_counter— Python documentation — the high-resolution clock the generator times each step with to compute the per-group latency. How a block's time is measured in Python.