Auth, Correlation and Data
Table of Contents
Why these three go together
A load test that plays back a fixed HTTP transcript breaks the moment the server hands back a different id, and it can't represent "20 different customers logging in" with one recorded token. Every script past 01-first-test.js needs to log in, thread ids from one response into the next request, and give each virtual user its own data instead of everyone hammering the same row. 02-auth.js, lib/flow.js and lib/data.js are where the sample handles all three.
How: auth
02-auth.js runs both auth patterns side by side as two scenarios:
import http from 'k6/http';
import { check, sleep } from 'k6';
import { API } from './lib/config.js';
import { login, authHeaders, resetPlayground } from './lib/auth.js';
import { customerFor } from './lib/data.js';
export const options = {
scenarios: {
// Pattern 1: one login in setup(), every VU reuses the token.
shared_token: { executor: 'per-vu-iterations', vus: 2, iterations: 3, exec: 'adminBoard' },
// Pattern 2: each VU logs in as its own customer, once.
// CUSTOMER_VUS above 20 shows the CSV wrap-around.
per_vu_login: { executor: 'per-vu-iterations', vus: Number(__ENV.CUSTOMER_VUS || 5), iterations: 3, exec: 'myOrders' },
},
thresholds: { checks: ['rate==1'] },
};
export function setup() {
return { ownerToken: resetPlayground() }; // whatever setup() returns is passed to every VU
}
export function adminBoard(data) {
const res = http.get(`${API}/admin/orders`, { headers: authHeaders(data.ownerToken) });
check(res, { 'admin board 200': (r) => r.status === 200 });
sleep(1);
}
let token; // module scope: one per VU, survives between iterations
export function myOrders() {
const me = customerFor(__VU);
if (!token) token = login(me.email, me.password);
const res = http.get(`${API}/orders/mine`, { headers: authHeaders(token) });
check(res, { 'my orders 200': (r) => r.status === 200 });
sleep(1);
}
Pattern 1 — shared token. setup() logs in once as owner@playground.local and returns the token. k6 runs setup() exactly once for the whole test and copies whatever it returns into every VU's data argument. It's a copy, not a shared reference — a VU mutating its data doesn't affect any other VU — so treat it as read-only test fixtures, not a place to accumulate state across VUs.
Pattern 2 — per-VU login. Each VU logs itself in as its own customer inside myOrders, using customerFor(__VU) to pick a row from the CSV. The token is cached in a variable declared at module scope (let token;), outside the function. That matters: init code and module-level variables run once per VU when the script file is loaded for that VU, but the exported function (myOrders) runs once per iteration. Put token inside the function and every iteration would log in again — put it outside and the if (!token) guard means each VU logs in exactly once, on its first iteration, then reuses the token for the rest of its iterations.
The CUSTOMER_VUS switch (-e CUSTOMER_VUS=25, default 5) controls how many VUs per_vu_login spins up. It's there specifically to demonstrate the CSV wrap-around: customers.csv has 20 rows, and customerFor wraps with (vu - 1) % customers.length, so VU 21 reuses customer01's login. Push CUSTOMER_VUS past 20 to see two VUs sharing a login rather than colliding.
When I use which:
| Situation | Pattern |
|---|---|
| Admin/reporting endpoints, a dashboard, anything behind one privileged account | Shared token from setup() — one login for the whole run, no login load in the numbers |
| Data or actions scoped to "the current customer" (their orders, their cart) | Per-VU login — each VU needs its own identity to get its own data back |
| The NFR explicitly includes login as part of the flow being measured | Per-VU login — a shared token hides login cost from the results entirely |
How: correlation
Correlation is passing an id from one response into the next request instead of hard-coding it. lib/flow.js's createOrder and payOnGateway:
// Correlation: ids come from the menu response, not hard-coded.
export function createOrder(token, customer, menu, { autoPay = true, mockDelay } = {}) {
const body = {
type: 'PICKUP',
customer: { name: 'k6 load test', email: customer.email, phone: '0100000000' },
items: [{
pizzaId: pick(menu.pizzas).id,
sizeId: pick(menu.sizes).id,
crustId: pick(menu.crusts).id,
toppingIds: [],
quantity: 1,
}],
autoPay,
};
const headers = authHeaders(token);
if (mockDelay) headers['X-Mock-Delay'] = String(mockDelay);
const res = http.post(`${API}/orders`, JSON.stringify(body), { headers, tags: { name: 'POST /orders' } });
const ok = check(res, {
'order 201': (r) => r.status === 201,
'order number assigned': (r) => r.status === 201 && r.json('orderNumber') >= 1001,
});
return ok ? res.json() : null; // { orderId, orderNumber, payUrl }
}
// What the browser does on the mock pay page. redirects: 0 stops k6 following
// the 302 to the web app, which a load test does not need.
export function payOnGateway(payUrl) {
const res = http.post(payUrl, { action: 'pay' }, { redirects: 0, tags: { name: 'POST /pay/{billId}' } });
check(res, { 'gateway accepted payment': (r) => r.status === 302 });
}
The chain, end to end:
getMenu()returns pizzas/sizes/crusts — theiridfields go straight intocreateOrder's request body (pick(menu.pizzas).id, and so on). No id in this test is typed in by hand.createOrder's response carries{ orderId, orderNumber, payUrl }.03-order-flow.jspassesorder.payUrlstraight intopayOnGateway.waitUntilPaid(orderId)pollsGET /payments/{orderId}/statususing the sameorderIdthat came back from step 2, untilpaymentStatusisPAIDor it gives up.
res.json('orderNumber') is k6's gjson-style path syntax: res.json() with no argument parses the whole body, res.json('orderNumber') pulls one field straight out without a separate JSON.parse, and it supports dotted/indexed paths (res.json('items.0.id')) for nested bodies. menu.pizzas above is res.json() called with no path, then indexed like a normal array in JS.
How: test data
lib/data.js in full:
import { SharedArray } from 'k6/data';
// Loaded once and shared by every VU, instead of one copy per VU.
export const customers = new SharedArray('customers', () =>
open('../data/customers.csv')
.trim()
.split('\n')
.slice(1) // header
.map((line) => {
const [email, password] = line.trim().split(',');
return { email, password };
}),
);
// VU numbers start at 1. Wrap around so VU 21 reuses customer01.
export const customerFor = (vu) => customers[(vu - 1) % customers.length];
SharedArray exists for memory, not correctness. Without it, every VU that does open('customers.csv') and parses it gets its own copy of the array in its own VU's isolated JS runtime — fine for a 20-row CSV, not fine for a 100k-row file at 500 VUs. SharedArray runs the callback once, stores the result in a shared, read-only, immutable structure, and every VU indexes into the same memory. The cost is that it's frozen: nothing can push, splice or reassign into it after creation (see Tips).
customerFor(__VU) is how each VU gets a stable customer: the same VU always maps to the same row, so a VU's login (cached in 02-auth.js's module-scope token) stays valid for that VU's whole run. The % customers.length wrap means the mapping is deterministic but not unique once VU count exceeds row count — two VUs will legitimately share a login, which is what the CUSTOMER_VUS switch demonstrates above.
When rows need to be unique per iteration rather than per VU — every iteration across every VU should touch a different row, never repeating — SharedArray plus __VU isn't the right tool, because iterations on the same VU would all read the same row. The sample doesn't need this and doesn't use it, but the alternative is exec.scenario.iterationInTest, a running count of iterations across the whole scenario:
import exec from 'k6/execution';
import { SharedArray } from 'k6/data';
// Not used in this sample — shown for the case where every iteration,
// across every VU, needs a distinct row rather than a per-VU one.
const rows = new SharedArray('rows', () => JSON.parse(open('./rows.json')));
export default function () {
const row = rows[exec.scenario.iterationInTest % rows.length];
}
Tips
- JWT lifetime vs. test length. Decoding the token the playground issues shows
iat/exp43200 seconds apart — a 12-hour lifetime. Fine for anything up to a long endurance run; if a soak test ever runs past 12 hours, the cached module-scope token in02-auth.jsand03-order-flow.jswould start failing requests partway through and the script has no re-login logic to recover. open()only works in init code. It reads from disk, and k6 only allows filesystem access while the script is being loaded (once per VU), not while the default function is running. Callingopen()inside the default function throwsthe "open" function is only available in the init stage, confirmed by running it that way — see Debug.- Hard-coded ids break after a
?full=truereset.POST /admin/reset?full=truere-seeds the users and catalog tables, so a pizza id, size id or user id that was4yesterday might not exist (or might mean something else) today. That's the whole reasoncreateOrderreads ids from the livegetMenu()response instead of a constant — a script withpizzaId: 4baked in works until the next full reset and then fails with an id that no longer resolves.