Unit Test Generation and Running PoC (#2232)

Co-authored-by: Opender Singh <opender.singh@konghq.com>
This commit is contained in:
Gregory Schier 2020-06-17 17:21:52 -07:00 committed by GitHub
parent df2bbda240
commit 5220d34a3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 11622 additions and 0 deletions

View File

@ -0,0 +1,16 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "10"
}
}
],
"@babel/preset-flow"
],
"plugins": [
["@babel/plugin-proposal-optional-chaining"]
]
}

View File

@ -0,0 +1,16 @@
[ignore]
.*/node_modules/.*
.*/__fixtures__/.*
./.cache
./dist
[include]
[libs]
./flow-typed
./types
[options]
esproposal.optional_chaining=enable
[lints]

1
packages/insomnia-testing/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist

View File

@ -0,0 +1,7 @@
# Ignore everything by default
# NOTE: NPM publish will never ignore package.json, package-lock.json, README, LICENSE, CHANGELOG
# https://npm.github.io/publishing-pkgs-docs/publishing/the-npmignore-file.html
*
# Don't ignore dist folder
!dist/*

View File

@ -0,0 +1,5 @@
// @flow
declare module 'axios' {
declare module.exports: *;
}

View File

@ -0,0 +1,5 @@
// @flow
declare module 'chai' {
declare module.exports: *;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
// @flow
declare class Mocha {
constructor(options?: { global?: Array<string> }): Mocha;
static Runner: {
constants: Object;
};
static reporters: {
Base: Object;
JSON: Object;
};
reporter(reporter: Object, options: Object): void;
addFile(filename: string): void;
run(callback: (failures: number) => void): Object;
}
declare module 'mocha' {
declare module.exports: typeof Mocha;
}

View File

@ -0,0 +1,3 @@
// @flow
export { generate } from './src/generate';
export { runTests } from './src/run';

9589
packages/insomnia-testing/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
{
"name": "insomnia-testing",
"version": "2.2.6",
"main": "dist/index.js",
"scripts": {
"typecheck": "flow check",
"test": "jest",
"build": "webpack --config webpack.config.js --display errors-only",
"bootstrap": "npm run build",
"prepublish": "npm run build"
},
"jest": {
"testEnvironment": "node",
"testMatch": [
"**/__tests__/**/*.test.js"
],
"verbose": false,
"resetMocks": true,
"resetModules": true
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
"husky": "^3.1.0",
"jest": "^26.0.1",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
},
"dependencies": {
"axios": "^0.19.2",
"chai": "^4.2.0",
"mocha": "^6.2.3"
}
}

View File

@ -0,0 +1,27 @@
const axios = jest.genMockFromModule('axios');
const mockResponses = {};
function key(method, url) {
return `${method.toLowerCase()}:${url.toLowerCase()}`;
}
axios.__setResponse = (method, url, resp) => {
mockResponses[key(method, url)] = resp;
};
axios.request = async function({ method, url }) {
const k = key(method, url);
const resp = mockResponses[k];
if (!resp) {
throw new Error(
`Could not find mock axios response for ${k}. Options are [${Object.keys(mockResponses).join(
', ',
)}]`,
);
}
return Promise.resolve(resp);
};
module.exports = axios;

View File

@ -0,0 +1,100 @@
// @flow
import axios from 'axios';
import type { TestSuite } from '../generate';
import { generateToFile } from '../generate';
import { runTests } from '../run';
import path from 'path';
import os from 'os';
import * as config from '../../webpack.config';
jest.mock('axios');
describe('webpack config', () => {
it('should set mocha as external', () => {
expect(config.externals.mocha).toBe('mocha');
});
});
describe('integration', () => {
it('generates and runs basic tests', async () => {
const testFilename = await generateToTmpFile([
{
name: 'Example TestSuite',
suites: [],
tests: [
{
name: 'should return -1 when the value is not present',
code: 'expect([1, 2, 3].indexOf(4)).toBe(-1);\nexpect(true).toBe(true);',
},
{
name: 'is an empty test',
code: '',
},
],
},
]);
const { stats } = await runTests(testFilename);
expect(stats.tests).toBe(2);
expect(stats.failures).toBe(0);
expect(stats.passes).toBe(2);
});
it('sends an HTTP request', async () => {
axios.__setResponse('GET', '200.insomnia.rest', {
status: 200,
headers: { 'content-type': 'application/json' },
data: { foo: 'bar' },
});
axios.__setResponse('GET', '301.insomnia.rest', {
status: 301,
headers: { location: '/blog' },
});
const testFilename = await generateToTmpFile([
{
name: 'Example TestSuite',
suites: [],
tests: [
{
name: 'Tests sending a request',
code: [
`const resp = await insomnia.send({ url: '200.insomnia.rest' });`,
`expect(resp.status).toBe(200);`,
`expect(resp.headers['content-type']).toBe('application/json');`,
`expect(resp.data.foo).toBe('bar');`,
].join('\n'),
},
{
name: 'Tests referencing request by ID',
code: [
`const resp = await insomnia.send('req_123');`,
`expect(resp.status).toBe(301);`,
].join('\n'),
},
],
},
]);
const { stats } = await runTests(testFilename, {
requests: {
req_123: {
url: '301.insomnia.rest',
method: 'get',
},
},
});
expect(stats.tests).toBe(2);
expect(stats.failures).toBe(0);
expect(stats.passes).toBe(2);
});
});
export async function generateToTmpFile(suites: Array<TestSuite>): Promise<string> {
const p = path.join(os.tmpdir(), `${Math.random()}.test.js`);
await generateToFile(p, suites);
return p;
}

View File

@ -0,0 +1,6 @@
[
{
"name": "Example Suite",
"tests": []
}
]

View File

@ -0,0 +1,2 @@
describe('Example Suite', () => {
});

View File

@ -0,0 +1,16 @@
[
{
"name": "Example Suite",
"tests": [
{
"name": "should return -1 when the value is not present",
"code": "expect([1, 2, 3].indexOf(4)).toBe(-1);\nexpect(true).toBe(true);"
},
{
"name": "is an empty test",
"code": ""
}
]
}
]

View File

@ -0,0 +1,9 @@
describe('Example Suite', () => {
it('should return -1 when the value is not present', async () => {
expect([1, 2, 3].indexOf(4)).toBe(-1);
expect(true).toBe(true);
});
it('is an empty test', async () => {
});
});

View File

@ -0,0 +1,21 @@
[
{
"name": "Parent Suite",
"suites": [
{
"name": "Nested Suite",
"tests": [
{
"name": "should return -1 when the value is not present",
"code": "expect([1, 2, 3].indexOf(4)).toBe(-1);\nexpect(true).toBe(true);"
},
{
"name": "is an empty test",
"code": ""
}
]
}
]
}
]

View File

@ -0,0 +1,11 @@
describe('Parent Suite', () => {
describe('Nested Suite', () => {
it('should return -1 when the value is not present', async () => {
expect([1, 2, 3].indexOf(4)).toBe(-1);
expect(true).toBe(true);
});
it('is an empty test', async () => {
});
});
});

View File

@ -0,0 +1,36 @@
[
{
"name": "Parent Suite",
"tests": [
{
"name": "should return -1 when the value is not present",
"code": "expect([1, 2, 3].indexOf(4)).toBe(-1);\nexpect(true).toBe(true);"
},
{
"name": "is an empty test",
"code": ""
}
],
"suites": [
{
"name": "Nested Suite",
"suites": [
{
"name": "Nested Again Suite",
"tests": [
{
"name": "should return -1 when the value is not present",
"code": "expect([1, 2, 3].indexOf(4)).toBe(-1);\nexpect(true).toBe(true);"
},
{
"name": "should be true",
"code": "expect(true).toBe(true);"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,22 @@
describe('Parent Suite', () => {
describe('Nested Suite', () => {
describe('Nested Again Suite', () => {
it('should return -1 when the value is not present', async () => {
expect([1, 2, 3].indexOf(4)).toBe(-1);
expect(true).toBe(true);
});
it('should be true', async () => {
expect(true).toBe(true);
});
});
});
it('should return -1 when the value is not present', async () => {
expect([1, 2, 3].indexOf(4)).toBe(-1);
expect(true).toBe(true);
});
it('is an empty test', async () => {
});
});

View File

@ -0,0 +1,36 @@
// @flow
import { generate } from '../index';
import fs from 'fs';
import path from 'path';
const fixturesPath = path.join(__dirname, '../__fixtures__');
const fixtures = fs.readdirSync(fixturesPath);
describe('fixtures', () => {
for (const input of fixtures) {
if (input.match(/\.output\.js$/)) {
continue;
}
const prefix = input.replace(/\.input\.json$/, '');
const output = `${prefix}.output.js`;
if (prefix.startsWith('skip')) {
continue;
}
it(`Generate ${input}`, async () => {
expect(typeof input).toBe('string');
expect(typeof output).toBe('string');
const inputContents = fs.readFileSync(path.join(fixturesPath, input), 'utf8');
const outputContents = fs.readFileSync(path.join(fixturesPath, output), 'utf8');
expect(typeof inputContents).toBe('string');
expect(typeof outputContents).toBe('string');
const expected = generate(JSON.parse(inputContents));
expect(expected.trim()).toBe(outputContents.trim());
});
}
});

View File

@ -0,0 +1,31 @@
// @flow
import { escapeJsStr, indent } from '../util';
describe('util', () => {
describe('indent()', () => {
it('skips indent on <= 0', () => {
expect(indent(0, 'hello')).toBe('hello');
expect(indent(-1, 'hello')).toBe('hello');
});
it('indents single lines', () => {
expect(indent(1, 'hello')).toBe(' hello');
expect(indent(3, 'hello')).toBe(' hello');
});
it('indents multi-line blocks', () => {
const text = `function greet() {\n console.log('Hello World!');\n}`;
expect(indent(1, text)).toBe(` function greet() {\n console.log('Hello World!');\n }`);
});
});
describe('escapeJsStr()', () => {
it('does not escape something without quotes', () => {
expect(escapeJsStr('Hello World')).toBe('Hello World');
});
it('escapes something with quotes', () => {
expect(escapeJsStr(`"Hello" 'World'`)).toBe(`"Hello" \\'World\\'`);
});
});
});

View File

@ -0,0 +1,77 @@
// @flow
import { escapeJsStr, indent } from './util';
import fs from 'fs';
export type Test = {
name: string,
code: string,
};
export type TestSuite = {
name: string,
suites: Array<TestSuite>,
tests?: Array<Test>,
};
export function generate(suites: Array<TestSuite>): string {
const lines = [];
for (const s of suites || []) {
lines.push(...generateSuiteLines(0, s));
}
return lines.join('\n');
}
export async function generateToFile(filepath: string, suites: Array<TestSuite>): Promise<void> {
const js = generate(suites);
return fs.promises.writeFile(filepath, js);
}
function generateSuiteLines(n: number, suite: ?TestSuite): Array<string> {
if (!suite) {
return [];
}
const lines = [];
lines.push(indent(n, `describe('${escapeJsStr(suite.name)}', () => {`));
const suites = suite.suites || [];
for (let i = 0; i < suites.length; i++) {
if (i !== 0) {
lines.push('');
}
lines.push(...generateSuiteLines(n + 1, suites[i]));
}
const tests = suite.tests || [];
for (let i = 0; i < tests.length; i++) {
// Add blank like if
// - it's the first test
// - we've outputted suites above
if (suites.length > 0 || i !== 0) {
lines.push('');
}
lines.push(...generateTestLines(n + 1, tests[i]));
}
lines.push(indent(n, `});`));
return lines;
}
function generateTestLines(n: number, test: ?Test): Array<string> {
if (!test) {
return [];
}
const lines = [];
lines.push(indent(n, `it('${escapeJsStr(test.name)}', async () => {`));
test.code && lines.push(indent(n + 1, test.code));
lines.push(indent(n, `});`));
return lines;
}

View File

@ -0,0 +1,17 @@
// @flow
export function escapeJsStr(s: string): string {
return s.replace(/'/g, `\\'`);
}
export function indent(level: number, code: string, tab: string = ' '): string {
if (!level || level < 0) {
return code;
}
const prefix = new Array(level + 1).join(' ');
return code
.split('\n')
.map(line => prefix + line)
.join('\n');
}

View File

@ -0,0 +1,41 @@
// @flow
import { runTests } from '../index';
import os from 'os';
import fs from 'fs';
import path from 'path';
const exampleTest = `
const assert = require('assert');
describe('Example', () => {
it('should be true', async () => assert.equal(true, true));
it('should fail', async () => assert.equal(true, false));
});
`;
describe('run', () => {
it('runs a mocha suite', async () => {
const testPath = writeToTmp(exampleTest);
const { stats } = await runTests(testPath);
expect(stats.passes).toBe(1);
expect(stats.tests).toBe(2);
expect(stats.failures).toBe(1);
});
it('works on multiple files', async () => {
const testPath1 = writeToTmp(exampleTest);
const testPath2 = writeToTmp(exampleTest);
const { stats } = await runTests([testPath1, testPath2]);
expect(stats.passes).toBe(2);
expect(stats.tests).toBe(4);
expect(stats.failures).toBe(2);
});
});
function writeToTmp(contents: string): string {
const tmpPath = path.join(os.tmpdir(), `${Math.random()}.test.js`);
fs.writeFileSync(tmpPath, contents);
return tmpPath;
}

View File

@ -0,0 +1,74 @@
// @flow
import Mocha from 'mocha';
import { JavaScriptReporter } from './javaScriptReporter';
import Insomnia from './insomnia';
import type { Request } from './insomnia';
type TestErr = {
generatedMessage: boolean,
name: string,
code: string,
actual: string,
expected: string,
operator: string,
};
type TestResult = {
title: string,
fullTitle: string,
file: string,
duration: number,
currentRetry: number,
err: TestErr | {},
};
type TestResults = {
stats: {
suites: number,
tests: number,
passes: number,
pending: number,
failures: number,
start: Date,
end: Date,
duration: number,
},
tests: Array<TestResult>,
pending: Array<TestResult>,
failures: Array<TestResult>,
passes: Array<TestResult>,
};
/**
* Run a test file using Mocha
*/
export async function runTests(
filename: string | Array<string>,
options: { requests?: { [string]: Request } } = {},
): Promise<TestResults> {
return new Promise(resolve => {
// Add global `insomnia` helper.
// This is the only way to add new globals to the Mocha environment as far
// as I can tell
global.insomnia = new Insomnia(options.requests);
const mocha = new Mocha({
global: ['insomnia'],
});
mocha.reporter(JavaScriptReporter);
const filenames = Array.isArray(filename) ? filename : [filename];
for (const f of filenames) {
mocha.addFile(f);
}
const runner = mocha.run(() => {
resolve(runner.testResults);
// Remove global since we don't need it anymore
delete global.insomnia;
});
});
}

View File

@ -0,0 +1,71 @@
// @flow
import axios from 'axios';
export type Request = {
url?: string,
method?: string,
headers?: {
[string]: string,
},
};
export type Response = {
status: number,
statusText: string,
data: Object,
headers: {
[string]: string,
},
};
/**
* An instance of Insomnia will be exposed as a global variable during
* tests, and will provide a bunch of utility functions for sending
* requests, etc.
*/
export default class Insomnia {
requests: { [string]: $Shape<Request> };
/**
* @param requests - map of ID -> Request to be used when referencing requests by Id
*/
constructor(requests?: { [string]: $Shape<Request> }) {
this.requests = requests || {};
}
/**
*
* @param req - raw request object or an ID to reference a request
* @returns {Promise<{headers: *, data: *, statusText: (string|string), status: *}>}
*/
async send(req: string | Request): Promise<Response> {
if (typeof req === 'string' && !this.requests.hasOwnProperty(req)) {
throw new Error(`Failed to find request by ID ${req}`);
}
if (typeof req === 'string' && this.requests.hasOwnProperty(req)) {
req = this.requests[req];
}
const options = {
url: req.url || '',
method: req.method || 'GET',
headers: req.headers || {},
// Don't follow redirects,
maxRedirects: 0,
// Don't throw errors on status != 200
validateStatus: status => true,
};
const resp = await axios.request(options);
return {
status: resp.status,
statusText: resp.statusText,
data: resp.data,
headers: resp.headers,
};
}
}

View File

@ -0,0 +1,111 @@
/**
* NOTE: This is a straight copy of the default Mocha JSON reporter, except
* stdout logging is removed.
*
* https://github.com/mochajs/mocha/blob/9d4a8ec2d22ee154aecb1f8eeb25af8e6309faa8/lib/reporters/json.js
*/
import Mocha from 'mocha';
export function JavaScriptReporter(runner, options) {
Mocha.reporters.Base.call(this, runner, options);
const self = this;
const tests = [];
const pending = [];
const failures = [];
const passes = [];
runner.on(Mocha.Runner.constants.EVENT_TEST_END, function(test) {
tests.push(test);
});
runner.on(Mocha.Runner.constants.EVENT_TEST_PASS, function(test) {
passes.push(test);
});
runner.on(Mocha.Runner.constants.EVENT_TEST_FAIL, function(test) {
failures.push(test);
});
runner.on(Mocha.Runner.constants.EVENT_TEST_PENDING, function(test) {
pending.push(test);
});
runner.once(Mocha.Runner.constants.EVENT_RUN_END, function() {
runner.testResults = {
stats: self.stats,
tests: tests.map(clean),
pending: pending.map(clean),
failures: failures.map(clean),
passes: passes.map(clean),
};
// This is the main change from the original JSONReporter
// process.stdout.write(JSON.stringify(obj, null, 2));
});
}
/**
* Return a plain-object representation of `test`
* free of cyclic properties etc.
*
* @private
* @param {Object} test
* @return {Object}
*/
function clean(test) {
let err = test.err || {};
if (err instanceof Error) {
err = errorJSON(err);
}
return {
title: test.title,
fullTitle: test.fullTitle(),
file: test.file,
duration: test.duration,
currentRetry: test.currentRetry(),
err: cleanCycles(err),
};
}
/**
* Replaces any circular references inside `obj` with '[object Object]'
*
* @private
* @param {Object} obj
* @return {Object}
*/
function cleanCycles(obj) {
const cache = [];
return JSON.parse(
JSON.stringify(obj, function(key, value) {
if (typeof value === 'object' && value !== null) {
if (cache.indexOf(value) !== -1) {
// Instead of going in a circle, we'll print [object Object]
return '' + value;
}
cache.push(value);
}
return value;
}),
);
}
/**
* Transform an Error object into a JSON object.
*
* @private
* @param {Error} err
* @return {Object}
*/
function errorJSON(err) {
const res = {};
Object.getOwnPropertyNames(err).forEach(function(key) {
res[key] = err[key];
}, err);
return res;
}
JavaScriptReporter.description = 'single JS object';

View File

@ -0,0 +1,32 @@
const path = require('path');
module.exports = {
entry: { index: './index.js' },
target: 'node',
mode: 'production',
devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
library: 'insomniatesting',
libraryTarget: 'commonjs2',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
],
},
externals: {
// Don't bundle Mocha because it needs to use require() to
// load tests. If it's bundled in the Webpack build, it will
// try to use Webpack's require() function and fail to
// import the test file because it lives outside the bundle.
mocha: 'mocha',
},
};