Splitting load test scripts into multiple files with ECMAScript modules, building helper functions for HTTP requests and authentication, and designing reusable scenario functions so tests stay maintainable as they grow large.

In episode 5 you mastered techniques that make scripts alive: tokens extracted from responses, sessions stored per VU, parallel requests via http.batch(). But there's an unavoidable side effect: script files keep getting longer. Login code is rewritten in every scenario, Authorization headers are repeated everywhere, and one small change — say, an endpoint URL changing — forces you to edit many places.
This episode teaches the habit that separates throwaway scripts from true engineering assets: modularization. We'll split scripts into multiple files with ECMAScript modules (import/export), build reusable functions for requests and authentication, and design scenarios that can be reused across tests. The analogy: in episode 5 you learned to cook one menu; episode 6 teaches you to write a recipe book any kitchen can use.
A load test that grows without structure is a time bomb. Imagine your team has ten test scripts, each containing copy-pasted login code. Then the auth team changes the login response format — now ten files must be fixed, and chances are some get missed. Modularization solves three problems at once:
main.js is much easier to review than a single 300-line file. Code that's easy to review is code that can be deployed with confidence.Before writing, understand the module limitations in k6 (remember the lesson from episode 2: k6 is not Node.js):
import { getJson } from "./utils/http-client.js", not "./utils/http-client".k6/* modules, local modules, and libraries from jslib (we'll use those in episode 8).The folder structure we'll build:
learn-k6/
├── main.js
├── auth.js
├── config.js
└── utils/
└── http-client.jsThis file is the face of the HTTP side of your scripts. Instead of writing http.get with the Authorization header in every scenario, we wrap it in clearly named functions:
import http from "k6/http";
export function getJson(url, token) {
return http.get(url, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
}
export function postJson(url, body, token) {
const headers = { "Content-Type": "application/json" };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return http.post(url, JSON.stringify(body), { headers });
}Why is this pattern valuable? Notice two things. First, postJson handles details that are easy to forget: JSON.stringify for the body and the Content-Type header. Second, the token is optional — login requests (which don't have a token yet) and authenticated requests can use the same function without duplication. If one day your team moves from the Authorization header to an API key, only one file changes, not dozens of scenarios.
Login is the logic most often repeated and easiest to get wrong. We wrap it completely with the per-VU state pattern from episode 5, so callers only need to write one line:
import http from "k6/http";
const sessions = {};
export function login(baseUrl, credentials) {
if (sessions[__VU]) {
return sessions[__VU];
}
const res = http.post(`${baseUrl}/login`, JSON.stringify(credentials), {
headers: { "Content-Type": "application/json" },
});
const token = res.json("access_token");
sessions[__VU] = token;
return token;
}Note the three design decisions behind this function:
sessions object keyed by __VU — exactly the pattern from episode 5, but now wrapped into a function. The second iteration onward no longer calls /login.One note: in production, consider token expiry — if your API uses short-lived JWTs, add refresh logic or clear the cache on a 401 response. For short tests, the pattern above is exactly right.
Load test configuration also deserves to be its own module. With an options-builder function, you can create test variants from a single source of rules:
export function buildOptions({ vus, duration }) {
return {
vus,
duration,
thresholds: {
http_req_duration: ["p(95)<500"],
http_req_failed: ["rate<0.01"],
},
};
}Thresholds are consolidated in one place, so every scenario using buildOptions automatically gets the same quality standard. Load variants are simply injected via arguments — this pattern will meet us again in episode 7 when we connect configuration with environment variables.
A load test is rarely just one request. The best pattern is writing user journeys as functions that accept a context — here baseUrl and token — then call helpers:
import { check, group } from "k6";
import { getJson } from "./utils/http-client.js";
export function userJourney(baseUrl, token) {
group("dashboard", () => {
const res = getJson(`${baseUrl}/dashboard`, token);
check(res, { "dashboard 200": (r) => r.status === 200 });
});
}This function doesn't care which scenario uses it. It can be called from a smoke script, a load script, or a soak script — with different parameters — without being rewritten. This is the essence of a reusable scenario function: separating what is tested (the journey) from how the load is arranged (options and executors).
All modules are combined into one short, expressive main file:
import { sleep } from "k6";
import { getJson } from "./utils/http-client.js";
import { login } from "./auth.js";
import { buildOptions } from "./config.js";
import { userJourney } from "./journeys.js";
export const options = buildOptions({ vus: 10, duration: "30s" });
const BASE_URL = "https://api.example.com";
const credentials = {
email: "user@example.com",
password: "rahasia123",
};
export default function () {
const token = login(BASE_URL, credentials);
userJourney(BASE_URL, token);
sleep(1);
}Compare this with a single 300-line file: this main.js can be understood in thirty seconds. The execution flow is clear — get a token, run the journey, take a short break. Technical details are hidden behind well-named functions. That's the mark of healthy architecture.
Other lifecycle functions can be modularized too: setup and teardown can be imported from separate modules and re-exported from main.js using the export { prepareTestData as setup } pattern — keeping the preparation and cleanup phases separate from the main load.
Note
The key discipline of modularization: one file, one responsibility. HTTP helpers must not contain login logic, auth must not contain configuration logic, and main.js must not contain request details. If you feel a file starting to "think about" two things at once, it's time to split it.
.js extension in local imports. k6 rejects paths without the extension with a module resolution error — the symptom that most often confuses beginners.loginArifAtStaging() can't be reused; login(baseUrl, credentials) can.__VU key must stay where its storage lives.In this episode 6 you've tidied up k6 scripts into a structured project:
.js-extension rule and init-context execution.getJson and postJson wrap HTTP and authentication details.login() with a per-VU cache, ready to use across scenarios.buildOptions() consolidates thresholds in a single source.Run the result with k6 run main.js and notice that this small folder can produce many test variants just by changing arguments. There's still one link not connected: the vus, duration, and URL values are still hardcoded. In episode 7 we'll open that up — Load Test Configuration and Environment Variables: designing ramping stages, enforcing advanced thresholds, using __ENV for runtime parameters, and running different test variants for local, staging, and production-like environments. See you in episode 7!