Grafana K6 as a powerful tool for testing with Standalone, Scalable and Flexible architecture

Grafana K6 as a powerful tool for testing with Standalone, Scalable and Flexible architecture

Introduction

In the realm of software development, ensuring that an application performs well under various conditions is paramount. Load testing is one of the critical processes employed to achieve this goal. It helps identify how an application behaves when subjected to expected and peak user loads, uncovering potential performance bottlenecks before the software goes live.

What is Load Testing?

Load testing is a type of performance testing that evaluates a system's behavior under a specific load. This involves simulating multiple users accessing the system simultaneously to understand how the application handles high traffic volumes. The primary goal is to ensure that the system can operate efficiently and effectively under normal and peak conditions without compromising functionality or user experience.

Typically, load testing examines aspects such as response times, throughput rates, and resource utilization (e.g., CPU, memory, and network bandwidth). By doing so, it helps developers and engineers ascertain whether the current infrastructure can handle the anticipated load or if it requires optimization.

The primary purposes of load testing include:

Validating System Capacity: Load testing helps determine if a system can handle the expected number of concurrent users or transactions.

Identifying Performance Bottlenecks: It reveals performance issues that could impede the system’s efficiency. These bottlenecks could be related to server performance, database queries, application code, or network issues.

Ensuring Reliability and Stability: By subjecting the application to load tests, developers can ensure that the system remains stable and reliable even during peak usage times. This is critical for maintaining a positive user experience and avoiding downtime.

Assessing Scalability: Load testing helps in understanding how well the system scales with increasing user loads. This includes identifying the maximum capacity and planning for future growth.

Cost Efficiency: Identifying and addressing performance issues early in the development process can save costs associated with post-release fixes, which are typically more expensive and time-consuming.

To conduct effective load testing, several key aspects need to be considered:

Defining Test Objectives: Clear objectives must be set to guide the load testing process. These objectives should align with business goals and user expectations. Common objectives include response time thresholds, maximum user load capacity, and acceptable error rates.

Creating Realistic Test Scenarios: Load tests should mimic real-world usage as closely as possible. This involves simulating a variety of user behaviors, such as different types of transactions, varying data inputs, and fluctuating access patterns.

Monitoring System Performance: During the test, it’s crucial to monitor various performance metrics. These metrics include response times, error rates, server CPU and memory usage, and network latency. Monitoring tools can provide real-time data and help pinpoint the cause of any issues that arise.

Analyzing Test Results: After conducting the load test, the results need to be thoroughly analyzed. This analysis should focus on identifying performance bottlenecks, understanding the system’s behavior under load, and making informed decisions about necessary optimizations.

Iterative Testing: Load testing is not a one-time activity. It should be performed iteratively, especially after making changes to the system. Continuous testing helps ensure that performance improvements are effective and that no new issues have been introduced.

Load testing is an essential practice in the software development lifecycle, ensuring that applications can handle expected and peak loads effectively. By simulating real-world scenarios, load testing helps identify performance bottlenecks, validate system capacity, and ensure reliability and scalability. The benefits of load testing extend beyond technical performance, contributing to enhanced user satisfaction, business credibility, and cost efficiency. As software applications continue to grow in complexity and usage, load testing remains a crucial tool for delivering high-quality, reliable software products.

What is K6?

K6 is an open-source load testing tool designed to help developers and QA engineers test the performance of their applications. Created by Grafana Labs, K6 focuses on making load testing accessible, efficient, and developer-friendly. It allows for scripting tests in JavaScript, making it intuitive for those familiar with web development technologies.

K6 is primarily used to test the performance of APIs, web applications, and microservices. Its design emphasizes ease of use, automation, and integration with continuous integration (CI) and continuous delivery (CD) pipelines, which makes it a popular choice among modern development teams. Here are the key advantages of using K6:

Ease of Integration: K6 integrates seamlessly with CI/CD pipelines, making it an essential tool for continuous performance testing. It supports integrations with popular CI/CD tools like Jenkins, GitLab CI, and GitHub Actions. This capability ensures that performance tests can be automated and executed alongside functional tests, catching performance regressions early in the development cycle.

High Performance and Low Resource Usage: K6 is built to handle high-load scenarios while maintaining low resource consumption. Written in Go, K6 is highly efficient and capable of generating a significant amount of load from a single machine. This efficiency is crucial for testing large-scale applications without requiring extensive infrastructure.

Extensive Protocol Support: K6 supports multiple protocols, including HTTP/HTTPS, WebSocket, and gRPC. This versatility allows it to be used for testing various types of applications and services, from traditional web applications to modern, real-time applications.

Comprehensive Metrics and Reporting: K6 provides detailed performance metrics and robust reporting capabilities. Users can collect and analyze data on response times, request rates, error rates, and more. The tool offers both real-time and post-test analysis, helping teams identify bottlenecks and performance issues quickly.

K6 stands out as a versatile and powerful load testing tool that addresses the needs of modern development and operations teams. Its developer-friendly design, robust scripting capabilities, seamless CI/CD integration, and scalable architecture make it an excellent choice for performance testing. Whether used for testing web applications, APIs, or real-time services, K6 provides the tools and flexibility needed to ensure optimal performance and reliability in production environments.

Key Components of a K6 Script

K6 scripts are at the core of using K6 for performance testing, and understanding their key components is essential for creating effective load tests. The list of key components of the K6 script is as follows: Imports and Setup, Options, Setup Function, Default Function, Teardown Function.

Imports and Setup. At the beginning of a K6 script, the necessary modules are usually imported and configuration parameters are defined. K6 provides a lot of built-in modules that can be used to extend the functionality of the tests.

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

In this example, http is used for making HTTP requests, sleep is used for introducing delays, and check is used for validating responses.

Options. The options object defines the configuration for the test, such as the number of virtual users, test duration, and ramp-up stages.

export let options = {
    stages: [
        { duration: "30s", target: 10 }, // Ramp-up to 10 users over 30 seconds
        { duration: "1m", target: 10 }, // Stay at 10 users for 1 minute
        { duration: "10s", target: 0 },  // Ramp-down to 0 users over 10 seconds
    ],
    thresholds: {
        http_req_duration: ["p(95)<500"], // 95% of requests must complete below 500ms
    },
};
        

  • stages: Defines the load pattern for the test. In this example, it ramps up to 10 users, stays at 10 users, and then ramps down.
  • thresholds: Sets performance criteria that the test must meet. Here, it specifies that 95% of HTTP requests should complete in under 500 milliseconds.

Setup Function. The setup() function is optional and runs once before the test starts. It’s used to perform any necessary initialization, such as preparing test data or establishing initial conditions.

export function setup() {
    // Code to set up the test environment. For example, user authorization
    return { token: "exampleToken" };
}        

This function can return data that will be available to the default function.

Default Function. The default function contains the main test scenario that will be executed by each virtual user. It runs repeatedly for the duration of the test.

export default function (data) {
    let res = http.get("https://api.example.com/data", {
        headers: { Authorization: `Bearer ${data.token}` },
    });

    check(res, {
        "response status is 200": (r) => r.status === 200,
        "response time is less than 500ms": (r) => r.timings.duration < 500,
    });

    sleep(1); // Simulate user think time
}
        

  • http.get: Sends an HTTP GET request to the specified URL.
  • check: Validates the response. In this example, it checks that the status code is 200 and the response time is less than 500 milliseconds.
  • sleep: Introduces a delay to simulate real user behavior, such as thinking time between actions.

Teardown Function. The teardown() function is another optional component that runs once after the test completes. It’s used for any necessary cleanup operations.

export function teardown(data) {
    // Code to clean up after the test
}        

K6 scripts are powerful and flexible, allowing us to define complex performance testing scenarios with ease.

Advanced Scripting in K6

And now let's move on to the topic, which is primarily related to the complex structure of building scenarios and settings in the K6 environment. First of all, need to remember that K6 allows to flexibly manipulate the execution of both one main script in the form of a default function and several separate scripts at the same time, which act as different separate functions. The following code provides a general example of using a multi-scenario system.

function scenario_1() {
    // Testing code for first scenario
}

function scenario_2() {
    //Testing code for second scenario
}

function scenario_3() {
    // Testing code for third scenario
}

export const options = {
    scenarios: {
        scenario_1: {
            exec: "scenario_1",
            executor: "shared-iterations",
            maxDuration: "10m",
            vus: 2,
            iterations: 10,
        },
        scenario_2: {
            exec: "scenario_2",
            executor: "shared-iterations",
            maxDuration: "10m",
            vus: 2,
            iterations: 10,
        },
        scenario_2: {
            exec: "scenario_2",
            executor: "shared-iterations",
            maxDuration: "10m",
            vus: 2,
            iterations: 10,
        },
    },
    thresholds: {
        http_req_failed: ["rate==0.1"],
    },
};        

What do we get from this type of scripting? Usually parallel testing of various required cases, which makes the test much more flexible and deeper.

Further, as we remember, each scenario of our test receives as props data from the setup function, which, for example, allows pre-authorization of the user. It would be nice to define a specific case of autozation in a separate function, as shown in the example below.

function userAuth() {
    group("User Auth", function () {
        const payload = JSON.stringify({ username: "testuser", password: "testpassword" });
        const loginRes = http.post("https://api.example.com/auth/login", payload);

        const loginSuccess = check(loginRes, {
            "Login successful": (r) => r.status === 200,
            "Token is received": (r) => r.json("token") !== "",
        });

        sleep(1);

        return { token: loginRes.json("token") };
    });
}        

We now have an authorization script (or scenario) that performs additional checks and returns a token. Accordingly, it is worth testing some protected requests. An example will be demonstrated based on an imaginary online store.

function addProductToCart(props) {
    group("Add Product to Cart", function () {
        const payload = JSON.stringify({ product_id: "12345", quantity: 1 });
        const params = { headers: { "Content-Type": "application/json", Authorization: `Bearer ${props.token}` } };
        const addProductRes = http.post("https://api.example.com/cart", payload, params);

        check(addProductRes, {
            "Add product to cart successful": (r) => r.status === 201,
            "Product added to cart": (r) => r.json("product_id") === "12345",
        });

        sleep(1);

        const cartParams = { headers: { Authorization: `Bearer ${props.token}` } };
        const cartRes = http.get("https://api.example.com/cart");

        check(cartRes, {
            "Get cart products successful": (r) => r.status === 200,
            "Cart contains products": (r) => Array.isArray(r.json()) && r.json().length >= 0,
        });
    });
}        

In addition to the existing test for adding a product to the cart, separate tests for changing the product quantity and for removing the product from the cart should also be added.

function changeProductQuantity(props) {
    group("Change Product Quantity", function () {
        const cartParams = { headers: { Authorization: `Bearer ${props.token}` } };
        const cartRes = http.get("https://api.example.com/cart");

        check(cartRes, {
            "Get cart products successful": (r) => r.status === 200,
            "Cart contains products": (r) => Array.isArray(r.json()) && r.json().length >= 0,
        });

        sleep(1);

        const randomProduct = cartRes.json()[Math.floor(Math.random() * cartRes.json().length)];
        const changeProductPayload = JSON.stringify({ product_id: randomProduct.product_id, quantity: 2 });
        const changeProductParams = { headers: { "Content-Type": "application/json", Authorization: `Bearer ${props.token}` } };
        const changeProductRes = http.put("https://api.example.com/cart/12345", changeProductPayload, changeProductParams);

        check(changeProductRes, {
            "Change product quantity successful": (r) => r.status === 200,
            "Product quantity changed": (r) => r.json("quantity") === 2,
        });
    });
}

function removeProductFromCart(props) {
    group("Remove Product from Cart", function () {
        const cartParams = { headers: { Authorization: `Bearer ${props.token}` } };
        const cartRes = http.get("https://api.example.com/cart");

        check(cartRes, {
            "Get cart products successful": (r) => r.status === 200,
            "Cart contains products": (r) => Array.isArray(r.json()) && r.json().length >= 0,
        });

        sleep(1);

        const randomProduct = cartRes.json()[Math.floor(Math.random() * cartRes.json().length)];
        const removeProductRes = http.del(`https://api.example.com/cart/${randomProduct.product_id}`, null, {
            headers: { Authorization: `Bearer ${props.token}` },
        });

        check(removeProductRes, {
            "Remove product from cart successful": (r) => r.status === 200,
            "Product removed from cart": (r) => !r.json().some((product) => product.product_id === randomProduct.product_id),
        });
    });
}        

And as a result, we get a series of tests with which we can test the functionality of the shopping cart of our imaginary online store.

But now the question may arise, how should we perform a certain test sequence, because no matter how we write our individual scenarios in the configuration, they will all be executed at the same time, because of which the user flow simulation will be broken? And already on the basis of the written code, the answer emerges, which tells us that nothing prevents these scenarios from being executed within one common function, such as the default function. But using the default function in this case is not very convenient, as it executes all the sequences within itself, due to which the possibility of convenient implementation of various scenarios sequences disappears. Therefore, it is worth creating separate general scenarios that will contain a specific sequence of our sub-scenarios.

function UserCartFlow1(props) {
    group("User Cart Flow: Scenario 1", function () {
        addProductToCart(props);
        changeProductQuantity(props);
        removeProductFromCart(props);
    });
}

function UserCartFlow2() {
    group("User Cart Flow: Scenario 2", function () {
        const authData = userAuth();
        addProductToCart(authData);
        addProductToCart(authData);
        removeProductFromCart(authData);
    });
}

function UserCartFlow3() {
    group("User Cart Flow: Scenario 3", function () {
        const authData = userAuth();
        addProductToCart(authData);
        changeProductQuantity(authData);
        addProductToCart(authData);
        removeProductFromCart(authData);
    });
}        

As we can see from the code example, now we have the ability to create an infinite number of unique variations of test execution sequences. And that's not all, since we have a universal authorization function, we can perform it both in the setup part of the script and receive authorization data from the props in each scenario, and we can perform it separately within one scenario and transfer the execution result to other scenarios. This creates more flexibility when writing tests, allowing you to cover even more test cases. Thanks to this approach, a large variety of scenarios can be generated with a minimum amount of code.

Conclusion

Grafana K6 is a powerful tool for load testing, distinguished by its standalone, scalable, and flexible architecture. As software applications become increasingly complex and user demands grow, the need for robust load testing solutions has never been greater. K6 addresses this need with a comprehensive suite of features tailored for modern development and operations teams.

K6's standalone nature, built on Go, ensures high performance with low resource consumption. This allows for substantial load generation from a single machine, making it suitable for testing large-scale applications without requiring extensive infrastructure.

K6's ability to scale, both in terms of the number of virtual users and the complexity of test scenarios, is one of its standout features. Whether testing APIs, web applications, or microservices, K6 can handle diverse protocols and real-time applications, ensuring thorough performance evaluations.

The flexibility of K6 scripting, using JavaScript, makes it accessible to developers and QA engineers. Its integration with CI/CD pipelines ensures continuous performance testing, catching regressions early and maintaining high software quality throughout the development lifecycle. Advanced scripting capabilities, including multi-scenario execution and detailed performance metrics, enable precise and varied test scenarios, mimicking real-world usage patterns effectively.

In summary, K6 is not just a load testing tool but a critical asset for ensuring application reliability, scalability, and performance. By incorporating K6 into the development process, teams can proactively identify and address performance issues, optimize system capacity, and enhance user satisfaction. Its robust features and ease of use make it an indispensable tool for modern software development, helping deliver high-quality, reliable software products to market efficiently.


This article was created by the SP-Lutsk LLC team. We are dedicated to producing quality content and hope you found this article informative and engaging. Stay tuned for more updates!

?SP-Lutsk — 2024

Oleksandr Portianko

Frontend Web Developer at SP-Lutsk

9 个月

Really impressed by Grafana K6's flexibility! Thanks for sharing such a useful post.

Viktoriia Mosiichuk

DevSecOps Engineer

9 个月

Useful tips! Thanks!

Pavlo Andrushchuk

Backend developer at SP-Lutsk

9 个月

Useful info, thanks for your work on article!

Oleksandr Klekha

Business Owner and CEO at SP-LUTSK

9 个月

Thanks for sharing

Oleksandr Shypulin

DevSecOps - SP-Lutsk / Individual Entrepreneur / Cybersecurity Enthusiast

9 个月

Thanks for sharing

要查看或添加评论,请登录

SP-Lutsk的更多文章

社区洞察

其他会员也浏览了