Scripting a Performance Test
A performance test script is the program each virtual user runs: one user journey’s requests in order, with the data, pauses and checks that make hundreds of copies behave like hundreds of different people. It turns the workload model from the previous lesson into something a load tool can run, and most of the work is making every request valid on every run.
For example, a checkout script logs in, adds a product to the cart and places the order. If it replays the session cookie and the CSRF value captured when it was recorded, the server rejects every request after login; if all 200 virtual users share one account, they share one cart. Both runs produce charts; neither measures checkout.
Why it matters
A broken script rarely fails loudly. Rejected requests return in milliseconds, error pages can arrive with 200 OK, and a run in which most requests were refused can show better response times than the real system. Release and capacity decisions are then based on load that never happened.
For the tester, scripting is where the workload model meets the real system: which values the server issues per session, which test data each journey uses up, how a correct response is recognised. Those answers decide whether the results can be trusted.
Recording or writing, transactions and timers
You can record a script or write it. Recording captures the traffic as you click through the journey: JMeter’s HTTP(S) Test Script Recorder acts as a proxy between browser and server, and Grafana offers a converter from a browser recording (a HAR file) to a k6 script. Recording is quick but captures everything: analytics calls, third-party resources you may not load test and every value the server issued that day. Writing from the API contract or the browser’s network panel gives fewer, cleaner requests and is usual for APIs; many testers record first to see what is sent, then rewrite.
Either way, build the script from transactions: named groups of requests forming one user step, such as "log in", timed together against the response time targets. JMeter uses a Transaction Controller, k6 a group(). Timers add think time between transactions, so that pauses are not reported as response time. The JMeter plan below shows every part used in this lesson.
Test Plan: checkout
└─ Thread Group: 200 threads, ramp-up 300 s, duration 2,100 s (ramp-up included)
├─ CSV Data Set Config: users.csv -> email, password, sku
├─ HTTP Cookie Manager: one cookie store per thread (session cookies)
├─ Transaction Controller: 01 log in
│ └─ HTTP Request: POST /api/login
│ ├─ JSON Extractor: $.csrfToken -> csrf (default value NOT_FOUND)
│ └─ Response Assertion: response code equals 200
├─ Flow Control Action: pause (think time, outside the transactions)
│ └─ Uniform Random Timer: 3,000 ms + random 0 to 4,000 ms
└─ Transaction Controller: 02 add to cart
└─ HTTP Request: POST /api/cart/items
├─ HTTP Header Manager: X-CSRF-Token = ${csrf}
└─ JSON Assertion: $.sku equals ${sku}Correlation and parameterisation
Correlation means capturing a dynamic value from one response and sending it in a later request: session ids; CSRF values, the anti-forgery tokens a server puts in a hidden form field or a JSON body and expects back with every change; ids of things the script created, such as an order. A recorded script holds the recording day’s values, now expired or belonging to another session. Session cookies are the easy case: JMeter’s HTTP Cookie Manager and k6’s cookie jar keep and resend them per virtual user, like a browser. Other values in bodies, headers and URLs you correlate yourself, in four steps.
Parameterisation replaces fixed values with variables from a data file, so that each virtual user is a different person with its own account, product and request payload, such as a search term or a delivery address. One shared account means one session, one cart and the same database rows for all; one product id means every product page can come from a cache. Data the journey uses up, such as sign-up e-mail addresses or one-time coupons, needs a row per iteration, not per user. JMeter reads the file with CSV Data Set Config, k6 with open() and a SharedArray.
- Find: record the journey twice, or with two accounts, and compare; values that differ and came from an earlier response are the candidates.
- Extract: right after the response that issues the value, by JSON path, regular expression, CSS selector or the text around it (boundaries); k6 reads
res.json()or searches the body. - Replace: use the variable wherever the recorded value appeared, in bodies, headers and URLs.
- Verify: check that the extraction found something; a default such as
NOT_FOUNDmakes a failure visible instead of sending an empty value.
users.csv row and the fresh value from its own login.Recording replayed as it is
- Virtual user k6 script
- Server
staging.example
- 1 From Virtual user To Server: Log in
POST /api/login - 2 From Server To Virtual user: A new session and a new CSRF value reply
{"csrfToken":"f3a9…"} - 3 From Virtual user To Server: Sends the value from the recording day
X-CSRF-Token: 5d0b… - 4 From Server To Virtual user: Refused reply
403 ForbiddenFail
Outcome: Every request after login fails, and fast. Fail
Correlated and parameterised
- Data file
users.csv - Virtual user k6 script
- Server
staging.example
- 1 From Data file To Virtual user: Its own row: account and product
lt.user0007@…, SKU-10482 - 2 From Virtual user To Server: Log in with that account
POST /api/login - 3 From Server To Virtual user: Session cookie and a fresh CSRF value reply
Set-Cookie: sid=Q7mT… {"csrfToken":"f3a9…"} - 4 Virtual user internally: Extract and check the value missing: the iteration ends
csrf = res.json('csrfToken') - 5 Virtual user internally: Think time, random 3 to 7 s
- 6 From Virtual user To Server: Add the row’s product, sending the value the cookie jar adds the session cookie
X-CSRF-Token: f3a9… - 7 From Server To Virtual user: The cart line for this user reply
201 {"sku":"SKU-10482",…} - 8 Virtual user internally: Check: status 201 and the same SKU Pass
Outcome: Each virtual user sends only values issued to it in this iteration. Pass
- Data file
Text version
Recorded value replayed (not correlated)
POST /api/cart/items
X-CSRF-Token: 5d0b8a42 <- from the recording day
-> 403 Forbidden, for every virtual user
Correlated and parameterised
POST /api/login
{"email":"lt.user0007@example.com","password":"..."} <- row 7 of users.csv
-> 200 OK
Set-Cookie: sid=Q7mT2xVb; Path=/; Secure; HttpOnly <- kept by the cookie jar
{"csrfToken":"f3a9c1e7"} <- extract: csrf
POST /api/cart/items
Cookie: sid=Q7mT2xVb <- sent by the cookie jar
X-CSRF-Token: f3a9c1e7 <- send: csrf
{"sku":"SKU-10482","qty":1} <- row 7 of users.csv
-> 201 Created {"sku":"SKU-10482","qty":1} <- check: same SKUTry it: Load Test Data Generator Try it: JMeter CSV Generator Try it: CSV Dataset Generator
Think time, pacing and response checks
The workload model gave you think time and pacing; in a script they take a few lines. Pause randomly between steps: sleep(3 + Math.random() * 4) in k6, a Uniform Random Timer in JMeter. For pacing, let the tool hold the rate (k6’s arrival-rate executors, JMeter’s Constant Throughput Timer) or sleep for the rest of each iteration’s interval. Without pauses, a few virtual users send the traffic of many people, and the test no longer applies the modelled load.
A load tool times every response, whatever it contains, so without checks a fast error counts as a fast success. A 4xx or 5xx status is caught by default: JMeter marks the sample, its record of one request, as failed, k6 counts it in http_req_failed. But many failures arrive with a success status: a login page after a lost session, an error page with 200 OK, a body saying "status":"error", an empty search result from bad data. Check each response’s status and something only a correct response contains.
- Check content that proves the step worked, such as a field value or the product name, not text that appears on every page.
- Make failed checks count: in k6 a failed
check()fails the run only through a threshold such aschecks: ['rate>0.99']; in JMeter a failed assertion marks the sample as failed. - End a broken journey: when login fails, stop the iteration instead of sending the remaining steps with an empty value, but keep the pause, or the failing user retries login at full speed.
Load profile, one-user validation and generator capacity
The load profile is configuration, not journey code. It usually has three phases: ramp-up adds virtual users gradually, steady state holds the target load while you measure, and ramp-down removes them. In k6 each phase is one entry in stages, or you choose a scenario executor; in a JMeter thread group you set the threads (one per virtual user), the ramp-up period and the duration, which includes the ramp-up (the standard thread group has no ramp-down). Choosing the profile is the subject of the next lesson, on load testing. Keep the profile, the base URL and the data file outside the journey code, so that one script runs with one user or many.
Before any load, run one virtual user for one iteration with full logging: View Results Tree in JMeter, --http-debug=full in k6. Read every request and response, and confirm that each extracted value is fresh and the order exists for the right user and product. Repeat a few iterations and note each transaction’s time: this single-user baseline is what loaded results are compared with, and a step that is slow for one user is already a finding. Then run a few users for a few minutes to catch shared accounts, colliding data and rows running out.
- A load generator has limits: when its CPU, memory or network saturates, requests queue inside it and the run measures the generator, not the system. Watch its resources, run JMeter in non-GUI mode without debugging listeners such as View Results Tree, and in k6 set
discardResponseBodiesand ask forresponseType: 'text'only on the requests whose body a check reads. - If one machine is not enough, spread the load and give each generator its own slice of the data file, or two of them will log in as the same users.
Worked example: the start of a checkout script in k6
An online store’s test environment has 500 test accounts in users.csv, each row with an e-mail address, a password and a product SKU. The script below covers the first two checkout steps; the remaining steps follow the same pattern. The session cookie travels in k6’s cookie jar without any code.
- Data:
SharedArrayloads the file once for all virtual users;__VU(1, 2, 3 …) gives each its own row: 200 users, 200 accounts. The simplesplitassumes values without commas or quotes. - Correlation:
csrfTokenfrom the login response goes intoX-CSRF-Token; if it is missing, a check fails and the iteration ends after a pause. - Thresholds: the run fails if 1% or more of checks fail or the cart step’s p95 reaches 800 ms.
- Profile: 5 minutes of ramp-up to 200 users, 30 of steady state, 3 of ramp-down. Validate first with
k6 run --vus 1 --iterations 1 --http-debug=full -e BASE_URL=https://staging.example checkout.js.
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { SharedArray } from 'k6/data';
// users.csv: a header row, then one test account per row: email,password,sku
const users = new SharedArray('users', () =>
open('./users.csv').split(/\r?\n/).slice(1).filter(Boolean).map((l) => l.split(',')));
export const options = {
stages: [
{ duration: '5m', target: 200 }, // ramp-up
{ duration: '30m', target: 200 }, // steady state
{ duration: '3m', target: 0 }, // ramp-down
],
thresholds: {
checks: ['rate>0.99'],
'http_req_duration{name:add-to-cart}': ['p(95)<800'],
},
};
const BASE = __ENV.BASE_URL;
const think = () => sleep(3 + Math.random() * 4); // think time: random 3 to 7 s
function field(res, path) { // one JSON field, or null when the body is not JSON
try { const v = res.json(path); return v === undefined ? null : v; }
catch (e) { return null; }
}
export default function () {
const [email, password, sku] = users[(__VU - 1) % users.length]; // own row per VU
const csrf = group('01 log in', () => {
const res = http.post(`${BASE}/api/login`, JSON.stringify({ email, password }), {
headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' },
});
const token = field(res, 'csrfToken'); // correlation: extract the dynamic value
check(res, {
'login: status 200': (r) => r.status === 200,
'login: CSRF value received': () => !!token,
});
return token;
});
if (!csrf) { think(); return; } // end the iteration after a pause: the check failed
think();
group('02 add to cart', () => {
const res = http.post(`${BASE}/api/cart/items`, JSON.stringify({ sku, qty: 1 }), {
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
tags: { name: 'add-to-cart' },
});
check(res, {
'cart: status 201': (r) => r.status === 201,
'cart: line has this SKU': (r) => field(r, 'sku') === sku,
});
});
think();
}Common mistakes
- Replaying a recording as it is: stale session and CSRF values get every request after login rejected, and recorded third-party calls load services you may not test.
- Running all virtual users with one account or one product, so sessions collide and caches answer everything.
- Trusting status codes alone, so login redirects and error pages with
200 OKlook like fast successes. - Leaving think time out, or pausing inside a transaction, where the pause can be counted in its time: a k6
group()always includes it. - Starting at full load without a one-user run, burying script errors in thousands of failures.
- Ignoring the load generator: a saturated machine, or JMeter in GUI mode with debugging listeners, makes the system look slower than it is.
The QA perspective
Treat the script as testware that needs a review. Ask the developers which values are issued per session or per request and which requests a journey really sends; check that the test environment has enough test accounts, products and payment stubs; and agree with the product owner what a correct response looks like for each step, so that checks test business results, not just status codes.
Keep the script in version control with its data generator and a short note: the application version, the correlated values, the data files and the one-user command. Record the single-user baseline with the build number and repeat the one-user run after every release: a renamed field or a new security value breaks correlation silently.
Practice exercise
Find the dynamic values in a recorded booking script
You recorded a clinic booking journey with one test account; the shortened recording is below. Decide which values must be correlated and which parameterised, write one check per response, and say how you will validate the script before a 100-user load test.
1 GET /login
200 Set-Cookie: sid=A81kQ2; Path=/; Secure; HttpOnly
<input type="hidden" name="_csrf" value="9c2e71f0">
2 POST /login _csrf=9c2e71f0&email=qa.patient17@example.com&password=...
303 Location: /home Set-Cookie: sid=Zr40Pm; Path=/; Secure; HttpOnly
3 GET /api/slots?clinic=12&date=2026-11-03
200 [{"slotId":"s-55871","time":"09:30"},{"slotId":"s-55872","time":"09:45"}]
4 POST /api/bookings X-CSRF-Token: 9c2e71f0
{"slotId":"s-55871","patientId":"p-1043"}
201 Location: /api/bookings/b-77310
5 GET /api/bookings/b-77310
200 {"bookingId":"b-77310","status":"confirmed","time":"09:30"}- Mark every value the server issued, the response it came from and how you will extract it.
- Mark the values that must differ between virtual users and the data file that supplies them.
- Write one check per response that only a correct response passes.
- Plan the one-user and few-user runs.
Show the model answer
Correlate: the sid cookie from responses 1 and 2 (the cookie manager or jar handles it; confirm that the id changes at login); _csrf from the hidden field in response 1 (CSS selector or regular expression), sent in request 2 and as X-CSRF-Token in request 4, or the newer value if login issues one; slotId from response 3 (JSON path, a free slot, not always the first); the booking id from the Location header of response 4, for request 5.
Parameterise: e-mail and password, one test account per virtual user; patientId, which no response shows, in the account’s data row; clinic and date, always a future date instead of the fixed recorded 2026-11-03, spread so that 100 users do not compete for the same two slots, unless that contention is the test.
Checks: (1) a _csrf value in the form; (2) a 303 to /home, not back to /login (if the tool follows redirects, check the final URL); (3) a JSON array with at least one slot; (4) 201 with a Location header; (5) "status":"confirmed" and the booked time.
Validation: one user, one iteration, full logging; confirm fresh values and the booking for that patient, then a few iterations for the baseline. Next, 5 users for a few minutes to catch shared accounts, slot collisions and data running out, resetting slots between runs. Only then 100 users, watching the load generator.
Frequently asked questions
Should I use JMeter or k6 for my first script?
Either teaches the same skills. JMeter builds the test plan in a GUI from ready-made elements for recording, extraction and data files, and runs from the command line for the real test. k6 scripts are JavaScript files that live in version control and fit a CI pipeline. Use what your team already runs; correlation, parameterisation, checks and validation work the same way in both.
Can I load test with a browser automation script such as Selenium or Playwright?
Not at scale. Each browser needs far more CPU and memory than a protocol-level virtual user, so one machine runs far fewer of them. Protocol-level scripts send the HTTP requests directly. Browser-level tests are useful alongside protocol-level load, to measure what one real user experiences in the page, such as rendering time, while the system is under load.
How many rows of test data do I need?
At least one row per virtual user for data that identifies a user, such as accounts. For data the journey uses up, such as new registrations or one-time coupons, multiply the virtual users by the iterations each completes in the run and add a margin. Varied read data, such as product ids, must be large enough for caches to behave as in production, which the workload model states.
References
- Certified Tester Performance Testing (CT-PT)ISTQB's specialist syllabus on performance testing: basic concepts, performance measurements, test activities and tasks, and tools.
- Grafana k6 documentationOfficial documentation for Grafana k6, an open-source and extensible load and performance testing tool.
- Apache JMeter User's ManualHow to build, parameterise and run load tests with JMeter, and how to read its listeners and reports.
- ISTQB GlossaryThe standard definitions of testing terms — test case, regression testing, severity, priority, boundary value analysis and the rest.
Practise with Mutqan tools
Generate large parameter datasets (users, tokens, ids, payloads) for k6, JMeter, Gatling and Locust load tests.
Generate CSV files for JMeter CSV Data Set Config with correct quoting, delimiters and encodings.
Generate CSV files with custom columns, data types, row counts and delimiters.
Generate concurrency test scripts (k6, JMeter JMX, Locust, Artillery, Node.js) with safe defaults for a target endpoint.
Generate performance test inputs with realistic distributions: think times, request mixes, payload sizes and ramp profiles.
Measure an endpoint's response time over several samples: min, max, average, median and p95.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.