├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── README.md ├── examples ├── get.test.ts ├── post.test.ts └── schema.test.ts ├── index.js ├── jest-preset.json ├── jest.config.example.js ├── jest.config.js ├── package-lock.json ├── package.json ├── setup-files-after-env.js ├── setup.js ├── src ├── JestRestEnvironment.ts ├── constants.ts ├── global.ts ├── logger.test.ts ├── logger.ts ├── setup-files-after-env.ts ├── to-match-schema.test.ts ├── to-match-schema.ts ├── utils.test.ts ├── utils.ts ├── validate.test.ts └── validate.ts ├── teardown.js ├── testEnvironment.js ├── tsconfig.json └── types └── global.d.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | coverage/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: 'module', // Allows for the use of imports 6 | }, 7 | settings: {}, 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 10 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 11 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 12 | ], 13 | rules: { 14 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 15 | // e.g. '@typescript-eslint/explicit-function-return-type': 'off', 16 | 'global-require': 'off', 17 | 'import/no-extraneous-dependencies': 'off', 18 | 'import/no-dynamic-require': 'off', 19 | 'import/no-unresolved': 'off', 20 | 'prettier/prettier': 'error', 21 | '@typescript-eslint/ban-ts-ignore': 'off', 22 | '@typescript-eslint/ban-ts-comment': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | coverage/* 3 | lib/* 4 | .vscode/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/lib/*.js 3 | !types/ 4 | !index.js 5 | !jest-preset.json 6 | !setup-files-after-env.js 7 | !setup.js 8 | !teardown.js 9 | !testEnvironment.js -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | endOfLine: 'auto', 8 | }; 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jest-Rest 🎪 2 | 3 | Jest Rest is a Rest API testing framework using Jest, Axios and Ajv. 4 | 5 | Schema definition matching is done using AJV. (one of the fastest schema matcher). 6 | 7 | ### Highlights 8 | 9 | - **Debug** 🕵️ - Pause the tests to see what's happening in real-time. 10 | - **Logger** 📝- log request and response to the terminal for more debugging capability. 11 | - **Contract Tests** 🤝 - Perform contract testing / schema validation testing. 12 | - **Parallel tests** 🧪🧪 - Run parallel tests to speed-up testing. 13 | 14 | 15 | 16 | ### Installation 17 | 18 | ```bash 19 | npm install -D jest jest-rest-preset 20 | ``` 21 | 22 | ## Requirements 23 | 24 | - Node.js >= 10.15.0 25 | - Jest >=25 26 | 27 | ## Usage 28 | 29 | Update your Jest configuration, either: 30 | 31 | - with `package.json`: 32 | 33 | ```json 34 | "jest": { 35 | "preset": "jest-rest-preset" 36 | } 37 | ``` 38 | 39 | - with `jest.config.js`: 40 | 41 | ```javascript 42 | module.exports = { 43 | preset: "jest-rest-preset", 44 | ... 45 | } 46 | ``` 47 | 48 | **NOTE**: Be sure to remove any existing `testEnvironment` option from your Jest configuration. The `jest-rest-preset` preset needs to manage that option itself. 49 | 50 | Use Jest-Rest in your tests: 51 | 52 | - with `package.json` 53 | 54 | ```json 55 | { 56 | "scripts": { 57 | "test": "jest" 58 | } 59 | } 60 | ``` 61 | 62 | ```js 63 | describe('Get test', () => { 64 | test('get', async () => { 65 | const get = await axios.get('https://httpbin.org/get', { headers: { token: 'sometoken' } }); 66 | await jestRest.debug(); 67 | expect(get.status).toBe(200); 68 | }); 69 | }); 70 | ``` 71 | 72 | ## Configuration 73 | 74 | You can specify a `jest-rest.config.js` or `jest-rest.config.json` at the root of the project. It should export a config object. 75 | 76 | ```javascript 77 | { 78 | 79 | /* 80 | * Define all global config for axios here 81 | * 82 | * More info at https://github.com/axios/axios#request-config 83 | */ 84 | "axios": {}, 85 | 86 | /* 87 | * Define all global config for ajv here 88 | * 89 | * More info at https://ajv.js.org/#options 90 | * default option added is {"allError": true} 91 | */ 92 | "ajv": {} 93 | } 94 | ``` 95 | 96 | > Default jest timeout is 30 seconds 97 | 98 | ## Put the tests in debug mode 99 | 100 | Jest Rest exposes a method `jestRest.debug()` that suspends test execution and allows to see what's going on. 101 | 102 | ```javascript 103 | await jestRest.debug(); 104 | ``` 105 | 106 | > This will work perfectly when the tests are run sequentially using the jests `--runInBand` option or only single test is run. 107 | 108 | ## Schema Validation 109 | 110 | Jest-Rest extends the jest matcher to include schema validation using ajv. 111 | `toMatchSchema` 112 | 113 | usage: 114 | 115 | ```javascript 116 | const schema = { 117 | properties: { 118 | foo: { type: 'string' }, 119 | bar: { type: 'number', maximum: 3 }, 120 | }, 121 | }; 122 | test('schema validation', async () => { 123 | const data = { foo: 'abc', bar: 2 }; 124 | expect(data).toMatchSchema(schema); 125 | }); 126 | ``` 127 | 128 | ## Log request & response of axios 129 | 130 | Use environment variable `logger` to log request and/ or response from axios to the console/terminal. 131 | 132 | `logger="*request"` - To log axios request to console. 133 | 134 | `logger="*response"` - To log axios response to console. 135 | 136 | `logger="*info"` - To log axios request and response to console. 137 | 138 | `logger="*error"` - To log axios error request and / or response to console. 139 | 140 | usage: 141 | On Windows cmd 142 | 143 | ```sh 144 | set logger=*request 145 | ``` 146 | 147 | using powershell 148 | 149 | ```sh 150 | $env:logger="*request, *response" 151 | ``` 152 | 153 | on Mac/linux 154 | 155 | ```sh 156 | export logger="*request, *response" 157 | ``` 158 | 159 | To disable logs, set an empty value `logger=""`. 160 | 161 | ## Usage with Typescript 162 | 163 | Example Jest configuration in combination with [ts-jest](https://github.com/kulshekhar/ts-jest): 164 | 165 | ```javascript 166 | module.exports = { 167 | preset: 'jest-rest-preset', 168 | transform: { 169 | '^.+\\.ts$': 'ts-jest', 170 | }, 171 | }; 172 | ``` 173 | 174 | Types are also available, which you can either use via directly in your test: 175 | 176 | or at your central `tsconfig.json` either via `types`: 177 | 178 | ```json 179 | { 180 | "compilerOptions": { 181 | "types": ["jest-rest-preset", "@types/jest"] 182 | } 183 | } 184 | ``` 185 | 186 | or via `files`: 187 | 188 | ```json 189 | { 190 | "files": ["@types/jest", "node_modules/jest-rest-preset/types/global.d.ts"] 191 | } 192 | ``` 193 | 194 | ## HTML Reporters 195 | 196 | There are multiple Html reporter plugin's available. Feel free to add as per your choice. 197 | 198 | - https://github.com/zaqqaz/jest-allure 199 | - https://github.com/dkelosky/jest-stare 200 | - https://github.com/Hazyzh/jest-html-reporters 201 | 202 | ## Inspiration 203 | 204 | Thanks to [jest-playwright](https://github.com/playwright-community/jest-playwright) 205 | 206 | ## License 207 | 208 | MIT 209 | -------------------------------------------------------------------------------- /examples/get.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Get test', () => { 4 | test('get', async () => { 5 | const get = await axios.get('https://httpbin.org/get', { headers: { token: 'sometoken' } }); 6 | await jestRest.debug(); 7 | expect(get.status).toBe(200); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /examples/post.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Post test', () => { 4 | test('/post', async () => { 5 | const post = await axios.post('https://httpbin.org/post', { foo: 'bar' }).catch((err) => err.response); 6 | expect(post.status).toBe(200); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/schema.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { toMatchSchema } from '../src/to-match-schema'; 4 | 5 | expect.extend({ toMatchSchema }); 6 | 7 | const schema = { 8 | host: 'cert', 9 | paths: { 10 | products: { 11 | get: { 12 | responses: { 13 | '401': { 14 | description: 'Problem with the client request', 15 | headers: { 16 | 'x-correlator': { 17 | type: 'string', 18 | format: 'uuid', 19 | description: 'Correlation id', 20 | }, 21 | }, 22 | schema: { 23 | Unauthenticated: { 24 | allOf: [ 25 | { 26 | type: 'object', 27 | required: ['code'], 28 | properties: { 29 | code: { 30 | type: 'string', 31 | enum: ['UNAUTHENTICATED'], 32 | default: 'UNAUTHENTICATED', 33 | description: 'Request not authenticated due to missing, invalid, or expired credentials.', 34 | }, 35 | }, 36 | }, 37 | { 38 | type: 'object', 39 | required: ['message'], 40 | properties: { 41 | message: { 42 | type: 'string', 43 | description: 'A human readable description', 44 | }, 45 | }, 46 | }, 47 | ], 48 | }, 49 | }, 50 | examples: { 51 | 'application/json': { 52 | code: 'UNAUTHENTICATED', 53 | message: 'Authentication error', 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | }; 62 | 63 | describe('Schema test', () => { 64 | const unAuthenticatedSchema = schema.paths.products.get.responses[401].schema.Unauthenticated; 65 | 66 | test('fails', () => { 67 | const data = { code: 'AAA', message: 'token expired' }; 68 | expect(() => expect(data).toMatchSchema(unAuthenticatedSchema)).toThrowError(); 69 | }); 70 | 71 | test('passes', async () => { 72 | const data = { code: 'UNAUTHENTICATED', message: 'token expired' }; 73 | expect(data).toMatchSchema(unAuthenticatedSchema); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | module.exports = require('./lib/JestRestEnvironment').default; 3 | module.exports.globalSetup = require('./lib/global').setup; 4 | module.exports.globalTeardown = require('./lib/global').teardown; 5 | -------------------------------------------------------------------------------- /jest-preset.json: -------------------------------------------------------------------------------- 1 | { 2 | "globalSetup": "jest-rest-preset/setup.js", 3 | "globalTeardown": "jest-rest-preset/teardown.js", 4 | "testEnvironment": "jest-rest-preset", 5 | "testRunner": "jest-circus/runner", 6 | "testTimeout": 30000, 7 | "setupFilesAfterEnv": ["jest-rest-preset/setup-files-after-env.js"] 8 | } -------------------------------------------------------------------------------- /jest.config.example.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testPathIgnorePatterns: ['/node_modules/', 'lib'], 4 | testMatch: ['**/examples/**/*.test.ts'], 5 | globalSetup: './setup.js', 6 | globalTeardown: './teardown.js', 7 | testEnvironment: './testEnvironment.js', 8 | testTimeout: 30000, 9 | testRunner: 'jest-circus/runner', 10 | setupFilesAfterEnv: ['./setup-files-after-env.js'], 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testPathIgnorePatterns: ['/node_modules/', 'lib'], 4 | testMatch: ['**/src/**/*.test.ts'], 5 | collectCoverage: true, 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest-rest-preset", 3 | "version": "0.0.4", 4 | "description": "Run API tests using Jest, Axios and AJV", 5 | "types": "./types/global.d.ts", 6 | "main": "index.js", 7 | "homepage": "https://github.com/rupeshmore/jest-rest#readme", 8 | "scripts": { 9 | "start": "npm run build -- --watch", 10 | "build": "tsc", 11 | "format": "prettier --write \"*.{js,md}\" \"src/**/*.ts\"", 12 | "lint": "tsc --noEmit && eslint . --ext .js,.ts", 13 | "prepublishOnly": "npm test && npm run lint", 14 | "prepare": "npm run build", 15 | "preversion": "npm run lint", 16 | "test": "jest", 17 | "test:example": "npm run build && jest --config=jest.config.example.js --runInBand", 18 | "version": "npm run format && git add -A src", 19 | "postversion": "git push && git push --tags", 20 | "coveralls": "jest --coverage && cat ./coverage/lcov.info | coveralls" 21 | }, 22 | "author": "Rupesh More <“rupesh.more@gmail.com”>", 23 | "license": "ISC", 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/rupeshmore/jest-rest.git" 27 | }, 28 | "keywords": [ 29 | "api-tests", 30 | "jest", 31 | "axios", 32 | "ajv", 33 | "contract-tests", 34 | "open-api-tests", 35 | "swagger-tests" 36 | ], 37 | "husky": { 38 | "hooks": { 39 | "pre-commit": "lint-staged" 40 | } 41 | }, 42 | "lint-staged": { 43 | "**/*.{js,ts,md}": [ 44 | "prettier --write" 45 | ], 46 | "*.{js,ts}": "eslint --fix" 47 | }, 48 | "peerDependencies": { 49 | "jest-environment-node": ">=25.x" 50 | }, 51 | "devDependencies": { 52 | "@types/axios": "0.14.0", 53 | "@types/debug": "^4.1.5", 54 | "@types/jest": "^25.1.4", 55 | "@types/node": "12.12.2", 56 | "@typescript-eslint/eslint-plugin": "^3.1.0", 57 | "@typescript-eslint/parser": "^3.1.0", 58 | "coveralls": "^3.1.0", 59 | "eslint": "^7.1.0", 60 | "eslint-config-prettier": "^6.11.0", 61 | "eslint-plugin-prettier": "^3.1.3", 62 | "jest": "^25.2.4", 63 | "jest-environment-node": "^25.2.4", 64 | "prettier": "^2.0.5", 65 | "ts-jest": "^26.0.0", 66 | "typescript": "^3.8.3" 67 | }, 68 | "dependencies": { 69 | "ajv": "^6.12.2", 70 | "axios": "^0.21.1", 71 | "chalk": "^4.0.0", 72 | "debug": "^4.1.1", 73 | "jest-circus": "^26.0.1" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /setup-files-after-env.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/setup-files-after-env'); 2 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | module.exports = require('./lib/global').setup; 3 | -------------------------------------------------------------------------------- /src/JestRestEnvironment.ts: -------------------------------------------------------------------------------- 1 | import NodeEnvironment from 'jest-environment-node'; 2 | import { Config as JestConfig } from '@jest/types'; 3 | import { requestLogger, responseLogger, requestErrorLogger, responseErrorLogger } from './logger'; 4 | 5 | const KEYS = { 6 | CONTROL_C: '\u0003', 7 | CONTROL_D: '\u0004', 8 | ENTER: '\r', 9 | }; 10 | 11 | declare type DoneFn = (reason?: string | Error) => void; 12 | declare type HookFn = (done?: DoneFn) => Promise | null | undefined; 13 | declare type Event = { 14 | name: string; 15 | fn: HookFn; 16 | }; 17 | 18 | declare type State = { 19 | testTimeout: number; 20 | }; 21 | 22 | export default class JestRestEnvironment extends NodeEnvironment { 23 | private _config: JestConfig.ProjectConfig; 24 | private myRequestInterceptor!: number; 25 | private myResponseInterceptor!: number; 26 | constructor(config: JestConfig.ProjectConfig) { 27 | super(config); 28 | this._config = config; 29 | } 30 | 31 | async setup(): Promise { 32 | this.global.axios = require('axios'); 33 | 34 | this.global.jestRest = { 35 | debug: async (): Promise => { 36 | console.log('\n\n🕵️‍ Code is paused, press enter to resume \n'); 37 | 38 | // Run an infinite promise 39 | return new Promise((resolve) => { 40 | const { stdin } = process; 41 | const onKeyPress = (key: string): void => { 42 | if (key === KEYS.CONTROL_C || key === KEYS.CONTROL_D || key === KEYS.ENTER) { 43 | stdin.removeListener('data', onKeyPress); 44 | if (!listening) { 45 | if (stdin.isTTY) { 46 | stdin.setRawMode(false); 47 | } 48 | stdin.pause(); 49 | } 50 | resolve(); 51 | } 52 | }; 53 | const listening = stdin.listenerCount('data') > 0; 54 | if (!listening) { 55 | if (stdin.isTTY) { 56 | stdin.setRawMode(true); 57 | } 58 | stdin.resume(); 59 | stdin.setEncoding('utf8'); 60 | } 61 | stdin.on('data', onKeyPress); 62 | }); 63 | }, 64 | }; 65 | 66 | this.myRequestInterceptor = this.global.axios.interceptors.request.use(requestLogger, requestErrorLogger); 67 | this.myResponseInterceptor = this.global.axios.interceptors.response.use(responseLogger, responseErrorLogger); 68 | } 69 | 70 | async handleTestEvent(event: Event, state: State): Promise { 71 | // Hack to set testTimeout for jestRest debugging 72 | if ( 73 | event.name === 'add_test' && 74 | event.fn && 75 | event.fn.toString().includes('jestRest.debug()') && 76 | process.stdin.isTTY 77 | ) { 78 | // Set timeout to 4 days 79 | state.testTimeout = 4 * 24 * 60 * 60 * 1000; 80 | } 81 | } 82 | 83 | async teardown(): Promise { 84 | if (this.global.axios) { 85 | this.global.axios.interceptors.request.eject(this.myRequestInterceptor); 86 | this.global.axios.interceptors.response.eject(this.myResponseInterceptor); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | 3 | export interface Config { 4 | // jest?: Partial; 5 | axios?: AxiosRequestConfig; 6 | // https://ajv.js.org/#options 7 | ajv?: Record; 8 | } 9 | 10 | export const DEFAULT_CONFIG: Config = { 11 | // jest: { 12 | // testTimeout: 30000 13 | // }, 14 | axios: {}, 15 | // https://ajv.js.org/#options 16 | ajv: { 17 | allErrors: true, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | export const setup = (): void => {}; 3 | 4 | export const teardown = (): void => {}; 5 | -------------------------------------------------------------------------------- /src/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { requestLogger, responseLogger, requestErrorLogger, responseErrorLogger } from './logger'; 2 | import { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; 3 | 4 | const sampleAxiosReq: AxiosRequestConfig = { 5 | method: 'get', 6 | url: 'https://example.org', 7 | headers: { 8 | foo: 'bar', 9 | 'content-type': 'application/json', 10 | 'User-Agent': 'axios', 11 | }, 12 | }; 13 | 14 | const sampleAxiosRes: AxiosResponse = { 15 | status: 200, 16 | headers: { 'content-type': 'application/json' }, 17 | data: 'sample Res', 18 | statusText: 'sample Res', 19 | config: { 20 | ...sampleAxiosReq, 21 | }, 22 | }; 23 | 24 | const sampleRequestError: AxiosError = { 25 | config: { 26 | ...sampleAxiosReq, 27 | }, 28 | request: { 29 | ...sampleAxiosReq, 30 | }, 31 | response: { 32 | ...sampleAxiosRes, 33 | }, 34 | isAxiosError: false, 35 | toJSON: () => [], 36 | name: 'sample name', 37 | message: 'sample message', 38 | }; 39 | 40 | const sampleResponseError: AxiosError = { 41 | ...sampleRequestError, 42 | }; 43 | 44 | describe('logger', () => { 45 | const OLD_ENV = process.env; 46 | 47 | beforeEach(() => { 48 | jest.resetModules(); 49 | process.env = { ...OLD_ENV }; 50 | delete process.env.logger; 51 | }); 52 | 53 | afterEach(() => { 54 | process.env = OLD_ENV; 55 | }); 56 | 57 | test('should not print any log statements when an environmnet variable is not set', () => { 58 | process.env.logger = ''; 59 | requestLogger(sampleAxiosReq); 60 | responseLogger(sampleAxiosRes); 61 | requestErrorLogger(sampleRequestError); 62 | responseErrorLogger(sampleResponseError); 63 | expect(true).toBeTruthy(); 64 | }); 65 | 66 | test('should print requests log statements when an environmnet variable "request" is set', () => { 67 | process.env.logger = 'jest-rest:request'; 68 | const sampleReq: AxiosRequestConfig = { 69 | url: 'https://example.org', 70 | headers: { 71 | 'Content-Type': 'application/json', 72 | common: { 73 | dummy: 'dummy-header', 74 | }, 75 | }, 76 | params: { foo: 'bar' }, 77 | data: Buffer.from(sampleAxiosRes.data), 78 | auth: { username: 'foo', password: 'bar' }, 79 | }; 80 | 81 | const req = requestLogger(sampleReq); 82 | expect(req).toBe(sampleReq); 83 | }); 84 | 85 | test('should print response log statements when an environmnet variable "response" is set', () => { 86 | process.env.logger = 'jest-rest:response'; 87 | const sampleResponse: AxiosResponse = { 88 | ...sampleAxiosRes, 89 | data: { json: { foo: 'bar' } }, 90 | }; 91 | delete sampleResponse.statusText; 92 | 93 | const res = responseLogger(sampleResponse); 94 | expect(res).toBe(sampleResponse); 95 | }); 96 | 97 | test('should print error log statements when an environmnet variable "error" is set', () => { 98 | process.env.logger = 'jest-rest:error'; 99 | const sampleReq = { 100 | ...sampleRequestError, 101 | }; 102 | delete sampleReq.request.headers; 103 | 104 | const req = requestErrorLogger(sampleReq); 105 | expect(req).rejects.toBe(sampleReq); 106 | }); 107 | 108 | test('should print error log statements when an environmnet variable "error" is set', async () => { 109 | process.env.logger = 'jest-rest:error'; 110 | const res = responseErrorLogger(sampleResponseError); 111 | expect(res).rejects.toBe(sampleResponseError); 112 | }); 113 | 114 | test('should not print error log statements when error.request is undefined', () => { 115 | process.env.logger = 'jest-rest:error'; 116 | const error = { 117 | ...sampleRequestError, 118 | }; 119 | delete error.request; 120 | const req = requestErrorLogger(error); 121 | expect(req).rejects.toBe(error); 122 | }); 123 | 124 | test('should not print error log statements when error.response is undefined', async () => { 125 | process.env.logger = 'jest-rest:error'; 126 | const error: AxiosError = { 127 | ...sampleResponseError, 128 | }; 129 | delete error.response; 130 | const res = responseErrorLogger(error); 131 | expect(res).rejects.toBe(error); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import chalk from 'chalk'; 3 | import { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; 4 | const debug = require('debug'); 5 | 6 | const debugInfo = debug('jest-rest:info'); 7 | const debugError = debug('jest-rest:error'); 8 | const debugRequest = debug('jest-rest:request'); 9 | const debugResponse = debug('jest-rest:response'); 10 | 11 | const requestColor = chalk.grey; 12 | const responseColor = chalk.cyan; 13 | 14 | export const requestLogger = (req: AxiosRequestConfig): AxiosRequestConfig => { 15 | const loggerEnvVariable: string = getEnvVariable(); 16 | if (!loggerEnvVariable) return req; 17 | 18 | enableDebugIfNotFor(loggerEnvVariable); 19 | 20 | reqLogger(req); 21 | 22 | return req; 23 | }; 24 | 25 | export const responseLogger = (res: AxiosResponse): AxiosResponse => { 26 | const loggerEnvVariable: string = getEnvVariable(); 27 | if (!loggerEnvVariable) return res; 28 | 29 | enableDebugIfNotFor(loggerEnvVariable); 30 | 31 | resLogger(res); 32 | return res; 33 | }; 34 | 35 | export const requestErrorLogger = (error: AxiosError): AxiosError | Promise => { 36 | const loggerEnvVariable: string = getEnvVariable(); 37 | if (!loggerEnvVariable) return error; 38 | 39 | enableDebugIfNotFor(loggerEnvVariable); 40 | 41 | if (error.request !== undefined) { 42 | const req = reqLogger(error.request); 43 | debugError(`%s`, req); 44 | } 45 | return Promise.reject(error); 46 | }; 47 | 48 | export const responseErrorLogger = (error: AxiosError): AxiosError | Promise => { 49 | const loggerEnvVariable: string = getEnvVariable(); 50 | if (!loggerEnvVariable) return error; 51 | 52 | enableDebugIfNotFor(loggerEnvVariable); 53 | 54 | if (error.response !== undefined) { 55 | const reqLog = formatRequest(error.response.config); 56 | const prettifyRequest = requestColor(JSON.stringify(reqLog, null, 2)); 57 | debugError(`%s`, prettifyRequest); 58 | 59 | const res = resLogger(error.response); 60 | debugError(`%s`, res); 61 | } 62 | 63 | return Promise.reject(error); 64 | }; 65 | 66 | const reqLogger = (req: AxiosRequestConfig) => { 67 | const reqLog = formatRequest(req); 68 | const prettifyRequest = requestColor(JSON.stringify(reqLog, null, 2)); 69 | 70 | debugRequest(`%s`, prettifyRequest); 71 | debugInfo(`%s`, prettifyRequest); 72 | 73 | return prettifyRequest; 74 | }; 75 | 76 | const resLogger = (res: AxiosResponse) => { 77 | const resLog = formatResponse(res); 78 | const prettifyResponse = responseColor(JSON.stringify(resLog, null, 2)); 79 | 80 | debugResponse(`%s`, prettifyResponse); 81 | debugInfo(`%s`, prettifyResponse); 82 | 83 | return prettifyResponse; 84 | }; 85 | 86 | const getEnvVariable = (): string => process.env.logger || ''; 87 | 88 | const enableDebugIfNotFor = (name: string): void => { 89 | if (!debug.enabled(`${name}`)) { 90 | debug.enable(`${name}`); 91 | } 92 | }; 93 | 94 | const formatRequest = (req: AxiosRequestConfig): AxiosRequestConfig => { 95 | const method = req.method; 96 | const url = req.url; 97 | let headers = req.headers; 98 | if (headers) { 99 | headers = Object.assign(headers.common ? { ...headers.common } : {}, method ? { ...headers[method] } : {}, { 100 | ...headers, 101 | }); 102 | 103 | ['common', 'get', 'post', 'head', 'put', 'patch', 'delete'].forEach((header) => { 104 | if (headers[header]) { 105 | delete headers[header]; 106 | } 107 | }); 108 | 109 | if (headers['User-Agent']) delete headers['User-Agent']; 110 | } 111 | 112 | if (method) { 113 | method.toUpperCase(); 114 | } 115 | 116 | let body = req.data; 117 | 118 | if (Buffer.isBuffer(body)) { 119 | body = `buffer body; will not be printed`; 120 | } 121 | 122 | return Object.assign( 123 | { 124 | method, 125 | url, 126 | }, 127 | req.params ? { params: req.params } : {}, 128 | { headers }, 129 | body ? { body } : {}, 130 | req.auth ? { auth: req.auth } : {}, 131 | ); 132 | }; 133 | 134 | const formatResponse = (res: AxiosResponse) => { 135 | const body = res.data; 136 | const headers = res.headers; 137 | const status = res.status; 138 | 139 | return { 140 | status, 141 | headers, 142 | body, 143 | }; 144 | }; 145 | -------------------------------------------------------------------------------- /src/setup-files-after-env.ts: -------------------------------------------------------------------------------- 1 | import { toMatchSchema } from './to-match-schema'; 2 | 3 | expect.extend({ toMatchSchema }); 4 | -------------------------------------------------------------------------------- /src/to-match-schema.test.ts: -------------------------------------------------------------------------------- 1 | import { toMatchSchema } from './to-match-schema'; 2 | 3 | const schema = { 4 | properties: { 5 | foo: { type: 'string' }, 6 | bar: { type: 'number', maximum: 3 }, 7 | }, 8 | required: ['foo'], 9 | }; 10 | 11 | describe('.toMatchSchema', () => { 12 | test('passes', async () => { 13 | const data = { foo: 'a', bar: 3 }; 14 | const val = toMatchSchema(data, schema); 15 | expect(val.pass).toBeTruthy(); 16 | expect(val.message()).toBe(''); 17 | }); 18 | 19 | test('fails', async () => { 20 | const data = { foo: 2, bar: 4 }; 21 | const val = toMatchSchema(data, schema); 22 | expect(val.pass).toBeFalsy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/to-match-schema.ts: -------------------------------------------------------------------------------- 1 | import { matcherHint, printReceived } from 'jest-matcher-utils'; 2 | import { validate } from './validate'; 3 | 4 | type AnyObj = Record; 5 | 6 | type MatchSchemaType = { 7 | pass: boolean; 8 | message(): string; 9 | }; 10 | 11 | export const toMatchSchema = (data: AnyObj, schema: AnyObj): MatchSchemaType => { 12 | const schemaValid = validate(data, schema); 13 | 14 | const pass = schemaValid.valid; 15 | const errorText = schemaValid.errorText; 16 | 17 | if (pass) { 18 | return { 19 | pass, 20 | message: () => errorText, 21 | }; 22 | } 23 | return { 24 | pass, 25 | message: () => { 26 | return [ 27 | matcherHint(`.toMatchSchema`, 'data', 'schema'), 28 | '', 29 | `Error matching schema:`, 30 | ` ${printReceived(errorText)}`, 31 | ].join('\n'); 32 | }, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { readConfig } from './utils'; 4 | import { DEFAULT_CONFIG } from './constants'; 5 | 6 | describe('readConfig', () => { 7 | const jestSpy = jest.spyOn(fs, 'existsSync'); 8 | 9 | beforeEach(() => { 10 | jest.resetModules(); 11 | }); 12 | 13 | test('should return the default configuration if there was no separate configuration specified', () => { 14 | const config = readConfig(); 15 | expect(config).toMatchObject(DEFAULT_CONFIG); 16 | }); 17 | 18 | test('should return default config if file not found', () => { 19 | jestSpy.mockImplementationOnce(() => false).mockImplementationOnce(() => false); 20 | 21 | jest.mock(path.join(__dirname, '..', ''), () => ({}), { virtual: true }); 22 | 23 | const config = readConfig(); 24 | expect(config).toMatchObject(DEFAULT_CONFIG); 25 | }); 26 | 27 | test('should overwrite with a custom js configuration', () => { 28 | jestSpy.mockImplementationOnce(() => true).mockImplementationOnce(() => false); 29 | 30 | jest.mock( 31 | path.join(__dirname, '..', 'jest-rest.config.js'), 32 | () => ({ 33 | jest: { 34 | setTimeout: 50000, 35 | }, 36 | axios: {}, 37 | avj: { 38 | allErrors: true, 39 | }, 40 | }), 41 | { virtual: true }, 42 | ); 43 | 44 | const config = readConfig(); 45 | const expectedConfig = { 46 | jest: { 47 | setTimeout: 50000, 48 | }, 49 | axios: {}, 50 | avj: { 51 | allErrors: true, 52 | }, 53 | }; 54 | expect(config).toMatchObject(expectedConfig); 55 | }); 56 | 57 | test('should overwrite with a custom json configuration', () => { 58 | jestSpy.mockImplementationOnce(() => false).mockImplementationOnce(() => true); 59 | 60 | jest.mock( 61 | path.join(__dirname, '..', 'jest-rest.config.json'), 62 | () => ({ 63 | jest: { 64 | setTimeout: 150000, 65 | }, 66 | axios: {}, 67 | avj: { 68 | allErrors: true, 69 | }, 70 | }), 71 | { virtual: true }, 72 | ); 73 | const config = readConfig(); 74 | const expectedConfig = { 75 | jest: { 76 | setTimeout: 150000, 77 | }, 78 | axios: {}, 79 | avj: { 80 | allErrors: true, 81 | }, 82 | }; 83 | expect(config).toMatchObject(expectedConfig); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { DEFAULT_CONFIG, Config } from './constants'; 4 | 5 | const exists = (path: string): boolean => fs.existsSync(path); 6 | 7 | export const readConfig = (rootDir: string = process.cwd()): Config => { 8 | const jsConfigPath = path.resolve(rootDir, 'jest-rest.config.js'); 9 | const jsonConfigPath = path.resolve(rootDir, 'jest-rest.config.json'); 10 | const jsConfig = exists(jsConfigPath); 11 | const jsonConfig = exists(jsConfigPath); 12 | 13 | let localConfig = {}; 14 | 15 | if (jsConfig) { 16 | localConfig = require(jsConfigPath); 17 | } else if (jsonConfig) { 18 | localConfig = require(jsonConfigPath); 19 | } 20 | 21 | return { 22 | ...DEFAULT_CONFIG, 23 | ...localConfig, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/validate.test.ts: -------------------------------------------------------------------------------- 1 | import { validate } from './validate'; 2 | 3 | const schema = { 4 | properties: { 5 | foo: { type: 'string' }, 6 | bar: { type: 'number', maximum: 3 }, 7 | foobar: { type: 'string', enum: ['AAA'] }, 8 | }, 9 | required: ['foo'], 10 | }; 11 | 12 | describe('validate tests', () => { 13 | test('without errors', async () => { 14 | const data = { foo: 'a' }; 15 | const val = validate(data, schema); 16 | expect(val).toStrictEqual({ errorText: '', valid: true }); 17 | }); 18 | 19 | test('with errors', async () => { 20 | const data = { foo: 1 }; 21 | const val = validate(data, schema); 22 | expect(val).toStrictEqual({ errorText: 'data.foo should be string', valid: false }); 23 | }); 24 | 25 | test('when data is empty object', async () => { 26 | const data = {}; 27 | const val = validate(data, schema); 28 | expect(val).toStrictEqual({ errorText: "data should have required property 'foo'", valid: false }); 29 | }); 30 | 31 | test('with more than one error', async () => { 32 | const data = { foo: 101, bar: 'abc' }; 33 | const val = validate(data, schema); 34 | expect(val).toStrictEqual({ errorText: 'data.foo should be string, data.bar should be number', valid: false }); 35 | }); 36 | 37 | test('enum is valid', async () => { 38 | const data = { foo: 'a', bar: 1, foobar: 'AAA' }; 39 | const val = validate(data, schema); 40 | expect(val).toStrictEqual({ errorText: '', valid: true }); 41 | }); 42 | 43 | test('enum is invalid', async () => { 44 | const data = { foo: 'a', foobar: 'BBB' }; 45 | const val = validate(data, schema); 46 | expect(val).toStrictEqual({ 47 | errorText: "data.foobar should be equal to one of the allowed values ['AAA']", 48 | valid: false, 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { EnumParams } from 'ajv'; 2 | import { readConfig } from './utils'; 3 | 4 | const config = readConfig(); 5 | const ajvConfig = config.ajv; 6 | 7 | type ValidateType = { 8 | valid: boolean; 9 | errorText: string; 10 | }; 11 | 12 | export const validate = (data: Record, schema: Record): ValidateType => { 13 | const ajv = new Ajv({ $data: true, ...ajvConfig }); 14 | const valid = ajv.validate(schema, data); 15 | 16 | const ajvErrors = ajv.errors || []; 17 | let errorText = ''; 18 | for (const error of ajvErrors) { 19 | if (errorText !== '') { 20 | errorText += ', '; 21 | } 22 | 23 | errorText += `data${error.dataPath} ${error.message}`; 24 | 25 | const params = error.params as EnumParams; 26 | const allowedValues = (params || {}).allowedValues; 27 | if (allowedValues) { 28 | errorText += ` ['${allowedValues.join(', ')}']`; 29 | } 30 | } 31 | 32 | return { 33 | errorText, 34 | valid: !!valid, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /teardown.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | module.exports = require('./lib/global').teardown; 3 | -------------------------------------------------------------------------------- /testEnvironment.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/JestRestEnvironment'); 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./lib", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noImplicitThis": false 11 | }, 12 | "files": [ 13 | "types/global.d.ts" 14 | ], 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "**/*.test.ts", "coverage", "examples"] 17 | } -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { AxiosStatic } from 'axios'; 2 | 3 | interface JestRest { 4 | /** 5 | * Suspends test execution and gives you opportunity to see what's going on with test 6 | * - Jest is suspended (no timeout) 7 | * 8 | * ```ts 9 | * it('should put test in debug mode', async () => { 10 | * await jestRest.debug(); 11 | * }) 12 | * ``` 13 | */ 14 | debug: () => Promise; 15 | } 16 | 17 | declare global { 18 | const axios: AxiosStatic; 19 | const jestRest: JestRest; 20 | namespace jest { 21 | interface Matchers { 22 | toMatchSchema(schema: Record): R; 23 | } 24 | } 25 | } 26 | --------------------------------------------------------------------------------