Tutorials 18 October 2021

Supercharge your test script with a DSL

Inanc Gumus

    When someone new joins the company, they're encouraged to do what is called a "Week of Testing" - taking k6 for a spin and presenting your findings to the rest of the team. This article will show you how you can make your test scripts more readable and maintainable. To do that, I'll test a demo application with a custom DSL. DSL is short for a Domain Specific Language, and in this case, it allowed me to write meaningful code specific to our business requirements.

    I used demo app and checked that it continued to work as expected under load. During my experiment I noticed I was duplicating code in my scripts, for instance:

    • getting from, or posting to a URL
    • checking for a specific text in the body
    • extracting a CSRF token

    So I created a tiny DSL (Domain Specific Language) on top of k6's http module and figured more people might find it useful. The DSL allowed me to write the following test concisely:

    export default () => {
    // login screen
    let res = get({});
    rsleep();
    // do login
    res = post({});
    rsleep();
    // do logout
    res = post({});
    rsleep();
    // login with invalid credentials
    res = post({});
    rsleep();
    };

    Let's take a look at how these little functions work under the hood.

    // request is a generic function for checking an http response
    const request = (o) => {
    const res = http[o.method](o.url, o.params);
    if (o.connect != null) {
    o.connect(res);
    }
    o.res = res;
    if (o.check != null && !o.check(o) && o.fail != null) {
    o.fail(res);
    } else if (o.ok != null) {
    o.ok(res);
    }
    return res;
    };
    const get = (o) => {
    o.method = 'get';
    return request(o);
    };
    const post = (o) => {
    o.method = 'post';
    return request(o);
    };

    With the request function, you can:

    • use a specific http method like GET or POST
    • and check the response using a checker function

    You can also execute handlers on specific events like on-connect, on-fail, or on-ok.

    There are other helpers as well, like:

    • bodyShouldContain, which is a checker funtion that looks for a specific text in the response body
    • extractCSRFToken, which extracts the CSRF token from a form
    • rsleep for sleeping a random amount of time
    // bodyShouldContain is a helper that checks the response body text
    const bodyShouldContain = (o) => (args) => {
    const params = {};
    // saves the results with a tag
    params[o.tag] = (r) => r.body && r.body.indexOf(o.text) !== -1;
    return check(args.res, params);
    };
    const extractCSRFToken = (response) => {
    if (!response || typeof response.html !== 'function') {
    return '';
    }
    let el = response.html().find('input[name=csrftoken]');
    if (!el) {
    return '';
    }
    el = el.first();
    if (!el) {
    return '';
    }
    return el.attr('value');
    };
    const rsleep = () => sleep(Math.floor(1 + Math.random() * 5));

    The first test checks whether the "my messages" screen is protected. The script calls the fail handler if the body doesn't contain "Unauthorized". Otherwise, the script calls the ok handler. The script also calls the connect handler once k6 connects to the website.

    // login screen
    let res = get({
    url: cfg.pages.messages,
    params: [{tags: [{name: 'login-screen'}]}],
    check: bodyShouldContain({
    text: "Unauthorized",
    tag: "is unauthorized header present?",
    }),
    fail: () => metrics.failures.add(1)
    ok: () => metrics.successfulLogins.add(1)
    connect: (res) => metrics.timeToFirstByte.add(res.timings.waiting, { ttfbURL: res.url }),
    });
    rsleep();

    The second test checking whether a user can log in with valid credentials. Here, it needs to extract the CSRF token from the previous response object for the current session. Then, it checks whether the body contains the text: "Welcome, admin!" Finally, the script increments the fail/success metrics depending on the bodyShouldContain check.

    // do login
    res = post({
    url: cfg.pages.login,
    params: {
    tags: [{ name: 'login' }],
    login: cfg.validUser.login,
    password: cfg.validUser.password,
    csrftoken: extractCSRFToken(res),
    redir: '1',
    },
    check: bodyShouldContain({
    text: "Welcome, admin!",
    tag: "is login ok?",
    }),
    fail: () => metrics.failures.add(1)
    ok: () => metrcis.validLogins.add(1),
    });
    rsleep();

    The third test is for checking whether a user can log out. To do that, the script needs to use the CSRF token as well as the cookies for the current session. After logging out, it checks whether the body contains the text: "Unauthorized". Another test could be about whether we see the login screen after logging out.

    // do logout
    res = post({
    url: cfg.pages.login,
    params: {
    tags: [{ name: 'logout' }],
    csrftoken: extractCSRFToken(res),
    redir: '1',
    uid: res.cookies['uid'],
    sid: res.cookies['sid'],
    },
    check: bodyShouldContain({
    text: 'Unauthorized',
    tag: 'is logout OK?',
    }),
    fail: () => metrcis.failures.add(1),
    ok: () => metrics.successfulLogouts.add(1),
    });
    rsleep();

    The last test is for whether we cannot log in with invalid credentials.

    // login with invalid creds
    res = post({
    url: cfg.pages.login,
    params: {
    tags: [{ name: 'login-invalid' }],
    login: 'lemon',
    password: 'banana',
    csrftoken: extractCSRFToken(res),
    redir: '1',
    },
    check: bodyShouldContain({
    text: 'Unauthorized',
    tag: 'is login not OK',
    }),
    fail: () => metrics.failures.add(1),
    ok: () => metrics.invalidLogins.add(1),
    });
    rsleep();

    The script uses the ramping VUs to simulate a relatively realistic load, and it has three stages. These are the testing goals and, at the same time, failure conditions. It also uses abortFail to abort the test if the failure rate exceeds 10%. check_failure_rate is a custom metric that I showed you before.

    export const options = {
    stages: [
    // Ramp up to 25 VUs in 10s
    { target: 25, duration: '10s' },
    // Ramp up to 50 VUs in 10s
    { target: 50, duration: '10s' },
    // Go down to 0 VUs in 10s
    { target: 0, duration: '10s' },
    ],
    thresholds: {
    // Fail the test if 95th percentile of responses
    // go above 500ms
    http_req_duration: ['p(95)<500'],
    // Fail the test if the failure metric goes
    // above 10%
    check_failure_rate: [
    {
    threshold: 'rate<0.1',
    abortFail: true,
    },
    ],
    },
    };

    In case you're wondering, there were 82 successful VU iterations in total for my test app. All checks were successful. There were 574 requests in total because of the redirects. ​ You can improve upon this tiny DSL and create your DSLs for your specific business requirements, and that way create maintainable tests.

    Happy testing!

    < Back to all posts