k6 Load Testing Suite in TypeScript
Build a modular k6 test suite with scenarios, custom metrics, and HTML reports.
Introduction
30 Second Summary
Every app works fine with one user. The real question is what happens when hundreds of users hit it at the same time.
In this project, you will build a production-quality load testing suite in TypeScript using Grafana k6. Your suite will spin up virtual users that authenticate individually, execute realistic multi-step journeys against a REST API, enforce performance thresholds, and generate polished reports.
What You'll Build
When you run your finished test suite, k6 launches dozens of virtual users that each log in with their own JWT session, browse items, create records, and validate every response. You watch custom metrics scroll by in the terminal, see thresholds pass or fail automatically, and open an HTML report you can share with anyone.
By the end of this project, you'll have:
- A modular test architecture with a typed API client, environment configs, and reusable helpers that any teammate can extend without reading your test code.
- Multi-scenario load profiles (smoke, load, stress, spike) with per-scenario tags and thresholds that auto-fail your test when performance regresses.
- Custom business metrics that answer "which operation is degrading?" and per-VU authentication where each virtual user maintains its own session.
- Secret Mission: Wire up HTML reporting via k6-reporter, add a setup/teardown lifecycle, and create a CI-ready execution script.
Are there any prerequisites?
This project assumes you have JavaScript basics and a working Express REST API running locally. Your API needs JWT authentication, CRUD endpoints, pagination, filtering, rate limiting, and realistic latency.
You also need k6, Bun, and VS Code already installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
A production performance engineering team doesn't maintain a pile of standalone scripts. They maintain a structured codebase where changing one thing (a base URL, a new endpoint, an auth flow) doesn't mean hunting through every file. That's what you're about to build.
Set Up a TypeScript k6 Project
k6 can run TypeScript files natively. No bundling, no transpiling. You just point it at a .ts file and it executes.
To get the most out of that, you need a project structure that gives your editor type information. In this step, you will create a dedicated test project, install type definitions for IntelliSense, configure TypeScript, and verify everything works with a single test run.
In this step, get ready to:
- Create a project directory and install type definitions for IDE autocomplete.
- Configure TypeScript and build out the folder structure.
- Write and run a minimal smoke test to prove the setup works.
Create the project directory and install type definitions
Your load tests live in their own project folder, separate from your Express API. This keeps test dependencies isolated from application dependencies.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir k6-tests
cd k6-tests
- Open VS Code in this folder by running:
code .
VS Code didn't open?
If code is not recognized, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, press Enter, then use File > Open Folder to open the k6-tests folder on your Desktop.
help me set up the VS Code shell command so I can open projects from my terminal
- In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Create package.json in the root of your k6-tests folder. In the VS Code Explorer sidebar on the left, click the New File icon and name it package.json.
- Paste the following content into package.json:
{
"name": "k6-tests",
"version": "1.0.0",
"description": "Production k6 load testing suite",
"private": true,
"devDependencies": {
"@types/k6": "2.0.1"
}
}
What does this file do?
- "private": true prevents accidentally publishing this folder to npm.
- "@types/k6": "2.0.1" declares the k6 type definitions as a dev dependency. This gives your editor autocomplete for k6 APIs without adding anything to your test runtime.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Install the declared dependency by running this in the VS Code integrated terminal:
bun install
You should see output confirming the package was installed. A node_modules folder and bun.lockb file now appear in your Explorer sidebar.
Seeing install errors?
Make sure your terminal is inside the k6-tests folder. Run pwd to check. If you are somewhere else, run cd ~/Desktop/k6-tests and try bun install again.
help me fix bun install errors when installing packages
Configure TypeScript and create the folder structure
The tsconfig.json file tells VS Code how to type-check your test files. k6 itself does not read this file. It exists purely for editor support.
- Create a new file called tsconfig.json in the root of your k6-tests folder (click the New File icon in the Explorer sidebar).
- Paste the following configuration into tsconfig.json:
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["@types/k6"]
},
"include": ["src/**/*.ts"]
}
What do these options mean?
- "noEmit": true tells TypeScript to only check types, never compile. k6 handles execution itself.
- "allowImportingTsExtensions": true lets you import files with .ts extensions, which k6 requires in its import paths.
- "types": ["@types/k6"] scopes type resolution to just the k6 types, avoiding Node.js type conflicts.
- Save tsconfig.json.
- Create the folder structure for your test suite by running this in the terminal:
mkdir -p src/config src/types src/clients src/helpers src/metrics src/scenarios src/tests
- Confirm the folders were created by running:
ls src/
You should see seven directories listed: clients, config, helpers, metrics, scenarios, tests, and types.
Why so many folders for an empty project?
Each folder holds one responsibility. Config goes in config/, typed interfaces in types/, HTTP client wrappers in clients/. When the suite grows, you change one file without touching the rest.
🙋♀️ Only seeing some folders?
If mkdir -p did not create all seven directories, check for typos in the command. Run ls src/ again after correcting. On Windows without Git Bash, use PowerShell and replace the command with individual New-Item -ItemType Directory calls for each path.
help me create the k6 folder structure on my operating system
Write and run a minimal smoke test
With the structure in place, you will write the smallest possible test that proves k6 can find, parse, and execute a TypeScript file from your project.
- In the Explorer sidebar, expand the src/tests/ folder.
- Create a new file inside src/tests/ called smoke.ts.
- Paste the following code into smoke.ts:
import http from 'k6/http';
export default function () {
http.get('http://localhost:3000/health');
}
What does this test do?
- import http from 'k6/http' pulls in k6's built-in HTTP module. This is not Node.js. k6 has its own runtime.
- export default function () defines what each virtual user executes on every iteration. k6 calls this function repeatedly.
- http.get('http://localhost:3000/health') sends one GET request to your Express API's health endpoint.
- Save smoke.ts.
Keep your Express API running
This test hits http://localhost:3000/health. Make sure your Express API is still running in a separate terminal before you execute the test.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files (package.json, tsconfig.json, smoke.ts).
ⓧ I'd like to double check the full code
Here is each file you should have at this point:
{
"name": "k6-tests",
"version": "1.0.0",
"description": "Production k6 load testing suite",
"private": true,
"devDependencies": {
"@types/k6": "2.0.1"
}
}
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["@types/k6"]
},
"include": ["src/**/*.ts"]
}
import http from 'k6/http';
export default function () {
http.get('http://localhost:3000/health');
}
Before you run the test, predict what you expect to see. Will k6 show a success or a failure? How many requests will it make?
- Run your first k6 test by executing this command in the terminal:
k6 run src/tests/smoke.ts
k6 prints its logo, your test configuration, a progress bar, and then an end-of-test summary. You should see metrics including http_reqs, http_req_duration, and iterations with actual values. The default run uses 1 virtual user for 1 iteration, so you will see 1 completed iteration and 1 HTTP request.
Seeing connection refused errors?
If k6 shows request failures, your Express API is not reachable at http://localhost:3000/health. Open a separate terminal and start your API. Then run the k6 command again.
If you see a file-not-found error, make sure your terminal is inside the k6-tests folder. Run pwd to confirm.
help me troubleshoot why k6 cannot connect to my local Express API
Your TypeScript k6 project is wired up and running. Next up, you will expand this smoke test into a real load test that authenticates, creates data, and exercises your API under pressure.
Write a Naive Single-File Load Test
Your TypeScript k6 project is set up with a folder structure, type definitions, and a minimal test that hits your health endpoint.
A health check proves k6 can reach your API, but it tells you nothing about how your API handles real work. Real users authenticate, browse data, and create resources. You need a test that exercises these operations under load.
In this step, get ready to:
- Expand smoke.ts into a monolithic test with authentication and paginated requests.
- Add a request that creates a new item using the same hardcoded token.
- Run the test with multiple virtual users and observe k6's output.
Expand smoke.ts with authentication and list items
Your Express API requires a JWT token for most endpoints. The test needs to authenticate first, then use that token for every subsequent request.
- Replace the entire contents of src/tests/smoke.ts with this code:
import http from 'k6/http';
import { sleep } from 'k6';
// Hardcoded base URL for the Express API
const BASE_URL = 'http://localhost:3000';
export default function () {
// Authenticate by posting credentials to the login endpoint
const loginRes = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ username: 'testuser', password: 'testpassword' }),
{ headers: { 'Content-Type': 'application/json' } }
);
// Extract the JWT token from the response
const body = loginRes.json() as { token: string };
const token = body.token;
// Fetch a paginated list of items using the token
const listRes = http.get(`${BASE_URL}/items?page=1&limit=10`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
sleep(1);
}
What does this code do?
- BASE_URL is hardcoded at module level. Every request in this file builds on it.
- The default function runs once per iteration per virtual user. Each VU authenticates, grabs a token, then uses it for the list request.
- sleep(1) adds a 1-second pause between requests, simulating a real user pausing between actions.
- Save src/tests/smoke.ts.
- Confirm your Express API is running at http://localhost:3000 before continuing.
API not running?
This test needs your Express REST API actively running in a separate terminal. If it's not running, start it now before proceeding.
Help me get my Express API running for load testing.
Add a create item request
Browsing items is a read operation. A realistic test also writes data to exercise your API's create path under load.
- In src/tests/smoke.ts, find the sleep(1); line at the bottom of the function.
- Add this code below it (still inside the function):
// Create a new item with a unique timestamped name
http.post(
`${BASE_URL}/items`,
JSON.stringify({ name: `item-${Date.now()}`, description: 'Created during load test' }),
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
sleep(1);
What does this code do?
- The POST to /items creates a new item each iteration. Date.now() generates a unique name so items don't collide.
- Notice how the Authorization header is duplicated from the list request. That repetition is a maintenance smell you'll fix in Step 3.
- Save src/tests/smoke.ts.
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file before running the test.
ⓧ I'd like to double check the full code
Here is the complete src/tests/smoke.ts file at this point:
import http from 'k6/http';
import { sleep } from 'k6';
const BASE_URL = 'http://localhost:3000';
export default function () {
const loginRes = http.post(
`${BASE_URL}/auth/login`,
JSON.stringify({ username: 'testuser', password: 'testpassword' }),
{ headers: { 'Content-Type': 'application/json' } }
);
const body = loginRes.json() as { token: string };
const token = body.token;
const listRes = http.get(`${BASE_URL}/items?page=1&limit=10`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
});
sleep(1);
http.post(
`${BASE_URL}/items`,
JSON.stringify({ name: `item-${Date.now()}`, description: 'Created during load test' }),
{ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }
);
sleep(1);
}
Run the test with multiple virtual users
A single VU tells you if the code works. Multiple VUs tell you if your API holds up under concurrent load. You'll run 3 VUs for 10 seconds to see how the test behaves.
Before you run this, what do you expect to see? Will every request succeed, or might something fail with 3 users hitting the same endpoints simultaneously?
- Run your load test with 3 virtual users for 10 seconds:
k6 run --vus 3 --duration 10s src/tests/smoke.ts
You should see k6 output showing iteration counts, HTTP request durations, and a summary table. The requests to your login and list-items endpoints should complete successfully.
What does this output mean?
- --vus 3 tells k6 to simulate 3 concurrent users. Each VU runs the default function in a loop for the full duration.
- --duration 10s sets a 10-second time limit. After 10 seconds, k6 stops all VUs and prints the summary.
- The summary shows http_reqs (total requests made), http_req_duration (how long requests took), and iterations (how many complete cycles each VU finished).
🙋♀️ Seeing connection refused or 401 errors?
- If you see connection refused, your Express API is not running. Start it in a separate terminal.
- If you see 401 errors, check that your API accepts username testuser with password testpassword. These must match the credentials your API expects.
- If you see rate limiting errors (429 status), your API's rate limiter is kicking in. That's expected behavior. It means the test is working and pushing your API's limits.
Help me debug my k6 test output.
Your test works. Three virtual users are authenticating, listing items, and creating new items in parallel. But look at the code. The base URL is hardcoded in three places. The auth header is duplicated. Changing a single endpoint means hunting through the file. Next up, you'll refactor this into a production architecture where each file has one job.
Refactor Into a Production Architecture
In Step 2 you proved your test works. It authenticates, lists items, and creates data. But look at the code: the base URL is hardcoded, credentials are scattered inline, headers are duplicated on every request, and there is zero type safety.
Changing the base URL means editing the test file. Onboarding a teammate means explaining where everything lives. A modular architecture separates concerns so each file has exactly one job. In this step, you will extract configuration, define TypeScript interfaces, and build a typed API client that any team member can extend without reading your test code.
In this step, get ready to:
- Create an environment configuration module that reads __ENV variables with sensible local defaults.
- Define TypeScript interfaces for your API and build a typed client class.
- Refactor smoke.ts so it reads like a business scenario instead of HTTP plumbing.
Create the environment configuration
Right now your base URL and credentials are hardcoded inside the test. If you ever need to run against a staging server, you would have to edit the test file. An environment config module reads values from __ENV at runtime, falling back to local defaults when no variable is set.
- Create a new file src/config/env.ts in VS Code and paste the following code:
export interface EnvConfig {
baseUrl: string;
username: string;
password: string;
defaultTimeout: number;
}
export function getConfig(): EnvConfig {
return {
baseUrl: __ENV.BASE_URL || 'http://localhost:3000',
username: __ENV.TEST_USERNAME || 'testuser',
password: __ENV.TEST_PASSWORD || 'testpassword',
defaultTimeout: parseInt(__ENV.DEFAULT_TIMEOUT || '30000', 10),
};
}
What does this code do?
The EnvConfig interface defines the shape of your configuration so VS Code can autocomplete field names and catch typos at edit time.
The getConfig() function reads k6's __ENV object at runtime. If a variable is not set, the || fallback provides your local development defaults. To point tests at a different server, you pass -e BASE_URL=https://staging.example.com on the command line instead of editing code.
- Save src/config/env.ts.
- Confirm the file exists by running:
ls src/config
You should see env.ts in the output.
Don't see env.ts?
Make sure you created the file inside src/config/, not in the project root. The src/config/ folder should already exist from Step 1.
Help me find where my env.ts file ended up.
Define TypeScript interfaces and build the API client
Your naive test casts API responses with as { token: string } inline. That works once, but it gives you no autocomplete and no safety net when the API changes. TypeScript interfaces give every response a documented shape, and a client class wraps the raw HTTP calls so your tests never touch headers or URLs directly.
- Create src/types/api.ts and paste the following interfaces:
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
token: string;
}
export interface Item {
id: string;
name: string;
description: string;
createdAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
page: number;
limit: number;
total: number;
}
export interface CreateItemRequest {
name: string;
description: string;
}
What do these interfaces do?
- LoginRequest and LoginResponse describe the auth endpoint's contract.
- Item describes a single resource from your API.
- PaginatedResponse<T> is a generic wrapper. Any paginated endpoint returns data in this shape, regardless of what T is.
- CreateItemRequest ensures you never forget a required field when creating an item.
- Save src/types/api.ts.
Now you will build the API client class. This class wraps every HTTP call, attaches the auth header automatically, and returns typed responses.
- Create src/clients/api-client.ts and paste the class skeleton with the imports, constructor, header helper, and authentication method:
import http, { RefinedResponse, ResponseType } from 'k6/http';
import { getConfig, EnvConfig } from '../config/env.ts';
import { LoginRequest, LoginResponse, Item, PaginatedResponse, CreateItemRequest } from '../types/api.ts';
export class ApiClient {
private config: EnvConfig;
private token: string | null = null;
constructor() {
this.config = getConfig();
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
return headers;
}
authenticate(): string {
const payload: LoginRequest = { username: this.config.username, password: this.config.password };
const res = http.post(`${this.config.baseUrl}/auth/login`, JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } });
if (res.status === 200) {
const body = res.json() as unknown as LoginResponse;
this.token = body.token;
return this.token;
}
return '';
}
What does this code do?
- The constructor calls getConfig() to load environment settings. No hardcoded values live here.
- getHeaders() builds the headers object once. If a token exists, it attaches the Authorization header automatically.
- authenticate() posts credentials to the login endpoint, stores the JWT in the instance, and returns it. If auth fails, it returns an empty string.
- Add the remaining methods below the authenticate() method, still inside the class body:
isAuthenticated(): boolean {
return this.token !== null && this.token.length > 0;
}
browseItems(page: number = 1, limit: number = 10): RefinedResponse<ResponseType> {
return http.get(`${this.config.baseUrl}/items?page=${page}&limit=${limit}`, { headers: this.getHeaders() });
}
viewItem(id: string): RefinedResponse<ResponseType> {
return http.get(`${this.config.baseUrl}/items/${id}`, { headers: this.getHeaders() });
}
createItem(item: CreateItemRequest): RefinedResponse<ResponseType> {
return http.post(`${this.config.baseUrl}/items`, JSON.stringify(item), { headers: this.getHeaders() });
}
fetchItem(id: string): RefinedResponse<ResponseType> {
return http.get(`${this.config.baseUrl}/items/${id}`, { headers: this.getHeaders() });
}
}
What do these methods do?
- isAuthenticated() lets the test check whether it needs to log in before making requests.
- Each CRUD method (browseItems, viewItem, createItem, fetchItem) wraps one endpoint. Tests call these by name instead of building URLs and headers manually.
- Every method calls this.getHeaders() so the auth token is always attached. No more copy-pasting header objects.
- Save src/clients/api-client.ts.
- Confirm both new directories have files by running:
ls src/types src/clients
You should see api.ts under src/types and api-client.ts under src/clients.
Files not showing up?
Double check you saved each file in the correct subfolder. The folders src/types/ and src/clients/ should already exist from Step 1. If they do not, create them first.
Help me figure out where my files were saved.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: src/config/env.ts, src/types/api.ts, and src/clients/api-client.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm your src/clients/api-client.ts has these sections in order:
- Three import lines (k6/http, env.ts, api.ts).
- Class declaration with config and token private fields, then a constructor calling getConfig().
- getHeaders() private method that conditionally attaches the Bearer token.
- authenticate() method that posts credentials and stores the token.
- isAuthenticated(), browseItems(), viewItem(), createItem(), and fetchItem() methods, then the closing }.
Refactor smoke.ts to use the new modules
The entire point of the refactor is that your test file should read like a business scenario. Instead of raw URLs, JSON parsing, and header objects, the test says "authenticate, browse items, view one, create one."
- Open src/tests/smoke.ts and replace the entire contents with the following:
import { sleep } from 'k6';
import { ApiClient } from '../clients/api-client.ts';
import { Item, PaginatedResponse } from '../types/api.ts';
// Create a client instance (one per VU)
const client = new ApiClient();
export default function () {
// Authenticate only on first iteration
if (!client.isAuthenticated()) {
client.authenticate();
}
// Browse items with pagination
const browseRes = client.browseItems(1, 10);
sleep(1);
// View a single item if any exist
const items = browseRes.json() as unknown as PaginatedResponse<Item>;
if (items.data && items.data.length > 0) {
client.viewItem(items.data[0].id);
}
// Create a new item
const createRes = client.createItem({ name: `item-${Date.now()}`, description: 'Created during load test' });
sleep(1);
}
What changed?
- No hardcoded URLs or credentials. The client reads those from env.ts internally.
- No duplicate header objects. The client handles auth headers automatically.
- The client is declared at module level, so each virtual user gets its own instance. The isAuthenticated() check means authentication only runs on the first iteration per VU.
- A teammate reading this file immediately understands the user journey without knowing anything about HTTP headers or token management.
- Save src/tests/smoke.ts.
Before you run the refactored test, what do you expect the output to look like compared to Step 2? The behavior should be identical since you only changed structure, not logic.
- Run the refactored test by executing:
k6 run src/tests/smoke.ts
You should see k6 output with request durations, iteration counts, and the end-of-test summary. The results are identical to Step 2. The refactor changed structure without breaking behavior.
Seeing import errors?
- Check that every import path ends with .ts. k6 requires the full extension in import paths.
- Verify the relative paths match your folder structure. From src/tests/smoke.ts, the client lives at ../clients/api-client.ts (one level up, then into clients/).
- Make sure your Express API is still running on http://localhost:3000. The config defaults point there.
Help me debug my k6 import error.
✔️ Awesome, I've got everything!
Great. Your refactored test runs identically to the monolithic version from Step 2.
ⓧ I'd like to double check the full code
Here is the complete src/tests/smoke.ts:
import { sleep } from 'k6';
import { ApiClient } from '../clients/api-client.ts';
import { Item, PaginatedResponse } from '../types/api.ts';
const client = new ApiClient();
export default function () {
if (!client.isAuthenticated()) {
client.authenticate();
}
const browseRes = client.browseItems(1, 10);
sleep(1);
const items = browseRes.json() as unknown as PaginatedResponse<Item>;
if (items.data && items.data.length > 0) {
client.viewItem(items.data[0].id);
}
const createRes = client.createItem({ name: `item-${Date.now()}`, description: 'Created during load test' });
sleep(1);
}
Why does this architecture matter?
Production performance teams maintain test suites for months or years. When a new endpoint ships, you add one method to ApiClient and one interface to api.ts. The test file stays focused on the user journey.
When you switch from local to staging, you pass -e BASE_URL=... on the command line. Zero code changes.
Your test suite now has a clean architecture where each file has one job. Next up, you will add response validation checks and custom business metrics so you can answer "which operation is degrading?" instead of just "are requests slow?"
Add User Journeys with Checks and Custom Metrics
Your modular test fires requests and gets responses, but it has no idea whether those responses are correct. A load test without response validation is like deploying code without assertions.
In this step, you'll add checks that validate every response and custom metrics that track per-operation performance. Instead of just knowing "requests are slow," you'll answer "which specific operation is degrading?"
In this step, get ready to:
- Create reusable check helper functions for status codes and response times.
- Define custom Trend and Rate metrics that track each business operation separately.
- Build a realistic user journey with checks and metrics feeding the k6 summary.
Create reusable check helpers
k6 provides a built-in check() function that validates responses without aborting the test. Instead of scattering raw check() calls with duplicated logic, you'll create reusable helper functions in a dedicated module.
- Create a new file src/helpers/checks.ts and add the first two check functions by pasting this code:
import { check } from 'k6';
import { RefinedResponse, ResponseType } from 'k6/http';
export function checkStatus(
res: RefinedResponse<ResponseType>,
expectedStatus: number,
label: string
): boolean {
return check(res, {
[`${label} status is ${expectedStatus}`]: (r) => r.status === expectedStatus,
});
}
export function checkResponseTime(
res: RefinedResponse<ResponseType>,
maxMs: number,
label: string
): boolean {
return check(res, {
[`${label} responded within ${maxMs}ms`]: (r) => r.timings.duration < maxMs,
});
}
What does this code do?
Each function wraps k6's check() with a descriptive label that appears in the end-of-test summary. checkStatus validates the HTTP status code. checkResponseTime validates that res.timings.duration stays below a threshold in milliseconds.
Both return a boolean so callers can react to failures if needed. The label parameter generates unique check names like "Browse items status is 200" in the k6 output.
- Now add the third helper function below the existing code in src/helpers/checks.ts:
export function checkBodyContains(
res: RefinedResponse<ResponseType>,
field: string,
label: string
): boolean {
return check(res, {
[`${label} body contains ${field}`]: (r) => {
const body = r.body as string;
return body.includes(field);
},
});
}
What does checkBodyContains do?
This helper checks whether the response body includes a specific string. It is useful for verifying that JSON responses contain expected fields like "token" or "data" without parsing the entire structure.
- Save src/helpers/checks.ts.
- Confirm the file exists by running:
ls src/helpers/
You should see checks.ts listed in the output.
Don't see checks.ts?
Make sure you created the file inside src/helpers/, not in the project root. If the helpers directory does not exist, create it first with mkdir -p src/helpers.
help me create the checks.ts file in the right location
Define custom metrics and instrument the API client
k6's built-in http_req_duration metric tells you the average latency across all requests. Custom metrics let you break that down by business operation so you can pinpoint exactly which endpoint is degrading under load.
- Create a new file src/metrics/custom.ts with the following metric definitions:
import { Trend, Rate } from 'k6/metrics';
export const authDuration = new Trend('auth_duration', true);
export const browseItemsDuration = new Trend('browse_items_duration', true);
export const viewItemDuration = new Trend('view_item_duration', true);
export const createItemDuration = new Trend('create_item_duration', true);
export const fetchCreatedItemDuration = new Trend('fetch_created_item_duration', true);
export const errorRate = new Rate('error_rate');
What are Trend and Rate metrics?
A Trend records timing values and reports min, max, avg, median, and percentiles (p90, p95) in the summary. Passing true as the second argument tells k6 to treat values as milliseconds.
A Rate tracks the percentage of non-zero values. Adding 1 for errors and 0 for successes gives you an error percentage in the final output.
- Save src/metrics/custom.ts.
Now you need to wire these metrics into your API client so every request automatically records its timing.
- Open src/clients/api-client.ts and find the existing import lines at the top of the file.
- Add this new import line below the existing imports:
import { authDuration, browseItemsDuration, viewItemDuration, createItemDuration, fetchCreatedItemDuration, errorRate } from '../metrics/custom.ts';
- Next, add a recordMetrics private method inside the ApiClient class, directly below the getHeaders() method:
private recordMetrics(
res: RefinedResponse<ResponseType>,
metric: { add(value: number, tags?: Record<string, string>): void },
tags?: Record<string, string>
): void {
metric.add(res.timings.duration, tags);
errorRate.add(res.status >= 400 ? 1 : 0, tags);
}
What does recordMetrics do?
This helper takes any response, the target Trend metric, and optional tags. It records the response duration into the specific operation metric and simultaneously feeds the global errorRate with a 1 (error) or 0 (success) based on status code.
Every API method will call this after each request, so you get per-operation timing and a single cross-cutting error rate with no repeated code.
- Replace the existing authenticate() method with this updated version that adds operation tags and records metrics:
authenticate(): string {
const payload: LoginRequest = { username: this.config.username, password: this.config.password };
const res = http.post(`${this.config.baseUrl}/auth/login`, JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' }, tags: { operation: 'auth' } });
this.recordMetrics(res, authDuration, { operation: 'auth' });
if (res.status === 200) {
const body = res.json() as unknown as LoginResponse;
this.token = body.token;
return this.token;
}
return '';
}
- Replace the remaining four methods (browseItems, viewItem, createItem, fetchItem) with these updated versions:
browseItems(page: number = 1, limit: number = 10): RefinedResponse<ResponseType> {
const res = http.get(`${this.config.baseUrl}/items?page=${page}&limit=${limit}`, { headers: this.getHeaders(), tags: { operation: 'browse_items' } });
this.recordMetrics(res, browseItemsDuration, { operation: 'browse_items' });
return res;
}
viewItem(id: string): RefinedResponse<ResponseType> {
const res = http.get(`${this.config.baseUrl}/items/${id}`, { headers: this.getHeaders(), tags: { operation: 'view_item' } });
this.recordMetrics(res, viewItemDuration, { operation: 'view_item' });
return res;
}
createItem(item: CreateItemRequest): RefinedResponse<ResponseType> {
const res = http.post(`${this.config.baseUrl}/items`, JSON.stringify(item), { headers: this.getHeaders(), tags: { operation: 'create_item' } });
this.recordMetrics(res, createItemDuration, { operation: 'create_item' });
return res;
}
fetchItem(id: string): RefinedResponse<ResponseType> {
const res = http.get(`${this.config.baseUrl}/items/${id}`, { headers: this.getHeaders(), tags: { operation: 'fetch_created_item' } });
this.recordMetrics(res, fetchCreatedItemDuration, { operation: 'fetch_created_item' });
return res;
}
What changed in each method?
Each method now stores the response in a res variable, passes an operation tag via the request options, and calls this.recordMetrics() before returning. The tag connects this request to its custom metric so k6 can report per-operation statistics separately.
- Save src/clients/api-client.ts.
- Verify that the custom metrics appear by running your existing test:
k6 run src/tests/smoke.ts
In the end-of-test summary, you should now see your custom metrics listed under a CUSTOM section, including auth_duration, browse_items_duration, and error_rate alongside the built-in HTTP metrics.
Not seeing custom metrics in the output?
If you see an import error, make sure the import path in api-client.ts ends with .ts (e.g. '../metrics/custom.ts'). k6's TypeScript support requires the file extension in import paths.
If the test runs but you only see built-in metrics, confirm that recordMetrics is called inside each method and that you saved both custom.ts and api-client.ts.
help me debug why my custom k6 metrics are not appearing in the summary
Build the full user journey with checks
Now that metrics are recording and checks are available, you'll replace your smoke.ts with a complete user journey. The journey models what a real user does: log in, browse items, view one, create a new one, then fetch the created item to confirm it exists.
- Open src/tests/smoke.ts and replace everything from the imports through the browse section with this code:
import { sleep } from 'k6';
import { ApiClient } from '../clients/api-client.ts';
import { checkStatus, checkResponseTime } from '../helpers/checks.ts';
import { Item, PaginatedResponse } from '../types/api.ts';
const client = new ApiClient();
export default function () {
// Step 1: Authenticate on first iteration
if (!client.isAuthenticated()) {
client.authenticate();
}
// Step 2: Browse items with pagination
const browseRes = client.browseItems(1, 10);
checkStatus(browseRes, 200, 'Browse items');
checkResponseTime(browseRes, 500, 'Browse items');
sleep(1);
What changed so far?
You added the checkStatus and checkResponseTime imports from your new helpers module. After each API call, a check validates the response. If a check fails, k6 continues running but tracks the failure rate in the summary.
- Now add the rest of the default function below the sleep(1) line, completing the user journey:
// Step 3: View a single item from the list
const items = browseRes.json() as unknown as PaginatedResponse<Item>;
if (items.data && items.data.length > 0) {
const firstItem = items.data[0];
const viewRes = client.viewItem(firstItem.id);
checkStatus(viewRes, 200, 'View item');
}
sleep(0.5);
// Step 4: Create a new item
const createRes = client.createItem({
name: `perf-test-item-${Date.now()}`,
description: 'Created during load test',
});
checkStatus(createRes, 201, 'Create item');
// Step 5: Fetch the item we just created
if (createRes.status === 201) {
const created = createRes.json() as unknown as Item;
const fetchRes = client.fetchItem(created.id);
checkStatus(fetchRes, 200, 'Fetch created item');
checkResponseTime(fetchRes, 300, 'Fetch created item');
}
sleep(1);
}
What does the complete journey do?
After browsing, the virtual user picks the first item from the paginated list and views it. Then it creates a brand new item with a unique timestamp name and immediately fetches it back to confirm it was stored.
The sleep() calls simulate realistic think time between actions. Without them, your test would hit the API as fast as possible, which is unrealistic and can trigger rate limiting.
- Save src/tests/smoke.ts.
Before you run the test, what do you expect to see in the k6 output now that checks and custom metrics are both active?
- Run the test with 5 virtual users for 15 seconds:
k6 run --vus 5 --duration 15s src/tests/smoke.ts
In the end-of-test summary, you should see two new categories of information. Under TOTAL RESULTS, look for checks_succeeded showing a pass rate percentage, with individual checks listed (like "Browse items status is 200"). Under CUSTOM, look for your named metrics like auth_duration, create_item_duration, and error_rate each with their own statistical breakdown.
Seeing check failures or errors?
If checks_failed is non-zero, check which specific assertion is failing. A "Create item status is 201" failure might mean your Express API returns a different status code for item creation. Adjust the expected status to match your API.
If you see a connection error, make sure your Express API is still running on http://localhost:3000.
help me understand why my k6 checks are failing
✔️ Awesome, I've got everything!
Great. Double check you saved both src/clients/api-client.ts and src/tests/smoke.ts.
ⓧ I'd like to double check the full code
Your project now has several files that changed in this step. Scroll up and confirm each chunk is present and in order:
- src/helpers/checks.ts contains checkStatus, checkResponseTime, and checkBodyContains (3 exported functions).
- src/metrics/custom.ts contains 5 Trend exports and 1 Rate export.
- src/clients/api-client.ts has the new import from '../metrics/custom.ts', the recordMetrics method, tags on every HTTP call, and recordMetrics called after every request.
- src/tests/smoke.ts imports checkStatus and checkResponseTime, and the default function runs the full 5-step journey with checks after each call.
Your test now validates correctness and tracks per-operation performance. Next up, you'll add multiple scenario profiles and thresholds that automatically fail the test when performance regresses.
Add Scenarios, Thresholds, Tags, and Per-VU Auth
Your test suite runs requests and reports metrics, but it uses a single flat load profile. Real production traffic does not look like that.
Production systems face smoke tests in CI, sustained load during working hours, stress peaks around launches, and sudden traffic spikes from viral events. In this step, you will create scenario configurations that model each of these patterns, add thresholds that auto-fail the test when performance regresses, and implement per-VU authentication so every virtual user holds its own JWT session.
In this step, get ready to:
- Create named scenario configurations with appropriate executors and per-scenario tags.
- Add thresholds with tagged sub-metric limits per endpoint.
- Implement per-VU authentication and run a multi-scenario load test.
Create the smoke scenario with thresholds
k6 scenarios define how virtual users are scheduled during a test. Each scenario picks an executor that controls the VU count and duration. You also attach thresholds that define pass/fail criteria for your metrics.
- Create src/scenarios/options.ts with the smoke scenario configuration by adding this code:
import { Options } from 'k6/options';
export const smokeOptions: Options = {
scenarios: {
smoke: {
executor: 'constant-vus',
vus: 1,
duration: '30s',
tags: { test_type: 'smoke' },
},
},
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.05'],
auth_duration: ['p(95)<300'],
error_rate: ['rate<0.1'],
},
};
What does this code do?
The constant-vus executor runs exactly 1 VU for 30 seconds. This is perfect for a quick smoke test in CI where you just want to confirm nothing is broken.
The tags object attaches metadata to every metric generated by this scenario. You can filter by these tags later in dashboards or threshold rules.
Thresholds define pass/fail gates. p(95)<500 means "the 95th percentile of request duration must be below 500ms." rate<0.05 means "fewer than 5% of requests may fail." If any threshold is breached, k6 exits with a non-zero code.
Now wire the smoke test to use this configuration.
- In src/tests/smoke.ts, find the import block at the top of the file:
import { sleep } from 'k6';
import { ApiClient } from '../clients/api-client.ts';
import { checkStatus, checkResponseTime } from '../helpers/checks.ts';
import { Item, PaginatedResponse } from '../types/api.ts';
- Add a new import for smokeOptions and export it as options, so the top of the file now looks like this:
import { sleep } from 'k6';
import { ApiClient } from '../clients/api-client.ts';
import { checkStatus, checkResponseTime } from '../helpers/checks.ts';
import { smokeOptions } from '../scenarios/options.ts';
import { Item, PaginatedResponse } from '../types/api.ts';
export const options = smokeOptions;
Why export options?
When k6 finds an exported options object in your test file, it uses that configuration to schedule VUs, apply thresholds, and tag metrics. Without it, k6 defaults to 1 VU running for a single iteration with no thresholds.
Before you run, what do you think will be different in the output compared to previous runs?
- Run your smoke test with the new configuration:
k6 run src/tests/smoke.ts
At the end of the summary, you now see a thresholds section showing each threshold with a green checkmark (pass) or red cross (fail). Your custom metrics auth_duration and error_rate appear alongside the built-in k6 metrics.
Not seeing thresholds in the output?
Make sure you added export const options = smokeOptions; in smoke.ts. The variable must be named options exactly. k6 only picks up this specific export name.
If you see a reference error for smokeOptions, confirm the import path matches '../scenarios/options.ts' and the file is saved in src/scenarios/.
Still stuck? Why aren't my k6 thresholds showing up in the output?
Add load, stress, and spike scenarios
The smoke config runs a single VU at a constant rate. Real traffic ramps up, holds at a peak, and ramps back down. The ramping-vus executor gives you that shape through a stages array.
- In src/scenarios/options.ts, add the load scenario configuration below smokeOptions:
export const loadOptions: Options = {
scenarios: {
load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '30s', target: 5 },
{ duration: '1m', target: 10 },
{ duration: '30s', target: 0 },
],
tags: { test_type: 'load' },
},
},
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.05'],
auth_duration: ['p(95)<300'],
error_rate: ['rate<0.1'],
'http_req_duration{operation:browse_items}': ['p(95)<400'],
'http_req_duration{operation:create_item}': ['p(95)<600'],
},
};
What are tagged sub-metric thresholds?
The syntax 'http_req_duration{operation:browse_items}': ['p(95)<400'] applies a threshold only to requests tagged with operation: 'browse_items'. Your API client already attaches these tags on every call.
This lets you set tighter limits on read endpoints (400ms) and looser limits on write endpoints (600ms) while keeping a global ceiling at 500ms. If one endpoint degrades, you know exactly which one tripped.
- Below loadOptions, add the stress scenario:
export const stressOptions: Options = {
scenarios: {
stress: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '20s', target: 10 },
{ duration: '30s', target: 25 },
{ duration: '20s', target: 0 },
],
tags: { test_type: 'stress' },
},
},
thresholds: {
http_req_duration: ['p(95)<800'],
http_req_failed: ['rate<0.10'],
auth_duration: ['p(95)<500'],
error_rate: ['rate<0.15'],
},
};
- Below stressOptions, add the spike scenario:
export const spikeOptions: Options = {
scenarios: {
spike: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '5s', target: 20 },
{ duration: '10s', target: 20 },
{ duration: '5s', target: 0 },
],
tags: { test_type: 'spike' },
},
},
thresholds: {
http_req_duration: ['p(95)<1000'],
http_req_failed: ['rate<0.15'],
error_rate: ['rate<0.20'],
},
};
Why progressively looser thresholds?
Each scenario models a different severity of traffic. Smoke expects pristine performance (500ms p95). Load expects the same under sustained concurrency. Stress allows degradation up to 800ms because you are pushing beyond normal capacity.
Spike allows up to 1000ms because sudden bursts overwhelm connection pools and caches before the system adjusts. If latency exceeds even these relaxed limits, you have a real problem.
- Save src/scenarios/options.ts.
These scenario configurations become active when a test file imports them. Next, you will create a load test that uses loadOptions.
✔️ Awesome, I've got everything!
Great. Make sure you saved src/scenarios/options.ts with all four exports: smokeOptions, loadOptions, stressOptions, and spikeOptions.
ⓧ I'd like to double check the full code
Your src/scenarios/options.ts file should contain, in order:
- The import { Options } from 'k6/options'; line at the top.
- smokeOptions using the constant-vus executor with 1 VU and 30s duration.
- loadOptions using ramping-vus with three stages (0 to 5, hold 10, back to 0) plus tagged sub-metric thresholds for browse_items and create_item.
- stressOptions using ramping-vus with stages reaching 25 VUs and relaxed thresholds (p95 < 800ms).
- spikeOptions using ramping-vus with a sudden burst to 20 VUs and the loosest thresholds (p95 < 1000ms).
Scroll up and compare each chunk to confirm they match.
Implement per-VU auth and create the load test
In production, every user has their own session. Your test should model the same behavior. k6 runs each VU in its own JavaScript runtime, so a class instantiated at module level is unique per VU.
What is per-VU authentication?
The ApiClient instance lives at module level (outside the default function). k6 creates one copy per VU. On the first iteration, isAuthenticated() returns false, so the VU authenticates and caches its token. On subsequent iterations, the cached token is reused.
This means 10 VUs each hold 10 independent JWT sessions, just like 10 real users would. A shared token from setup() would mask auth-related bottlenecks because all VUs share a single session.
- Create src/tests/load.ts with the following imports and configuration:
import { sleep } from 'k6';
import { ApiClient } from '../clients/api-client.ts';
import { checkStatus, checkResponseTime } from '../helpers/checks.ts';
import { loadOptions } from '../scenarios/options.ts';
import { Item, PaginatedResponse } from '../types/api.ts';
// Wire the load scenario configuration
export const options = loadOptions;
// Each VU gets its own ApiClient instance with its own token
const client = new ApiClient();
- Below the client declaration, add the default function. This is the same user journey from smoke.ts:
export default function () {
// Authenticate on first iteration only
if (!client.isAuthenticated()) {
client.authenticate();
}
// Browse items with pagination
const browseRes = client.browseItems(1, 10);
checkStatus(browseRes, 200, 'Browse items');
checkResponseTime(browseRes, 500, 'Browse items');
sleep(1);
// View a single item
const items = browseRes.json() as unknown as PaginatedResponse<Item>;
if (items.data && items.data.length > 0) {
const firstItem = items.data[0];
const viewRes = client.viewItem(firstItem.id);
checkStatus(viewRes, 200, 'View item');
}
sleep(0.5);
// Create a new item
const createRes = client.createItem({
name: `perf-test-item-${Date.now()}`,
description: 'Created during load test',
});
checkStatus(createRes, 201, 'Create item');
- Add the remaining lines of the default function to complete the fetch-and-sleep section:
// Fetch the created item to verify consistency
if (createRes.status === 201) {
const created = createRes.json() as unknown as Item;
const fetchRes = client.fetchItem(created.id);
checkStatus(fetchRes, 200, 'Fetch created item');
checkResponseTime(fetchRes, 300, 'Fetch created item');
}
sleep(1);
}
What does this test file do?
This file imports loadOptions which uses ramping-vus: k6 will ramp from 0 to 5 VUs over 30 seconds, then up to 10 VUs over a minute, then back to 0. Each VU runs the full user journey (login, browse, view, create, fetch) on every iteration.
The per-VU auth pattern ensures each virtual user authenticates independently. If your auth endpoint becomes a bottleneck under load, you will see it in the auth_duration metric.
- Save src/tests/load.ts.
Before you run, predict: will the thresholds pass or fail against your local API?
- Run the load test:
k6 run src/tests/load.ts
You see k6 ramp VUs from 0 to 5 to 10, then back down. The end-of-test summary shows threshold results with pass/fail indicators next to each one. Your custom metrics auth_duration, browse_items_duration, create_item_duration, and error_rate all appear with their p95 and rate values.
Seeing threshold failures on your local API?
If http_req_duration fails the p95 threshold, your Express API may have realistic latency that exceeds the limit under 10 concurrent VUs. This is expected behavior. The threshold is catching a real performance signal.
If you see connection errors or timeouts, confirm your Express API is still running in its terminal. k6 does not start the API for you.
Need help? My k6 load test thresholds are all failing locally. How do I debug this?
✔️ Awesome, I've got everything!
Great. Make sure src/tests/load.ts is saved and your terminal shows the full k6 summary with thresholds.
ⓧ I'd like to double check the full code
Your src/tests/load.ts should contain, in order:
- Five imports: sleep from k6, ApiClient, checkStatus and checkResponseTime, loadOptions, and the type imports.
- export const options = loadOptions; line.
- const client = new ApiClient(); at module level.
- The default function with the isAuthenticated() check, then browse, view, create, and fetch sections separated by sleep() calls.
For src/tests/smoke.ts, confirm you have the import { smokeOptions } line and export const options = smokeOptions; above the client declaration. The rest of the file is unchanged.
Your test suite now models realistic traffic patterns and automatically fails when performance regresses. Next up, you will add HTML reporting, a setup/teardown lifecycle, and a CI-ready execution script in the Secret Mission.
Secret mission
HTML Reporting, Setup/Teardown, and CI Execution
Your test suite runs and reports to the terminal. Production teams need shareable HTML reports, pre-flight health checks, and automated CI scripts. Add all three to your k6 project.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with open-source tools, so there are no ongoing costs.
Resources you used:
- The k6-tests/ project directory containing all TypeScript source files, configuration, and the shell script.
- The reports/ directory inside the project containing generated HTML and JSON report files.
- The node_modules/ directory inside the project containing the @types/k6 package.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the test suite with more scenarios, integrate with Grafana Cloud k6, or add it to your CI pipeline.
- Your k6-tests/ directory stays on your Desktop, ready to run whenever you start your API.
- The generated HTML reports in reports/ remain available to open in your browser.
- k6 and Bun remain installed on your machine from before this project.
Pause - I'll come back to this later
Free up disk space from generated reports while keeping your source files intact.
- Delete the generated reports by running this from inside your k6-tests/ directory:
rm -rf reports/
- When you come back, run mkdir -p reports and your tests will generate fresh reports on the next run.
Delete - I don't want to use this again
Remove all project files from your machine. This deletes your entire test suite, reports, and installed packages.
- Navigate to your Desktop and delete the project directory.
macOS
cd ~/Desktop
rm -rf k6-tests
Windows
cd ~\Desktop
Remove-Item -Recurse -Force k6-tests
- Confirm the directory is gone by running ls on your Desktop. You should no longer see k6-tests in the output.
Nice Work!
Nice Work!
You just built a production-quality k6 load testing suite in TypeScript. This is the same architecture real performance engineering teams use to catch regressions, validate SLAs, and prove system reliability before code ships.
Here's what you learned:
- Built a modular k6 test suite in TypeScript with typed API clients, environment-aware configuration, and reusable helpers that separate test logic from HTTP plumbing.
- Implemented multi-scenario load profiles (smoke, load, stress, spike) with custom business metrics, per-VU authentication, and thresholds that auto-fail the test when performance regresses.
- Defined realistic user journeys with checks validating every response and custom Trend/Rate metrics that answer "which operation is degrading?" instead of just "are requests slow?".
- Secret Mission: Wired up HTML reporting via k6-reporter, added setup/teardown lifecycle hooks, and created a CI-ready shell script for automated execution.
Ready to quiz yourself?