Skip to content

moneyforward/performance-test-fw

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Performance Test Framework

K6-based performance testing framework with TypeScript support.

Prerequisites

  • Node.js >= 18
  • k6 >= 1.0.0

Setup

# 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 winget

Project Structure

src/
  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

Build

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:watch

Build output:

  • src/tests/<name>.test.ts compiles to dist/<name>.test.js
  • Group metrics are auto-detected and registered during build

Writing Step Files

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;
}

Writing Test Files

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,
  });
}

Running Tests

Using run.sh (Recommended)

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/xxx

Using pnpm scripts

pnpm build           # Build TypeScript to dist/
pnpm test            # Run default test
pnpm clean           # Clean dist, results, logs

Direct k6 command

# Build first (required before running k6)
pnpm build

# Run compiled test from dist/
k6 run dist/<test-name>.test.js -e TOKEN=xxx

Test Configuration Options

1. Simple Iteration Test

export const options = {
  vus: 1,
  iterations: 10,
  thresholds: {
    http_req_duration: ['p(95)<500'],
  },
};

2. Duration-based Test

export const options = {
  vus: 10,
  duration: '5m',
  thresholds: {
    http_req_duration: ['p(95)<500'],
  },
};

3. Ramping VUs (Stages)

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'],
  },
};

4. Stress Test

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'],
  },
};

5. Spike Test

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 },
  ],
};

Using Profile YAML

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=load

Output

After 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_URL is set

Environment Variables

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 -

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published