UNPKG

@frontendonly/api-testing

Version:

API testing module to validate the functionality, reliability, and performance of RESTful APIs by automating request and response verification.

385 lines (350 loc) 14.3 kB
import fetch from "node-fetch"; import fs from "fs"; import path from 'path'; import { evaluateExpression, evaluateExpressions, getContext, withContext } from "./utils.js"; import { apiJson } from './template/sample.js'; export class AgentRunnerApiTesting { static async init(name, config) { if (!name || typeof name !== 'string') { return console.log(`Name is required`); } try { // load from swagger url if (config.swagger && config.swagger) { // write the swaggerConfig to apiJson await this.loadSwaggerDocs(config.swagger, apiJson); } // generate a sample test case console.log(`Saving generated configuration to api-testing.json`); fs.writeFileSync('api-testing.json', JSON.stringify(Object.assign(apiJson, { name }), null, 3)); } catch (e) { console.log(e); } } /** * * @param {*} filePath * @param {*} concurrent * @returns */ static run(filePath = "api-testing.json", concurrent) { if (!fs.existsSync(filePath)) { return console.log(`Unable to perform run action due to missing ${filePath} file`); } const agentRunnerApiTesting = new this(filePath); agentRunnerApiTesting._run(concurrent); } process = { host: '', startTime: +new Date, endTime: null, logs: [] }; context = {}; testingData = null; constructor(filePath) { console.log(`Retrieving Test File and initializing test suite`); console.log(`Reading file ${filePath}`); this.testingData = JSON.parse(fs.readFileSync(filePath)); // set the default context this.setContext("currentDate", new Date().toLocaleDateString().split("/").reverse().join("")); this.setContext("currentTime", new Date().toLocaleTimeString()); if (this.testingData.defaultContext) { console.log(`Setting default Context`); for (const key in this.testingData.defaultContext) { let value = this.testingData.defaultContext[key]; if (value && typeof value == 'object') { value = JSON.parse(JSON.stringify(withContext(value, this.context))); } else { value = withContext(value, this.context); } this.setContext(key, value); } } } async authenticate() { const auth = this.testingData.auth; const logObj = { logs: [] }; const startTime = +new Date; const response = await this.fetch(auth.request, logObj); const passed = this.assert(auth, response, startTime, logObj); if (!passed && auth.failFast) { return console.log(`Failed to Authenticate user. Stopping all ${this.testingData.specs.length} test cases`); } } async _run(concurrent) { // check if authentication is registered and enabled if (this.testingData.auth && this.testingData.auth.enabled) { await this.authenticate(); } console.log(`Running ${this.testingData.name} cases`); if (!this.testingData.specs.length) { console.error(`No specs to run!`); process.exit(0); } const logObject = (msg) => ({ processed: [], logs: [msg] }); const env = this.testingData.envs[this.context.env]; this.process.host = `${env.host}${env.contextPath || ''}`; concurrent = Object.assign(this.testingData.concurrent || {}, concurrent || {}); const process = async (specs, next, logObj) => { const action = specs.shift(); if (!action) { console.log(`\n${logObj.logs.join('\n')}`); return next(); } if (!action.disabled) { if (!logObj.processed.includes(action.name)) { logObj.processed.push(action.name) logObj.logs.push(`Test<${action.name}>`); this.process.logs.push({ startTime: +new Date(), name: action.name, passed: 0, failed: 0, payload: action.body, requests: [], }); } const startTime = +new Date; const response = await this.fetch(action.request, logObj); this.assert(action, response, startTime, logObj); } process(specs, next, logObj); }; if (!concurrent?.enabled) { await process(this.testingData.specs.slice(), () => this.allDone(), logObject(`Running 1 of 1 concurrent session.`)); } else { // concurrent testing enabled console.log(`Performing ${concurrent.max * concurrent.rampup} concurrent user sessions, ramping ${concurrent.rampup} users every ${concurrent.every / 1000} second(s) `); let totalSessions = concurrent.max; let allProcessed = 0; let totalRan = 0; await new Promise((resolve) => { const next = () => { ++allProcessed; if ((concurrent.max * concurrent.rampup) == allProcessed) { resolve(); this.allDone(); } }; const intervalId = setInterval(() => { if (totalSessions) { for (let i = 0; i < concurrent.rampup; i++) { totalRan++; process(this.testingData.specs.slice(), next, logObject(`Running ${totalRan} of ${(concurrent.max * concurrent.rampup)} concurrent session`)); } totalSessions--; } else { clearInterval(intervalId); } }, concurrent.every || 1000); }); } } /** * * @param {*} req * @param {*} withContextPath * @param {*} logObj * @returns */ async fetch(req, logObj) { let url = withContext(`${req.url || this.process.host}${req.path || ""}`, this.context); this.processBeforeRequest(req); if ((req.conf.method || 'GET').toLowerCase() == 'post') { req.conf.body = JSON.stringify(req.conf.body); } else { const urlWithParams = new URL(url); Object.keys(req.conf?.body || {}).forEach(key => { urlWithParams.searchParams.append(key, req.conf.body[key]); }); url = urlWithParams.toString(); // remove the body prop delete req.conf.body; } // perform request let statusCode = 500; let failedRequest = false; const response = await fetch(url, req.conf) .catch((err) => { logObj.logs.push(`${err}\n`); failedRequest = true; }) .then((res) => { try { statusCode = res?.status || 500; return res.json(); } catch (e) { } finally { } }); return { statusCode, data: response || { message: 'Failed to process request' }, failedRequest }; } allDone() { this.process.endTime = Date.now(); console.log(`\n\t------------------------`); console.log( this.process.logs .map((item) => [`\n${item.name} : Passed<${item.passed}> Failed<${item.failed}>`, '------------------------------', item.requests.map(req => `${req.api} : <${req.passed ? 'Passed' : 'Failed'}> \n Request took ${req.took}ms\n${!req.passed ? JSON.stringify(req.error, null, 3) : ''}`).join('\n------------------------------\n') ].join('\n') ).join('\n') ); console.log(`All Done, please check logs`); // save records fs.writeFileSync('test_output.json', JSON.stringify(this.process, null, 3)); } /** * * @param {*} action * @param {*} response * @param {*} startTime * @param {*} logObj */ assert(action, response, startTime, logObj) { let allPassed = true; // evaluate success conditions if (Array.isArray(action.test)) { for (const test of action.test) { const passed = evaluateExpression(test, response, this.context); logObj.logs.push(`\t<${passed ? "Passed" : "Failed"}> ${test.title} `); if (!passed) { allPassed = false; } } } // save context for next request if (allPassed && action.store) { for (const store of action.store) { const value = store.value ? getContext(store.value, response.data) : response.data; this.setContext(store.key, value); } } const current = this.process.logs[this.process.logs.length - 1]; if (current) { if (allPassed) current.passed++; else current.failed++; current.requests.push({ api: `${(action.request.conf?.method || 'GET').toUpperCase()}${action.request.path}`, passed: allPassed, took: +new Date() - startTime, error: (!allPassed ? response : null) }); } return allPassed; } setContext(key, value) { console.log(`Writing key<${key}> to context`); this.context[key] = value; } getContext(key) { return this.context[key]; } processBeforeRequest(request) { if (!request.beforeRequest) return; const tasks = { requestDataMapping: (requestMapping) => { if (Array.isArray(requestMapping)) { const data = requestMapping.reduce((accum, item) => { if (evaluateExpressions(item.conditions, this.context)) { const value = getContext(item.value, this.context); accum[item.key] = value == undefined ? "" : value; } return accum; }, {}); // write the req body request.conf.body = data; } }, }; for (const key in request.beforeRequest) { tasks[key](request.beforeRequest[key]); } } /** * @param {*} swaggerConfig * { * url: 'https://url_to_swagger', * startsWith: 'api/' * } * @param {*} apiJson */ static async loadSwaggerDocs(swaggerConfig, apiJson) { console.log(`Loading swagger docs`); const swaggerDocs = await fetch(swaggerConfig.url).then(r => r.json()).catch(err => console.error(err.message)); if (!swaggerDocs) { console.log(`Unable to load swagger docs from ${swaggerConfig.url}`); return; } console.log(`${swaggerDocs.info.title}`); console.log(`${swaggerDocs.info.description || 'No description'}`); const putContext = (name, type) => { const types = { string: "", array: [], object: {}, number: 0 }; // set the context apiJson.defaultContext[name] = types[type]; }; const getRequestMapping = (req) => { if (req.parameters) { return req.parameters.filter(item => (item.in !== 'path')) .map((item) => { putContext(item.name, item.schema.type); return { key: item.name, value: `$.${item.name}`, }; }); } else if (req.requestBody) { const contentType = Object.keys(req.requestBody.content).find(type => type.includes("application/json")); if (!contentType) return []; const schemaPath = `$.${req.requestBody.content[contentType].schema.$ref.substr(2).replaceAll("/", ".")}`; const schema = getContext(schemaPath, swaggerDocs); return Object.keys(schema?.properties || {}).map((name) => { putContext(name, schema.properties[name].type); return { key: name, value: `$.${name}`, }; }); } }; // empty specs apiJson.specs = []; for (let cpath of Object.keys(swaggerDocs.paths)) { if (!swaggerConfig.startsWith || cpath.startsWith(swaggerConfig.startsWith)) { for (const method in swaggerDocs.paths[cpath]) { console.log(`Writting ${method}${cpath}`) const req = swaggerDocs.paths[cpath][method]; // push the new config to our spec apiJson.specs.push({ name: req.name || req.tags[0], request: { path: cpath.replaceAll(/{(.*)}/g, (a, b) => `%$.${b}%`), conf: { method: method.toLocaleUpperCase() }, beforeRequest: { requestDataMapping: getRequestMapping(req) } }, test: [{ 'title': 'StatusCode should be 200', key: "$.statusCode", operator: 'eq', value: 200 }] }); } } } } }