Exploring K6 for performance testing

Shyam KundaShyam Kunda
6 min read

Note: Running blog, will complete this blog by Dec 1st

Background

I am working in a startup who’s techstack based on javascript (nodejs + angular +PGSQL+ mongo). As system is scaling up we need build performance suites. Based on my previous experience I built performance suite using jmeter for which later I regretted for below reasons.

  • Jmeter is not suited for API's which has complex and dynamic payloads. Generating dynamic and complex payloads using groovy is possible but tricky in some scenarios.

  • Bad groovy editor of Jmeter makes it difficult to find errors and write complex code and it is time consuming.

  • JMeter is resource-intensive. Need to go to c5a.4xlarge (16 cpu, 32 GB) to simulate 2K Users. Running from local even with 500 users is not possible (on Mac M2 with 16 Gb ram).

  • Maintenance of scripts is a problem, due to language and tool barrier.

Reasons I decided to try K6

  • Developer-Centric, Code-First Approach. k6 uses a code-first approach (JavaScript), making it familiar for developers and easy to version control as scripts. I don’t want to be solo person who need to maintain these performance tests. More colleberation will bring more coverage.

  • k6 is designed to be efficient in resource usage, capable of generating a high load from minimal resources, even from local machines.

  • Integration with grafana

Installation & Getting started

Mac

brew install k6

Basic script to hit url

import http from 'k6/http'
import { sleep } from 'k6'

export const options = {
    vus: 10,
    iterations: 40
}

export default function () {
    http.get('http://test.k6.io');
    sleep(1);
}
  • VUS : Running 10 virtual users.

  • iterations : Running the function for 40 times.

  • we use sleep in the script to control no of http_reqs per second.

How to run test?

k6 run script.js

Sample Result

  • iterations : Tells how many times function executed

  • if number of iterations & http_reqs have different count, then some redirections are happening

  • we use sleep in the script to control no of http_reqs per second.

  • http_req_duration is important parameter to analyse response times

Debugging http requests

k6 run --http-debug script.js //prints response info omitting body
k6 run --http-debug="full" script.js //print entire response

Passing environment variable

k6 run -e BASE_URL=https://www.google.com
//accessing
console.log(__ENV.BASE_URL)

Load Testing types

TypeVUs/ThroughputDurationWhen?
SmokeLowShort (seconds or minutes)When the relevant system or application code changes. It checks functional logic, baseline metrics, and deviations
Average-loadAverage productionMid (5-60 minutes)Often to check system maintains performance with average use
StressHigh (above average)Mid (5-60 minutes)When system may receive above-average loads to check how it manages
SoakAverageLong (hours)After changes to check system under prolonged continuous use
SpikeVery highShort (a few minutes)When the system prepares for seasonal events or receives frequent traffic peaks
BreakpointIncreases until breakAs long as necessaryA few times to find the upper limits of the system

How to simulate ramp-up/hold-time/ramp-down?

we can simulate ramp-up/hold-time/ramp-down in stages. Below options will ramp up 50 users in 5m, then 100 users in 30 min and ramp down all users to 0 in last 5 mins

import http from 'k6/http'
import { sleep } from 'k6'

export const options = {
    stages: [
        {duration : '5m', target: 50},
        {duration : '30', target: 100},
        {duration : '5m', target: 0}
    ]
}
export default function () {
    http.get('http://test.k6.io');
    sleep(1);
}

Handling Authentication for multiple scenarios

To structure k6 tests with different APIs in separate files while passing an authentication token, you can manage shared data (like tokens) centrally and reuse it across test scripts. Here's a practical example:

File Structure

Suppose you have the following APIs:

  1. User API

  2. Product API

  3. Order API

The file structure looks like this:

k6-tests/
│
├── main.js         # Main entry point
├── auth.js         # Authentication logic
├── userApi.js      # User API test logic
├── productApi.js   # Product API test logic
└── orderApi.js     # Order API test logic

Authentication Logic (auth.js)

This file handles the generation or retrieval of the authentication token.

import http from 'k6/http';

export function getAuthToken() {
    const res = http.post('https://api.example.com/auth/login', JSON.stringify({
        username: 'testuser',
        password: 'password123',
    }), {
        headers: { 'Content-Type': 'application/json' },
    });

    if (res.status !== 200) {
        throw new Error(`Failed to authenticate: ${res.body}`);
    }

    const token = JSON.parse(res.body).token;
    return token; // Return the token
}

User API Script (userApi.js)

This script imports the getAuthToken function and uses the token in requests.

import http from 'k6/http';
import { check } from 'k6';

export function userApiTest(token) {
    const res = http.get('https://api.example.com/users', {
        headers: { Authorization: `Bearer ${token}` },
    });

    check(res, {
        'status is 200': (r) => r.status === 200,
        'response contains users': (r) => r.body.includes('users'),
    });
}

Product API Script (productApi.js)

Similar structure, reusing the token.

import http from 'k6/http';
import { check } from 'k6';

export function productApiTest(token) {
    const res = http.get('https://api.example.com/products', {
        headers: { Authorization: `Bearer ${token}` },
    });

    check(res, {
        'status is 200': (r) => r.status === 200,
        'response contains products': (r) => r.body.includes('product'),
    });
}

Order API Script (orderApi.js)

javascriptCopy codeimport http from 'k6/http';
import { check } from 'k6';

export function orderApiTest(token) {
    const res = http.post('https://api.example.com/orders', JSON.stringify({
        productId: 1,
        quantity: 2,
    }), {
        headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${token}`,
        },
    });

    check(res, {
        'status is 201': (r) => r.status === 201,
        'order created successfully': (r) => r.body.includes('orderId'),
    });
}

Main Entry File (main.js)

This file fetches the token once and passes it to all test scripts.

import { getAuthToken } from './auth.js';
import { userApiTest } from './userApi.js';
import { productApiTest } from './productApi.js';
import { orderApiTest } from './orderApi.js';

const authToken = getAuthToken(); // Get the token once

export const options = {
    scenarios: {
        user_api: {
            executor: 'constant-vus',
            vus: 5,
            duration: '30s',
            exec: 'userApiScenario',
        },
        product_api: {
            executor: 'constant-vus',
            vus: 5,
            duration: '30s',
            exec: 'productApiScenario',
        },
        order_api: {
            executor: 'constant-vus',
            vus: 5,
            duration: '30s',
            exec: 'orderApiScenario',
        },
    },
};

export function userApiScenario() {
    userApiTest(authToken);
}

export function productApiScenario() {
    productApiTest(authToken);
}

export function orderApiScenario() {
    orderApiTest(authToken);
}

Sample Service Level Objectives(SLO)

Availability :

The application will be available 99.8% of the time.

Response Time:

  • 90% of the responses are within 0.5s of recieving requests.

  • 95% of the responses are within 0.9s of recieving requests.

  • 99% of the responses are within 2.5s of recieving requests.

0
Subscribe to my newsletter

Read articles from Shyam Kunda directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shyam Kunda
Shyam Kunda