├── .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 |
--------------------------------------------------------------------------------