Module 2: The K6 Script And Virtual Users
1. Module introduction: the script and the virtual user
Overview
In module 1 you looked at a load test from above: you understood the difference between "does it work?" and "does it hold up?", met the test types (smoke, load, stress, spike, soak), and learned what k6 is and where it fits. You even saw, in passing, a first script. This module zooms in on that script: it opens it, takes it apart, and teaches you to read and write each of its pieces, because that little .js file is the blueprint of every load test you'll do with k6.
The good news is that the script is short: it fits on one screen. The idea you really have to understand isn't the syntax, but the execution model that script sets in motion —the VU, or Virtual User, a virtual user that repeats your code over and over, in parallel with many others—. When that model clicks, everything else falls into place: you understand why the default function exists, why there's a sleep, what the summary figures mean, and why "10 VUs" isn't the same as "10 requests."
Connection to the module: this lesson is the map. Here you see the whole module at a glance —the pieces of the script and the VU model— and understand the rule of the game that governs the whole guide: k6 is shown as content, the Python generator is actually run. Everything that in the coming lessons appears labeled as Python output was measured in this environment with Python 3.14.0 against the Reservo API; everything that appears as a k6 script or summary is reference content, correct but not run here. Lessons 2 to 6 open each piece of the script; lesson 7 takes apart the VUs-vs-iterations misunderstanding and teaches you to read the summary; lesson 8 brings it all together in a mini-project.
The theater script and the actors
Think of it this way. A theater director stages a play. They write a single script —the sequence of actions and lines a character performs— and then hire actors who perform that same script. If they hire ten actors, they don't write ten scripts: they write one and hand it out. The ten actors take the stage at once, each runs through the script at their own pace, and when one finishes their part, they start again from the beginning. The director doesn't control each actor line by line; they control two things: the script (what they do) and the cast (how many actors and for how long).
A k6 script is exactly that. The default function is the script: the sequence of actions a simulated user performs —quote a room, verify the price, wait a moment—. The VUs are the actors: virtual users who perform that same script, all at once, each in their own loop. And options is the cast sheet: vus: 10 hires ten actors, duration: '30s' keeps them on stage thirty seconds. You write a script and a cast sheet; k6 takes care of putting the ten actors to act in parallel and of counting what happened.
A k6 script has a single script (the
defaultfunction) that many actors (the VUs) perform in parallel, each in a loop. You describe what one user does and how many users there are; k6 runs them all at once and measures the result. You don't write ten scripts for ten users: you write one.
The anatomy of a k6 script
Here's the complete script we're going to take apart across the module. Read it whole once —not to understand every detail yet, but to see the shape— and then we go over the pieces.
// quote_test.js — load test of Reservo's /quote endpoint.
// SHOWN AS CONTENT: k6 is not installed in this environment.
import http from 'k6/http';
import { check, sleep } from 'k6';
// options: the cast sheet. 10 virtual users for 30 seconds.
export const options = {
vus: 10,
duration: '30s',
};
const BASE_URL = 'http://localhost:8000';
// default: the script. This is what each VU runs over and over in a loop.
export default function () {
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);
check(res, {
'status is 200': (r) => r.status === 200,
'price is 7500': (r) => r.json('price_cents') === 7500,
});
sleep(1); // think time: a one-second pause, like a real user.
}
It's barely twenty lines, and each block is a lesson of this module:
import http from 'k6/http'andimport { check, sleep } from 'k6'— you bring in the tools: the HTTP module (to talk to the API) and thecheckandsleepfunctions. k6 doesn't use Node'srequireand you don't install packages withnpm: the modules come inside k6 itself. (Lesson 3.)export const options = {vus: 10, duration: '30s'}— the run's configuration. How many VUs (vus: 10) and for how long (duration: '30s'). It's the cast sheet, separate from the script. (Lesson 6.)export default function () { ... }— the VU's script. Each virtual user runs this body, in a loop, until the duration runs out. It's the heart of the script. (Lesson 2.)http.post(...)— the HTTP request: it sends{room, tier, hours}to/quotewith its JSON header. (Lesson 3.)check(res, {...})— verifies that the response was correct: status 200 andprice_centsequal to 7500. It's how you know the API not only responded, but responded well, even under load. (Lesson 4.)sleep(1)— the think time: the pause that imitates a human reading the screen before the next action. Without this, your VU is a crazed robot and your test measures an unreal storm. (Lesson 5.)
Notice what's not there: there's no for loop, no while, nothing that says "repeat this ten times." The loop is put in by k6, not you. You write what happens once; k6 repeats it in each VU, in each iteration, throughout the whole duration. That inversion —you describe one lap, k6 manages the thousands of laps— is the idea that makes k6 a load tool and not a simple HTTP client.
The VU: the actor who repeats the script
The VU (Virtual User) is k6's unit of concurrency. A VU is a "virtual user": an independent thread of execution that runs your default function in a loop. When you set vus: 10, k6 starts ten of these threads at once. Each one:
- Runs the
defaultfunction top to bottom (one iteration). - On reaching the end, goes back to the beginning and runs it again.
- Repeats until the
durationis met.
Ten VUs running in parallel simulate ten people using Reservo simultaneously. Each one quotes, verifies, waits a second, and quotes again —just like a real user making one booking after another—. The load on your server is the sum of all those VUs hitting at once.
And here's the most common misunderstanding, which we'll take apart with numbers in lesson 7: a VU isn't a request, and an iteration isn't a user. A single VU, in thirty seconds, can do dozens of iterations (one per lap through the script). Ten VUs over thirty seconds, with a sleep(1) in between, do around three hundred iterations in total —not ten, not thirty, but the sum of all the laps of all the actors—. Counting that arithmetic right is half of knowing how to read a load test.
The k6 ↔ Python bridge: why you'll see two faces of everything
Since k6 isn't installed here, you'll see each concept in two versions, and it's important you understand the rule from the start:
- The k6 face (content). The
.jsscript and its summary. They're real and correct —verified against k6's documentation— but presented as reference content, never as if we'd run them on this machine. - The Python face (executed). A mini load generator that models k6's VUs with
concurrent.futures.ThreadPoolExecutor: N workers (threads) = N VUs, each thread repeats a "script" in a loop against the Reservo API, does the equivalent of acheck(), and counts iterations and errors. This one does run, and its output is real, measured in this environment.
The mapping between the two faces is almost one to one, and that's the reason for using Python: the VU model stops being a metaphor and becomes something you can count.
| Idea | In k6 (content) | In Python (executed) |
|---|---|---|
| A VU | a virtual user, k6's internal thread | a ThreadPoolExecutor worker |
| The script | export default function () {...} | a default_fn() function in a while loop |
| A request | http.post(url, body, params) | urllib.request.urlopen(req) |
| Verify | check(res, {...}) | compare status and price_cents and count |
| Think time | sleep(1) | time.sleep(1) |
| Cast | options = {vus, duration} | vus and duration_s arguments |
| Summary | the checks/iterations/vus block | a printed summary with the same counts |
So you can see the bridge is real from the start, here's the canonical Reservo API actually responding —the load target we'll hit throughout the module—:
# GET /rooms — the list of rooms
$ curl -s http://127.0.0.1:PORT/rooms
What to expect. Real output of the canonical API in this environment:
{"rooms": [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]}
# POST /quote — quote Focus/basic/3h (the guide's anchor)
$ curl -s -X POST http://127.0.0.1:PORT/quote \
-H 'Content-Type: application/json' \
-d '{"room":"Focus","tier":"basic","hours":3}'
What to expect. Real output:
{"price_cents": 7500}
That 7500 (Focus at 2500 cents per hour, three hours) is the anchor you already know from module 1, and it's exactly the value the k6 script's check('price is 7500') verifies. The two faces look at the same number.
This module's boundary
This module teaches the anatomy of the script and the VU model. It deliberately stops before several topics, so as not to jump ahead of what other modules cover in depth:
- Metrics in depth —the percentiles (p90/p95/p99), throughput/RPS, the error rate, why the average lies— are module 3. Here you'll see the
http_req_durationblock in the summary and know it exists, but we won't dissect it: when in lesson 7 we read the summary, we focus onchecks,iterations, andvus. - Load profiles —
stages, ramps, spikes, the executors— are module 4. Here the load is flat: a fixed number of VUs for a fixed duration. - Thresholds —the limits that make the test PASS or FAIL, the quality gate— are module 5. Here
check()tells you whether a response was correct, but does not fail the run; that's exactly the difference we'll see in lesson 4. - Checks and scenarios in depth —
group(), parametrizing data, the quote→book correlation— are module 6. Herecheck()is presented in its basic form: status 200 and correct price.
And, as in the whole guide, building the API of Reservo isn't the topic: it's a load target that's already given (the canonical Python server). We hit it and measure it, we don't build it.
Common mistakes
Believing you have to write the loop by hand. What happens: someone coming from writing HTTP clients puts a for inside default to "repeat the request many times." Why it happens: they haven't internalized that k6 puts in the loop. How to detect it: your default function has a for/while that repeats the request, and suddenly each iteration makes hundreds of requests instead of one. How to fix it: put in default what happens once (one lap of the script); k6 will repeat it for you, in each VU, in each iteration. The VU's loop is k6's, not yours.
Confusing "VU" with "request" or with "iteration." What happens: someone reads vus: 10 and writes down "10 requests," or sees 300 iterations and thinks there were 300 users. Why it happens: the three concepts sound similar but measure different things —VUs are concurrent actors, iterations are total laps through the script, requests are HTTP calls—. How to detect it: your counts don't add up (you expected 10 and see 300). How to fix it: remember the chain —N VUs, each does many iterations, and each iteration makes one or more requests—. Lesson 7 measures it with real numbers.
Expecting k6 run to run on Node. What happens: someone tries node script.js or npm install k6 and nothing works. Why it happens: although the script is written in JavaScript, k6 is not Node: it's a Go binary with its own JS engine, and its modules (k6/http, k6) only exist inside k6. How to detect it: node complains it can't find 'k6/http'. How to fix it: k6 scripts are run with k6 run script.js, not with Node. In this environment k6 isn't installed, so the script is content and its executable equivalent is the Python generator.
Exercises
Exercise 1 — Name the pieces. Look at this lesson's quote_test.js script and answer: (a) Which line is the "script" that each VU repeats? (b) Which line is the "cast sheet" that says how many users and for how long? (c) Which line imitates a human's pause between actions?
See solution
- (a) The script is
export default function () { ... }: its whole body is what each VU runs in each iteration (quote, verify, wait). - (b) The cast sheet is
export const options = { vus: 10, duration: '30s' }:vus: 10are the ten actors,duration: '30s'is how long they're on stage. - (c) The pause is
sleep(1): one second of think time that imitates a user reading the screen before the next quote.
Exercise 2 — Translate the concept. In the k6↔Python bridge, what is each of these k6 pieces modeled with in the Python generator? (a) a VU; (b) sleep(1); (c) options = {vus: 10}.
See solution
- (a) A VU is modeled with a worker (thread) of the
ThreadPoolExecutor: each worker repeats the script in a loop, just like a VU. - (b)
sleep(1)is modeled withtime.sleep(1)from Python's standard library: the same pause inside the loop. - (c)
options = {vus: 10}is modeled with avusargument that sets how many workers (max_workers) the pool starts. Ten workers = ten VUs.
Exercise 3 — Place the boundary. For each question, say whether this module (M2) answers it or whether it belongs to another module (and which): (a) "What was the p95 latency?" (b) "How do I write the function each VU runs?" (c) "How do I make the test fail if latency exceeds 500 ms?" (d) "How do I ramp the load from 0 to 50 VUs?"
See solution
- (a) The p95 (latency percentiles) is M3 (metrics). Here you'll only see that the
http_req_durationblock exists. - (b) Writing the VU's
defaultfunction is this module (M2) —it's exactly lesson 2—. - (c) Making the test fail against a threshold is M5 (thresholds). In M2,
check()verifies but doesn't fail the run. - (d) Ramping the load (
stages) is M4 (load profiles). In M2 the load is flat: fixedvusandduration.
Summary and next step
In this lesson you saw the whole module at a glance. A k6 script is a script (the default function, what each user does once) plus a cast sheet (options with vus and duration), and k6 takes care of putting many VUs —virtual users— to perform that script in parallel, each in their own loop. You describe one lap; k6 manages the thousands of laps and measures them. The misunderstanding to overcome —VU isn't request, iteration isn't user— was laid out and we'll take it apart with numbers in lesson 7.
The guide's rule of the game is also clear: k6 is shown as labeled content (correct, not run here) and the Python generator is actually run (real output against the canonical Reservo API). For each piece of the script you'll see the two faces, and the bridge between them is almost one to one —a VU is a thread, sleep is time.sleep, check is a comparison that's counted—.
Before moving on you should be able to: point out in a k6 script which is the script, which is the cast, and which is the think time; explain in one sentence what a VU is; and say which module a question about percentiles, ramps, or thresholds belongs to. What comes next, lesson 2, opens the first piece: the default function and the VU loop —and for the first time we'll put a VU (a Python thread) to repeat the script against /quote and count its real iterations—.
Resources
- How k6 works — Grafana k6 Docs — the overview of the script, the VUs, and the options. You'll recognize this lesson's anatomy in it.
- Running a test —
k6 run— how a script is run and what theoptionssection configures. It's the command we show as content in this environment. concurrent.futures— Python documentation — theThreadPoolExecutorwe model the VUs with (N workers = N virtual users). The engine of everything we do run.- k6's execution model — VUs and iterations — the reference for the VU as a loop and for the difference between users and iterations, the topic that closes in lesson 7.