Building a k6 performance test solution

Grafana k6 is an open-source load testing tool that makes performance testing easy and productive for engineering teams. k6 has packages for Linux, Mac, and Windows, but you can also use a Docker container or a standalone binary. Steps on how to install k6 are very well described on their website (along with other great documentation). [and I will not duplicate the steps here.]

In this article, we will focus on writing a performance script for the Petstore API (Application Programming Interface), starting from one simple JavaScript file and improving it further until a more reliable and maintainable performance test solution is achieved.


Author: Andrei Zarjitchi

The performance script that we want to create is described by the following flow:

  1. create a new user (POST /user)
  2. get the user details using the provided username (GET /user/{username})
  3. delete the added user (DELETE /user/{username})

Our first performance test script that reproduces this flow would look like this:

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

const BASE_URL = "https://petstore.swagger.io/v2";
const SLEEP_DURATION = 1;
const randomNumber = (maxNumber) => Math.floor(Math.random() * maxNumber);
const defaultHeaders = {
    headers: {
    "access-control-allow-headers": "Content-Type,api_key,Authorization",
    "access-control-allow-methods": "GET,POST,DELETE,PUT",
    "access-control-allow-origin": "*" ,
    "content-type": "application/json ",
    }};

export default function () {

    var num = randomNumber(50).toString();
    var myUsername = "username" + num;
        
    //POST
    var postUrl = BASE_URL + `/user`;

    var payload = JSON.stringify({
        username: myUsername,
        firstName: "firstName-"+ num,
        lastName : "lastName-"+ num,
        email : "aa@bb.cc",
        password : "myPassword",
        })

    var postResponse = http.post(
            postUrl, 
            payload, 
            defaultHeaders
        );
        
        check(postResponse, {
            "POST username responds OK": (r) => r.status === 200
        });
        sleep(SLEEP_DURATION);

        //GET 
        var getUrl = BASE_URL + `/user/`+ myUsername;

        var getResponse = http.get(
            getUrl, 
            defaultHeaders
        );                  
        
        check(getResponse, {
            "GET username responds OK": (r) => r.status === 200
        });
        sleep(SLEEP_DURATION);
    
        //DELETE
        var deleteUrl = BASE_URL + `/user/`+ myUsername;

        var deleteResponse = http.del(
            deleteUrl, 
            defaultHeaders
        );
        
        check(deleteResponse, {
            "DELETE username responds OK": (r) => r.status === 200
        });
        sleep(SLEEP_DURATION);
}

As you can see above, we have a default function that k6 uses to define the entry point for our virtual users. Every k6 performance script needs to have a default function.

The default function contains all the 3 requests defined in our flow.

To run this script, we use the following command:

k6 run --vus 1 --duration 2s .\ k6-petstore-v1.js

We are not interested in verifying the performance of the Pet Store app in this post, so for now, we will run our script with only one VU. When we run this performance script with k6, we will see the output from the image below.

At this point, our performance script is not easy to read or maintain.

Furthermore, the results of this script are not particularly useful as we cannot see the http_req_duration for each type of request (POST /user, GET /user/{username} or DELETE / user/{username}). The only http_req_duration that is shown in the results overview is calculated, in this case, for all the 3 requests.

Adding tags and thresholds

To see the results for each request, we will further improve our script by adding a tag to the requests and by defining the thresholds for each tag, too.

import http from "k6/http";
import { group, check, sleep } from "k6";

const BASE_URL = "https://petstore.swagger.io/v2";
const SLEEP_DURATION = 1;
const randomNumber = (maxNumber) => Math.floor(Math.random() * maxNumber);
const defaultHeaders = {
    "access-control-allow-headers": "Content-Type,api_key,Authorization",
    "access-control-allow-methods": "GET,POST,DELETE,PUT",
    "access-control-allow-origin": "*" ,
    "content-type": "application/json ",
    };

export const options = {
thresholds: {
    'http_req_duration{tag:DeleteUser}': [{ threshold: 'p(90)<2000', 
abortOn-Fail: false }],
    'http_req_duration{tag:CreateUser}': [{ threshold: 'p(90)<100', 
abortOn-Fail: false }],
    'http_req_duration{tag:GetUser}': [{ threshold: 'p(90)<2000', 
abortOnFail: false }],
   },
};

export default function() {
    var num = randomNumber(50).toString();
    var myUsername = "username"+ num;
    
    //POST  
    var postUrl = BASE_URL + `/user`;       
    var payload = JSON.stringify({
            username: myUsername,
            firstName: "firstName-"+ num,
            lastName : "lastName-"+ num,
            email : "aa@bb.cc",
            password : "myPassword",
            });
    var postResponse = http.post(
        postUrl, 
        payload, 
        {
            headers: defaultHeaders,
            tags: { tag : 'CreateUser' }
        }
    );
        
    check(postResponse, {
        "POST username responds OK": (r) => r.status === 200
    });
    sleep(SLEEP_DURATION);

    //GET
    var getUrl = BASE_URL + `/user/`+ myUsername;
    var getResponse = http.get(
        getUrl, 
        {
            headers: defaultHeaders, 
            tags: { tag : 'GetUser' }
        }
    );
        
    check(getResponse, {
        "GET username responds OK": (r) => r.status === 200
    });
    sleep(SLEEP_DURATION);

    //DELETE
    var deleteUrl = BASE_URL + `/user/`+ myUsername;
    var deleteResponse = http.del(
        deleteUrl, 
        null,
        {
            headers: defaultHeaders, 
            tags: { tag : 'DeleteUser' }
        }
    );
        
    check(deleteResponse, {
        "DELETE username responds OK": (r) => r.status === 200
    });
    sleep(SLEEP_DURATION);
}

In the following screenshot, we have the results after adding tags to each request. By adding thresholds for each tag, we can now see the http_req_duration for each request. Another benefit of using thresholds is that k6 will let you know when a certain threshold has failed. In the image below, we can see that CreateUser request failed because the desired threshold for 90th percentile (set to 100ms in our script) was reached.

Model each request as a class

At this point our script is crowded and difficult to read and maintain as it contains all the requests and their checks in the same JavaScript file. Also, we cannot reuse any parts of the performance script.

The next change we will make to our script is to move each request (POST, GET, DELETE) and its checks in a separate JavaScript file and model it as a class. In this way our performance script becomes more readable, and we can also reuse the same requests in different performance scripts.

Once we move each request to a separate file, our solution structure will look like this:

And our performance script is now smaller and easier to read, as we can see below.

import { sleep } from "k6";
import PostUser from "./post-user.js";
import GetUser from "./get-user.js";
import DeleteUser from "./delete-user.js";

const BASE_URL = "https://petstore.swagger.io/v2";
const randomNumber = (maxNumber) => Math.floor(Math.random() * maxNumber);
const defaultHeaders = {
    "access-control-allow-headers": "Content-Type,api_key,Authorization",
    "access-control-allow-methods": "GET,POST,DELETE,PUT",
    "access-control-allow-origin": "*" ,
    "content-type": "application/json ",
};

export const options = {
    thresholds: {
        'http_req_duration{tag: DeleteUser}': [{ threshold: 'p(90)<50', 
abor-tOnFail: false }],
        'http_req_duration{tag: CreateUser}': [{ threshold: 'p(90)<2000', 
abortOnFail: false }],
        'http_req_duration{tag: GetUser}':    [{ threshold: 'p(90)<2000', 
abortOnFail: false }],
       }
    };

export default function() {
    var myUsername = "username" + randomNumber(50).toString();

    new PostUser(BASE_URL, myUsername, defaultHeaders);
    sleep(1);

    new GetUser(BASE_URL, myUsername, defaultHeaders);
    sleep(1);

    new DeleteUser(BASE_URL, myUsername,defaultHeaders);
    sleep(1);
}

In the following code blocks we can see the code for each class that models our requests of POST, GET and DELETE.

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

export default class PostUser {
    constructor(baseUrl, myUsername, defaultHeaders)    {
        console.log("Adding username: "+ myUsername);
        var postUrl = baseUrl + `/user`;
    
        var payload = JSON.stringify({
                username: myUsername,
                firstName: "firstName-"+randomNumber(50).toString(),
                lastName : "lastName-"+randomNumber(50).toString(),
                email : "aa@bb.cc",
                password : "myPassword",
            }
        );

        var response = http.post(
            postUrl, 
            payload, 
            {
                headers: defaultHeaders,
                tags: { tag : 'CreateUser' }
            }
        );

        check(response, {
            "create user  - status is 200": (r) => r.status === 200, 
        });
    }
}
import http from "k6/http";
import { check } from "k6";

export default class GetUser {
    constructor(baseUrl, myUsername, defaultHeaders)    {

        var getUrl = baseUrl + `/user/`+ myUsername;
        console.log("get url: " + getUrl);
        var getResponse = http.get(
            getUrl, 
            {
                headers: defaultHeaders, 
                tags: { tag : 'GetUser' }
            }
        );
        
        check(getResponse, {
            "GET username responds OK": (r) => r.status === 200
        });
    }
}
import http from "k6/http";
import { check } from "k6";

export default class DeleteUser {

    constructor(baseUrl, myUsername, defaultHeaders)    {
        var deleteUrl = baseUrl + `/user/`+ myUsername;
        console.log("delete url: " + deleteUrl);
        var deleteResponse = http.del(
            deleteUrl, 
            null,
            {
                headers: defaultHeaders, 
                tags: { tag : 'DeleteUser' }
            }
        );
        
        check(deleteResponse, {
            "DELETE username responds OK": (r) => r.status === 200
        });
    }
}