K6-based performance testing framework with TypeScript support.
- Node.js >= 18
- k6 >= 1.0.0
# Clone repository
git clone <repository-url>
cd performance-test-fw
# Install dependencies
pnpm install
# Build project (compiles TypeScript to dist/)
pnpm build
# Install k6
# macOS
brew install k6
# Linux (Debian/Ubuntu)
sudo gpg -k
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
# Windows
winget install k6 --source wingetsrc/
config/ # Environment and API configurations
data/ # Test data files (CSV, JSON)
profiles/ # Test profiles (smoke, load, stress, etc.)
steps/ # Reusable API step functions
tests/ # Test scripts (.test.ts)
types/ # Type definitions
utils/ # Utility functions (HTTP, logging, etc.)
dist/ # Compiled JavaScript (auto-generated)
lib/ # k6-reporter plugin
results/ # HTML reports and JSON summaries
logs/ # Test execution logs
The framework uses TypeScript. Files must be compiled before running with k6.
# Build all test files
pnpm build
# Build with watch mode (auto-rebuild on changes)
pnpm build:watchBuild output:
src/tests/<name>.test.tscompiles todist/<name>.test.js- Group metrics are auto-detected and registered during build
Create step files in src/steps/ for reusable API operations.
Example: src/steps/user.steps.ts
import { get, post, parseJson } from '@utils/http.utils';
import { step, log, logHttpFailure } from '@utils/check.utils';
import { ApiContext, getAuthHeaders, StepResult, createResult } from '@types/index';
/**
* GET /users - Fetch all users
*
* Example Response:
* {
* "users": [
* { "id": "123", "name": "John Doe", "email": "john@example.com" },
* { "id": "456", "name": "Jane Doe", "email": "jane@example.com" }
* ]
* }
*/
export function getUsers(ctx: ApiContext): StepResult {
const result = createResult();
step('GET /users', () => {
const res = get(`${ctx.baseUrl}/users`, getAuthHeaders(ctx));
result.statusCode = res.status;
result.duration = res.timings.duration;
if (res.status !== 200) {
result.error = `Failed: ${res.status}`;
logHttpFailure(res);
return;
}
const data = parseJson<{ users: Array<{ id: string; name: string; email: string }> }>(res);
if (data?.users) {
result.success = true;
result.data = data.users;
log.info(`Found ${data.users.length} users`);
}
});
return result;
}
/**
* POST /users - Create a new user
*
* Example Request:
* {
* "name": "Test User",
* "email": "test@example.com"
* }
*
* Example Response:
* {
* "user": {
* "id": "789",
* "name": "Test User",
* "email": "test@example.com",
* "created_at": "2024-01-15T10:30:00Z"
* }
* }
*/
export function createUser(ctx: ApiContext, payload: { name: string; email: string }): StepResult {
const result = createResult();
step('POST /users', () => {
const res = post(`${ctx.baseUrl}/users`, payload, getAuthHeaders(ctx));
result.statusCode = res.status;
result.duration = res.timings.duration;
if (res.status !== 201) {
result.error = `Failed: ${res.status}`;
logHttpFailure(res, payload);
return;
}
const data = parseJson<{ user: { id: string; name: string; email: string; created_at: string } }>(res);
if (data?.user) {
result.success = true;
result.data = data.user;
log.info(`User created: ${data.user.id}`);
}
});
return result;
}
/**
* GET /users/:id - Fetch a specific user by ID
*
* Example Response:
* {
* "user": {
* "id": "789",
* "name": "Test User",
* "email": "test@example.com",
* "created_at": "2024-01-15T10:30:00Z"
* }
* }
*/
export function getUserById(ctx: ApiContext, userId: string): StepResult {
const result = createResult();
step(`GET /users/${userId}`, () => {
const res = get(`${ctx.baseUrl}/users/${userId}`, getAuthHeaders(ctx));
result.statusCode = res.status;
result.duration = res.timings.duration;
if (res.status !== 200) {
result.error = `Failed: ${res.status}`;
logHttpFailure(res);
return;
}
const data = parseJson<{ user: { id: string; name: string; email: string } }>(res);
if (data?.user) {
result.success = true;
result.data = data.user;
log.info(`Found user: ${data.user.id}`);
}
});
return result;
}Create test files in src/tests/ with the naming convention <name>.test.ts.
Example: src/tests/user-flow.test.ts
import { thinkTime } from '@utils/http.utils';
import { ApiContext } from '@types/index';
import { getUsers, createUser, getUserById } from '@steps/user.steps';
import { flow, testGroup, step, assert, log } from '@utils/check.utils';
import { createSummary } from '@utils/summary.utils';
/**
* Test Hierarchy Annotations:
*
* flow() - Top-level test scenario. Groups related testGroups together.
* Use for: Complete user journeys (e.g., "User Registration Flow")
* Metrics: Tracks total duration of entire flow
*
* testGroup() - Logical grouping of related steps within a flow.
* Use for: Feature areas or API domains (e.g., "1. Authentication")
* Metrics: Tracks duration, iterations, shown in HTML report
*
* step() - Individual operation, typically a single API call.
* Use for: Atomic actions (e.g., "GET /users")
* Metrics: Tracks duration at granular level
*
* Hierarchy: flow > testGroup > step
* All three record timing metrics automatically for the HTML report.
*/
// Configuration from environment variables
const CONFIG = {
baseUrl: __ENV.BASE_URL || 'https://api.example.com',
token: __ENV.TOKEN || '',
};
// Test options
export const options = {
vus: parseInt(__ENV.VUS || '1'),
iterations: parseInt(__ENV.ITERATIONS || '1'),
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
thresholds: {
http_req_duration: ['p(95)<500'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.95'],
},
};
// Setup (runs once before test)
export function setup(): ApiContext {
if (!CONFIG.token) {
throw new Error('TOKEN environment variable is required');
}
return {
baseUrl: CONFIG.baseUrl,
token: CONFIG.token,
};
}
// Main test function (runs per iteration per VU)
export default function (ctx: ApiContext): void {
// Variables to pass data between steps
let createdUserId: string;
// flow() - Wraps the entire test scenario
flow('User Flow', () => {
// testGroup() - Groups related operations
testGroup('1. List Users', () => {
const result = getUsers(ctx);
// Assertions on the step result
assert.isTrue(result.success, 'API call successful');
assert.equals(result.statusCode, 200, 'Status code is 200');
// Assertions on response payload
const users = result.data as Array<{ id: string; name: string; email: string }>;
assert.isTrue(Array.isArray(users), 'Response contains users array');
assert.isTrue(users.length > 0, 'At least one user exists');
log.info(`Found ${users.length} users`);
});
thinkTime(1, 2);
testGroup('2. Create User', () => {
const testEmail = `test-${Date.now()}@example.com`;
const result = createUser(ctx, {
name: 'Test User',
email: testEmail,
});
// Assertions on the step result
assert.isTrue(result.success, 'User creation successful');
assert.equals(result.statusCode, 201, 'Status code is 201');
// Assertions on response payload
const user = result.data as { id: string; name: string; email: string; created_at: string };
assert.isTrue(!!user.id, 'User has ID');
assert.equals(user.name, 'Test User', 'Name matches request');
assert.equals(user.email, testEmail, 'Email matches request');
// Store user ID for use in next step
createdUserId = user.id;
log.info(`Created user with ID: ${createdUserId}`);
});
thinkTime(1, 2);
// Example: Using data from previous step in another API call
testGroup('3. Verify Created User', () => {
// Use the ID from step 2 to fetch the created user
const result = getUserById(ctx, createdUserId);
assert.isTrue(result.success, 'Fetch user successful');
assert.equals(result.statusCode, 200, 'Status code is 200');
// Verify the fetched user matches what we created
const user = result.data as { id: string; name: string; email: string };
assert.equals(user.id, createdUserId, 'User ID matches created user');
assert.equals(user.name, 'Test User', 'Name matches created user');
log.info(`Verified user: ${user.id}`);
});
});
}
// Generate reports (runs once after all iterations complete)
export function handleSummary(data: unknown): Record<string, string> {
return createSummary(data as Parameters<typeof createSummary>[0], {
testName: 'user-flow',
slack: __ENV.SLACK_WEBHOOK_URL ? {
webhookUrl: __ENV.SLACK_WEBHOOK_URL,
environment: __ENV.ENVIRONMENT || 'test',
} : undefined,
});
}The run.sh script automatically builds before running.
# Basic run
./run.sh <test-name>
# With environment variables
./run.sh <test-name> -e TOKEN=xxx -e BASE_URL=https://api.example.com
# With VUs and iterations
./run.sh <test-name> -e TOKEN=xxx -e VUS=5 -e ITERATIONS=10
# With duration instead of iterations
./run.sh <test-name> -e TOKEN=xxx --vus 10 --duration 5m
# With Slack notifications
./run.sh <test-name> -e TOKEN=xxx -e SLACK_WEBHOOK_URL=https://hooks.slack.com/xxxpnpm build # Build TypeScript to dist/
pnpm test # Run default test
pnpm clean # Clean dist, results, logs# Build first (required before running k6)
pnpm build
# Run compiled test from dist/
k6 run dist/<test-name>.test.js -e TOKEN=xxxexport const options = {
vus: 1,
iterations: 10,
thresholds: {
http_req_duration: ['p(95)<500'],
},
};export const options = {
vus: 10,
duration: '5m',
thresholds: {
http_req_duration: ['p(95)<500'],
},
};export const options = {
stages: [
{ duration: '2m', target: 10 }, // Ramp up
{ duration: '5m', target: 10 }, // Steady
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'],
},
};export const options = {
stages: [
{ duration: '2m', target: 10 },
{ duration: '5m', target: 50 },
{ duration: '5m', target: 100 },
{ duration: '5m', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.1'],
},
};export const options = {
stages: [
{ duration: '1m', target: 10 },
{ duration: '30s', target: 200 }, // Spike
{ duration: '1m', target: 200 },
{ duration: '30s', target: 10 },
{ duration: '2m', target: 0 },
],
};Define reusable profiles in src/profiles/profiles.yaml:
profiles:
smoke:
vus: 1
duration: "1m"
thresholds:
http_req_duration: "p(95)<2000"
load:
stages:
- duration: "2m"
target: 50
- duration: "5m"
target: 50
- duration: "2m"
target: 0
thresholds:
http_req_duration: "p(95)<500"
stress:
stages:
- duration: "2m"
target: 100
- duration: "5m"
target: 200
- duration: "5m"
target: 0
thresholds:
http_req_failed: "rate<0.10"Load profile in test:
import { loadProfile } from '@utils/config.loader';
const profile = loadProfile(__ENV.PROFILE || 'smoke');
export const options = {
...profile,
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)'],
};Run with profile:
./run.sh <test-name> -e TOKEN=xxx -e PROFILE=loadAfter each test run:
- HTML Report:
results/<test-name>-report.html - JSON Summary:
results/<test-name>-summary.json - Logs:
logs/<test-name>-<timestamp>.log - Slack Notification: If
SLACK_WEBHOOK_URLis set
| Variable | Description | Default |
|---|---|---|
TOKEN |
API authentication token | Required |
BASE_URL |
API base URL | Test environment |
VUS |
Virtual users count | 1 |
ITERATIONS |
Number of iterations | 1 |
DURATION |
Test duration (e.g., 5m) | - |
PROFILE |
Profile name from profiles.yaml | smoke |
ENVIRONMENT |
Environment name for reports | test |
SLACK_WEBHOOK_URL |
Slack webhook for notifications | - |