UNPKG

k6-cucumber-steps

Version:

Cucumber step definitions for running k6 performance tests.

687 lines (630 loc) 23 kB
// e2e/step_definitions/load_test_steps.js import { Given, When, Then } from "@cucumber/cucumber"; import fs from "fs"; import path from "path"; import crypto from "crypto"; import * as dotenv from "dotenv"; import resolvePayloadPath from "../lib/helpers/resolvePayloadPath.js"; import resolveBody from "../lib/helpers/resolveBody.js"; import buildK6Script from "../lib/helpers/buildK6Script.js"; import generateHeaders from "../lib/helpers/generateHeaders.js"; import { runK6Script } from "../lib/utils/k6Runner.js"; dotenv.config(); /** * @typedef {Object} CustomWorld * @property {Object} config * @property {Object} aliases * @property {Object} lastResponse * @property {Object} parameters * @property {Function} log */ /** * @typedef {Object} K6Config * @property {string} method - HTTP method for the request (e.g., "GET", "POST"). * @property {string} [endpoint] - The specific endpoint for a single request. * @property {string[]} [endpoints] - An array of endpoints for multiple requests. * @property {Object} [headers] - Request headers. * @property {string} [body] - Request body content. * @property {Object} options - k6 test options (vus, duration, stages, thresholds). * @property {Object} options.thresholds - k6 metric thresholds. * @property {string[]} options.thresholds.http_req_failed - Thresholds for failed HTTP requests. * @property {string[]} options.thresholds.http_req_duration - Thresholds for request duration. * @property {string[]} [options.thresholds.error_rate] - Optional threshold for error rate. * @property {number} [options.vus] - Number of virtual users (for open model). * @property {string} [options.duration] - Test duration (for open model, e.g., "30s"). * @property {Array<Object>} [options.stages] - Array of stages for a stepped load model. */ // =================================================================================== // K6 SCRIPT CONFIGURATION STEPS // =================================================================================== /** * Initializes the k6 script configuration by setting the primary HTTP method for the load test. * * ```gherkin * Given I set a k6 script for {word} testing * ``` * * @param {string} method - The HTTP method (e.g., "GET", "POST", "PUT", "DELETE"). * @example * Given I set a k6 script for GET testing * Given I set a k6 script for POST testing * @remarks * This step typically starts the definition of a k6 load test scenario. * It sets `this.config.method` in the Cucumber World context. * Subsequent steps will build upon this configuration. * @category k6 Configuration Steps */ export async function Given_I_set_k6_script_for_method_testing(method) { /** @type {CustomWorld} */ (this).config = { method: method.toUpperCase() }; /** @type {CustomWorld} */ (this).log?.( `⚙️ Initialized k6 script for ${method.toUpperCase()} testing.` ); } Given( /^I set a k6 script for (\w+) testing$/, Given_I_set_k6_script_for_method_testing ); /** * Configures the k6 script options (VUs, duration, stages, thresholds) from a data table. * * ```gherkin * When I set to run the k6 script with the following configurations: * | virtual_users | duration | stages | http_req_failed | http_req_duration | error_rate | * | 10 | 30 | | p(99)<0.01 | p(99)<500 | rate<0.01 | * | | | [{"duration":"10s","target":10}] | p(90)<0.01 | p(90)<200 | rate<0.001 | * ``` * * @param {DataTable} dataTable - A Cucumber data table containing k6 configuration parameters. * Expected columns: `virtual_users`, `duration`, `stages` (JSON string), `http_req_failed`, `http_req_duration`, `error_rate`. * @example * When I set to run the k6 script with the following configurations: * | virtual_users | duration | http_req_failed | http_req_duration | * | 50 | 60 | p(99)<0.01 | p(99)<1000 | * When I set to run the k6 script with the following configurations: * | stages | http_req_failed | http_req_duration | error_rate | * | [{"duration":"10s","target":10}, {"duration":"20s","target":50}] | p(99)<0.01 | p(99)<500 | rate<0.01 | * @remarks * This step populates `this.config.options`. It intelligently handles either a simple * `virtual_users`/`duration` model or a complex `stages` array. Threshold formats are validated. * Example values from scenario outlines are resolved if present. * @category k6 Configuration Steps */ export async function When_I_set_k6_script_configurations(dataTable) { /** @type {CustomWorld} */ (this); const rawRow = dataTable.hashes()[0]; const row = {}; const exampleMap = {}; if (this.pickle && this.pickle.astNodeIds && this.gherkinDocument) { const scenarioNodeId = this.pickle.astNodeIds.find((id) => id.startsWith("Scenario") ); if (scenarioNodeId) { const scenario = this.gherkinDocument.feature.children.find( (child) => child.scenario && child.scenario.id === scenarioNodeId )?.scenario; if (scenario && scenario.examples && scenario.examples.length > 0) { const exampleTable = scenario.examples[0].tableBody?.[0]; const headerCells = scenario.examples[0].tableHeader?.cells || []; const dataCells = exampleTable?.cells || []; headerCells.forEach((cell, idx) => { exampleMap[cell.value] = dataCells[idx]?.value; }); } } } for (const [key, value] of Object.entries(rawRow)) { row[key] = value.replace(/<([^>]+)>/g, (_, param) => { if (exampleMap.hasOwnProperty(param)) { return exampleMap[param]; } return `<${param}>`; }); } const validateThreshold = (value, thresholdName) => { const regex = /^[\w{}()<>:]+([<>=]=?)\d+(\.\d+)?$/; if (value && !regex.test(value)) { throw new Error( `Invalid k6 threshold format for '${thresholdName}': "${value}". Expected format like 'p(99)<500' or 'rate<0.01'.` ); } }; validateThreshold(row.http_req_failed, "http_req_failed"); validateThreshold(row.http_req_duration, "http_req_duration"); if (row.error_rate) { validateThreshold(row.error_rate, "error_rate"); } let k6Options; if (row.stages) { try { k6Options = { stages: JSON.parse(row.stages), thresholds: { http_req_failed: [row.http_req_failed], http_req_duration: [row.http_req_duration], }, }; } catch (e) { throw new Error(`Invalid 'stages' JSON format: ${e.message}`); } } else if (row.virtual_users && row.duration) { k6Options = { vus: parseInt(row.virtual_users), duration: `${row.duration}s`, thresholds: { http_req_failed: [row.http_req_failed], http_req_duration: [row.http_req_duration], }, }; } else { throw new Error( "k6 configuration requires either 'stages' or 'virtual_users' and 'duration' to be set." ); } if (row.error_rate) { k6Options.thresholds.error_rate = [row.error_rate]; } this.config.options = k6Options; this.log?.( `⚙️ k6 script configured with options: ${JSON.stringify(k6Options)}` ); } When( /^I set to run the k6 script with the following configurations:$/, When_I_set_k6_script_configurations ); /** * Sets request headers for the k6 script. Headers are merged with any existing headers. * * ```gherkin * When I set the request headers: * | Header | Value | * | Content-Type | application/json | * | Authorization | Bearer <my_token> | * ``` * * @param {DataTable} dataTable - A Cucumber data table with 'Header' and 'Value' columns. * * @example * When I set the request headers: * | Header | Value | * | Content-Type | application/json | * | X-Custom-Header| MyValue | * * @remarks * This step updates `this.config.headers`. Values can include placeholders * (e.g., `<my_token>`) if your `resolveBody` or `generateHeaders` helpers handle them. * Note: `generateHeaders` is specifically used for authentication types. If your headers * contain dynamic values beyond simple alias resolution, ensure your helpers support it. * @category k6 Configuration Steps */ export async function When_I_set_request_headers(dataTable) { /** @type {CustomWorld} */ (this); const headers = {}; dataTable.hashes().forEach(({ Header, Value }) => { headers[Header] = Value; }); this.config.headers = { ...(this.config.headers || {}), ...headers, }; this.log?.(`⚙️ Request headers set: ${JSON.stringify(this.config.headers)}`); } When(/^I set the request headers:$/, When_I_set_request_headers); /** * Sets the list of endpoints to be used in the k6 script. These are typically used when * the k6 script iterates over multiple URLs. * * ```gherkin * When I set the following endpoints used: * /api/v1/users * /api/v1/products * /api/v1/orders * ``` * * @param {string} docString - A DocString containing a newline-separated list of endpoints. * * @example * When I set the following endpoints used: * /health * /status * /metrics * * @remarks * This step populates `this.config.endpoints` as an array of strings. * Ensure these endpoints are relative to your k6 `BASE_URL`. * @category k6 Configuration Steps */ export async function When_I_set_endpoints_used(docString) { /** @type {CustomWorld} */ (this); this.config.endpoints = docString .trim() .split("\n") .map((line) => line.trim()); if (this.log) this.log(`⚙️ Endpoints set: ${JSON.stringify(this.config.endpoints)}`); } When(/^I set the following endpoints used:$/, When_I_set_endpoints_used); /** * Sets the request body for a specific HTTP method and endpoint. * * ```gherkin * When I set the following POST body is used for "/api/v1/create" * { "name": "test", "email": "test@example.com" } * ``` * * @param {string} method - The HTTP method (e.g., "POST", "PUT"). * @param {string} endpoint - The specific endpoint URL for this body. * @param {string} docString - A DocString containing the request body content (e.g., JSON). * * @example * When I set the following PUT body is used for "/api/v1/update/1" * { "status": "active" } * * @remarks * This step sets `this.config.method`, `this.config.endpoint`, and `this.config.body`. * The `resolveBody` helper is used to process the DocString, allowing for dynamic values * from environment variables. * @category k6 Configuration Steps */ export async function When_I_set_method_body_for_endpoint( method, endpoint, docString ) { /** @type {CustomWorld} */ (this); const methodUpper = method.toUpperCase(); const payloadDir = this.parameters?.payloadPath || "payloads"; const doc = docString.trim(); let body = ""; // Try resolving from file if it looks like a filename const isLikelyFile = /^[\w\-.]+(\.json)?$/.test(doc); const fileName = doc.endsWith(".json") ? doc : `${doc}.json`; try { if (isLikelyFile) { const filePath = resolvePayloadPath(fileName, payloadDir); const fileContent = fs.readFileSync(filePath, "utf-8"); body = resolveBody(fileContent, { ...process.env, ...(this.aliases || {}), }); this.log?.(`📁 Loaded payload from file: "${fileName}"`); } else { throw new Error("Skipping file load; using raw input as body."); } } catch (e) { // If file doesn't exist or error occurs, treat as inline string body = resolveBody(doc, { ...process.env, ...(this.aliases || {}), }); this.log?.("📝 Using docString directly as payload body."); } this.config = { ...(this.config || {}), method: methodUpper, endpoint, body, headers: this.config?.headers || {}, }; this.lastRequest = { method: methodUpper, endpoint, body, }; this.log?.( `⚙️ Body set for ${methodUpper} to "${endpoint}". Body preview: ${body.slice( 0, 100 )}...` ); } /** * Loads a JSON payload from a file to be used as the request body for a specific * method and endpoint in the k6 script. * * ```gherkin * When I use JSON payload from "user_create.json" for POST to "/api/v1/users" * ``` * * @param {string} fileName - The name of the JSON payload file (e.g., "user_data.json"). * @param {string} method - The HTTP method (only "POST", "PUT", "PATCH" are supported for bodies). * @param {string} endpoint - The specific endpoint URL. * * @example * When I use JSON payload from "login_payload.json" for POST to "/auth/login" * * @remarks * This step reads the JSON file, resolves any placeholders within it (using `resolveBody`), * and sets `this.config.method`, `this.config.endpoint`, and `this.config.body`. * It also stores `lastRequest` in `this.lastRequest`. * The payload file path is resolved relative to `payloads` directory or `this.parameters.payloadPath`. * @category k6 Configuration Steps */ export async function When_I_use_JSON_payload_from_file_for_method_to_endpoint( fileName, method, endpoint ) { /** @type {CustomWorld} */ (this); const allowedMethods = ["POST", "PUT", "PATCH"]; const methodUpper = method.toUpperCase(); if (!allowedMethods.includes(methodUpper)) { throw new Error( `Method "${method}" is not supported for JSON payloads from files. Use one of: ${allowedMethods.join( ", " )}` ); } const payloadDir = this.parameters?.payloadPath || "payloads"; const payloadPath = resolvePayloadPath(fileName, payloadDir); const rawTemplate = fs.readFileSync(payloadPath, "utf-8"); const resolved = resolveBody(rawTemplate, { ...process.env, ...(this.aliases || {}), }); this.config = { ...(this.config || {}), method: methodUpper, endpoint, body: resolved, headers: this.config?.headers || {}, }; this.lastRequest = { method: methodUpper, endpoint, body: resolved, }; this.log?.( `⚙️ JSON payload from "${fileName}" used for ${methodUpper} to "${endpoint}".` ); } When( /^I use JSON payload from "([^"]+)" for (\w+) to "([^"]+)"$/, When_I_use_JSON_payload_from_file_for_method_to_endpoint ); /** * Sets the authentication type for the k6 request, generating relevant headers. * * ```gherkin * When I set the authentication type to "BearerToken" * ``` * * @param {string} authType - The type of authentication (e.g., "BearerToken", "BasicAuth", "APIKey"). * * @example * When I set the authentication type to "BearerToken" * * @remarks * This step uses the `generateHeaders` helper to create or modify `this.config.headers` * based on the specified `authType` and environment variables/aliases. * Ensure your `generateHeaders` helper is configured to handle the `authType` and retrieve * necessary credentials (e.g., from `process.env` or `this.aliases`). * @category k6 Configuration Steps */ export async function When_I_set_authentication_type(authType) { /** @type {CustomWorld} */ (this); this.config.headers = generateHeaders( authType, process.env, this.aliases || {} ); this.log?.(`⚙️ Authentication type set to "${authType}". Headers updated.`); } When( /^I set the authentication type to "([^"]+)"$/, When_I_set_authentication_type ); /** * Stores a value from the last API response into the Cucumber World's aliases context. * * ```gherkin * Then I store the value at "data.token" as alias "authToken" * ``` * * @param {string} jsonPath - A dot-separated JSON path to the value in the last response (e.g., "data.user.id"). * @param {string} alias - The name of the alias to store the value under (e.g., "userId"). * * @example * Then I store the value at "token" as alias "accessToken" * Then I store the value at "user.profile.email" as alias "userEmail" * * @remarks * This step expects `this.lastResponse` to contain a parsed JSON object. * It traverses the `jsonPath` to extract the desired value and saves it into * `this.aliases`. This alias can then be used in subsequent steps or payload resolutions. * @category Data Management Steps */ export async function Then_I_store_value_as_alias(jsonPath, alias) { /** @type {CustomWorld} */ (this); if (!this.lastResponse) { throw new Error( "No previous API response available to extract value from. Ensure a login or request step was executed." ); } const pathParts = jsonPath.split("."); let value = this.lastResponse; for (const key of pathParts) { if (value && typeof value === "object" && key in value) { value = value[key]; } else { value = undefined; break; } } if (value === undefined) { throw new Error( `Could not resolve path "${jsonPath}" in the last response. Value is undefined.` ); } if (!this.aliases) this.aliases = {}; this.aliases[alias] = value; this.log?.( `🧩 Stored alias "${alias}" from response path "${jsonPath}". Value: ${JSON.stringify( value ).slice(0, 100)}...` ); } Then( /^I store the value at "([^"]+)" as alias "([^"]+)"$/, Then_I_store_value_as_alias ); /** * Logs in via a POST request to a specified endpoint using a JSON payload from a file. * The response data is stored for subsequent steps. * * ```gherkin * When I login via POST to "/auth/login" with payload from "admin_credentials.json" * ``` * * @param {string} endpoint - The API endpoint for the login request (relative to `BASE_URL`). * @param {string} fileName - The name of the JSON file containing login credentials. * * @example * When I login via POST to "/api/login" with payload from "user_creds.json" * * @remarks * This step constructs and executes a `fetch` POST request. It reads the payload from * the specified file (resolved from `payloads` directory), resolves placeholders in the payload, * sends the request, and stores the JSON response in `this.lastResponse`. * It throws an error if the login request fails (non-2xx status). * @category Authentication Steps */ export async function When_I_login_via_POST_with_payload_from_file( endpoint, fileName ) { /** @type {CustomWorld} */ (this); const payloadDir = this.parameters?.payloadPath || "payloads"; const payloadPath = resolvePayloadPath(fileName, payloadDir); const rawTemplate = fs.readFileSync(payloadPath, "utf-8"); const resolved = resolveBody(rawTemplate, { ...process.env, ...(this.aliases || {}), }); try { const baseUrl = process.env.BASE_URL; if (!baseUrl) { throw new Error("Missing BASE_URL environment variable."); } const fullUrl = `${baseUrl.replace(/\/+$/, "")}${endpoint}`; this.log?.( `🔐 Attempting login via POST to "${fullUrl}" with payload from "${fileName}".` ); const response = await fetch(fullUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(resolved), }); const data = await response.json(); if (!response.ok) { this.log?.( `❌ Login request failed for "${fullUrl}". Status: ${ response.status }. Response body: ${JSON.stringify(data).slice(0, 100)}...` ); throw new Error( `Login request failed with status ${response.status} for endpoint "${endpoint}".` ); } this.lastResponse = data; this.log?.( "🔐 Login successful, response data saved to 'this.lastResponse'." ); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.log?.(`❌ Login request failed: ${message}`); throw new Error( `Login request failed for endpoint "${endpoint}": ${message}` ); } } When( /^I login via POST to "([^"]+)" with payload from "([^"]+)"$/, When_I_login_via_POST_with_payload_from_file ); const genScriptDir = path.resolve(process.cwd(), "genScript"); if (!fs.existsSync(genScriptDir)) { fs.mkdirSync(genScriptDir, { recursive: true }); } const reportDir = process.env.REPORT_OUTPUT_DIR || process.env.K6_REPORT_DIR || process.env.npm_config_report_output_dir || "reports"; if (!fs.existsSync(reportDir)) { fs.mkdirSync(reportDir, { recursive: true }); } Then( /^I see the API should handle the (\w+) request successfully$/, { timeout: 300000 }, async function (method) { if (!this.config || !this.config.method) { throw new Error("Configuration is missing or incomplete."); } const expectedMethod = method.toUpperCase(); const actualMethod = this.config.method.toUpperCase(); if (actualMethod !== expectedMethod) { throw new Error( `Mismatched HTTP method: expected "${expectedMethod}", got "${actualMethod}"` ); } try { const scriptContent = buildK6Script(this.config); const uniqueId = crypto.randomBytes(8).toString("hex"); const scriptFileName = `k6-script-${uniqueId}.js`; const scriptPath = path.join(reportDir, scriptFileName); fs.writeFileSync(scriptPath, scriptContent, "utf-8"); this.log?.(`✅ k6 script generated at: "${scriptPath}"`); this.log?.(`🚀 Running k6 script: "${scriptFileName}"...`); const { stdout, stderr, code } = await runK6Script( scriptPath, process.env.K6_CUCUMBER_OVERWRITE === "true" ); if (stdout) this.log?.(`k6 STDOUT:\n${stdout}`); if (stderr) this.log?.(`k6 STDERR:\n${stderr}`); if (code !== 0) { throw new Error( `k6 process exited with code ${code}. Check k6 output for details.` ); } this.log?.( `✅ k6 script executed successfully for ${expectedMethod} request.` ); const saveK6Script = process.env.saveK6Script === "true" || process.env.SAVE_K6_SCRIPT === "true" || this.parameters?.saveK6Script === true; if (!saveK6Script) { try { fs.unlinkSync(scriptPath); this.log?.(`🧹 Temporary k6 script deleted: "${scriptPath}"`); } catch (cleanupErr) { this.log?.( `⚠️ Warning: Could not delete temporary k6 script file: "${scriptPath}". Error: ${ cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr) }` ); } } else { this.log?.( `ℹ️ k6 script kept at: "${scriptPath}". Set SAVE_K6_SCRIPT=false to delete automatically.` ); } } catch (error) { this.log?.( `❌ Failed to generate or run k6 script: ${ error instanceof Error ? error.message : String(error) }` ); throw new Error( `k6 script generation or execution failed: ${ error instanceof Error ? error.message : String(error) }` ); } } );