Skip to main content

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:

Two auth patterns: a shared token where setup() logs in once as the owner and every VU gets a copy, versus per-VU login where each VU logs in as its own customer from customers.csv on its first iteration and reuses the token

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:

SituationPattern
Admin/reporting endpoints, a dashboard, anything behind one privileged accountShared 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 measuredPer-VU login — a shared token hides login cost from the results entirely

How: correlation​

Order flow correlation: GET /menu gives pizzaId, sizeId and crustId to POST /orders, which returns orderId and payUrl; payUrl is posted to the mock gateway, and orderId drives GET /payments/{orderId}/status polling until PAID

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:

  1. getMenu() returns pizzas/sizes/crusts — their id fields go straight into createOrder's request body (pick(menu.pizzas).id, and so on). No id in this test is typed in by hand.
  2. createOrder's response carries { orderId, orderNumber, payUrl }. 03-order-flow.js passes order.payUrl straight into payOnGateway.
  3. waitUntilPaid(orderId) polls GET /payments/{orderId}/status using the same orderId that came back from step 2, until paymentStatus is PAID or 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/exp 43200 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 in 02-auth.js and 03-order-flow.js would 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. Calling open() inside the default function throws the "open" function is only available in the init stage, confirmed by running it that way — see Debug.
  • Hard-coded ids break after a ?full=true reset. POST /admin/reset?full=true re-seeds the users and catalog tables, so a pizza id, size id or user id that was 4 yesterday might not exist (or might mean something else) today. That's the whole reason createOrder reads ids from the live getMenu() response instead of a constant — a script with pizzaId: 4 baked in works until the next full reset and then fails with an id that no longer resolves.