diff --git a/.github/workflows/mockServerHelper.yml b/.github/workflows/mockServerHelper.yml new file mode 100644 index 000000000..5bc373ac1 --- /dev/null +++ b/.github/workflows/mockServerHelper.yml @@ -0,0 +1,34 @@ +name: Mock Server Tests + +on: + push: + branches: + - 3.x + pull_request: + branches: + - '**' + +env: + CI: true + # Force terminal colors. @see https://www.npmjs.com/package/colors + FORCE_COLOR: 1 + +jobs: + build: + + runs-on: ubuntu-22.04 + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: npm install + run: npm i --legacy-peer-deps + - name: run unit tests + run: npm run test:unit:mockServer diff --git a/docs/helpers/MockServer.md b/docs/helpers/MockServer.md new file mode 100644 index 000000000..25a42ca5a --- /dev/null +++ b/docs/helpers/MockServer.md @@ -0,0 +1,212 @@ +--- +permalink: /helpers/MockServer +editLink: false +sidebar: auto +title: MockServer +--- + + + +## MockServer + +MockServer + +The MockServer Helper in CodeceptJS empowers you to mock any server or service via HTTP or HTTPS, making it an excellent tool for simulating REST endpoints and other HTTP-based APIs. + + + +## Configuration + +This helper should be configured in codecept.conf.(js|ts) + +Type: [object][1] + +### Properties + +- `port` **[number][2]?** Mock server port +- `host` **[string][3]?** Mock server host +- `httpsOpts` **[object][1]?** key & cert values are the paths to .key and .crt files + + + +#### Examples + +You can seamlessly integrate MockServer with other helpers like REST or Playwright. Here's a configuration example inside the `codecept.conf.js` file: + +```javascript +{ + helpers: { + REST: {...}, + MockServer: { + // default mock server config + port: 9393, + host: '0.0.0.0', + httpsOpts: { + key: '', + cert: '', + }, + }, + } +} +``` + +#### Adding Interactions + +Interactions add behavior to the mock server. Use the `I.addInteractionToMockServer()` method to include interactions. It takes an interaction object as an argument, containing request and response details. + +```javascript +I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/hello' + }, + response: { + status: 200, + body: { + 'say': 'hello to mock server' + } + } +}); +``` + +#### Request Matching + +When a real request is sent to the mock server, it matches the received request with the interactions. If a match is found, it returns the specified response; otherwise, a 404 status code is returned. + +- Strong match on HTTP Method, Path, Query Params & JSON body. +- Loose match on Headers. + +##### Strong Match on Query Params + +You can send different responses based on query parameters: + +```javascript +I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/users', + queryParams: { + id: 1 + } + }, + response: { + status: 200, + body: 'user 1' + } +}); + +I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/users', + queryParams: { + id: 2 + } + }, + response: { + status: 200, + body: 'user 2' + } +}); +``` + +- GET to `/api/users?id=1` will return 'user 1'. +- GET to `/api/users?id=2` will return 'user 2'. +- For all other requests, it returns a 404 status code. + +##### Loose Match on Body + +When `strict` is set to false, it performs a loose match on query params and response body: + +```javascript +I.addInteractionToMockServer({ + strict: false, + request: { + method: 'POST', + path: '/api/users', + body: { + name: 'john' + } + }, + response: { + status: 200 + } +}); +``` + +- POST to `/api/users` with the body containing `name` as 'john' will return a 200 status code. +- POST to `/api/users` without the `name` property in the body will return a 404 status code. + +Happy testing with MockServer in CodeceptJS! 🚀 + +## Methods + +### Parameters + +- `passedConfig` + +### addInteractionToMockServer + +An interaction adds behavior to the mock server + +```js +I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/hello' + }, + response: { + status: 200, + body: { + 'say': 'hello to mock server' + } + } +}); +``` + +```js +// with query params +I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/hello', + queryParams: { + id: 2 + } + }, + response: { + status: 200, + body: { + 'say': 'hello to mock server' + } + } +}); +``` + +#### Parameters + +- `interaction` **(CodeceptJS.MockInteraction | [object][1])** add behavior to the mock server + +Returns **any** void + +### startMockServer + +Start the mock server + +#### Parameters + +- `port` **[number][2]?** start the mock server with given port + +Returns **any** void + +### stopMockServer + +Stop the mock server + +Returns **any** void + +[1]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object + +[2]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number + +[3]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String diff --git a/lib/helper/MockServer.js b/lib/helper/MockServer.js new file mode 100644 index 000000000..9cb748808 --- /dev/null +++ b/lib/helper/MockServer.js @@ -0,0 +1,221 @@ +const { mock, settings } = require('pactum'); + +/** + * ## Configuration + * + * This helper should be configured in codecept.conf.(js|ts) + * + * @typedef MockServerConfig + * @type {object} + * @prop {number} [port=9393] - Mock server port + * @prop {string} [host="0.0.0.0"] - Mock server host + * @prop {object} [httpsOpts] - key & cert values are the paths to .key and .crt files + */ +let config = { + port: 9393, + host: '0.0.0.0', + httpsOpts: { + key: '', + cert: '', + }, +}; + +/** + * MockServer + * + * The MockServer Helper in CodeceptJS empowers you to mock any server or service via HTTP or HTTPS, making it an excellent tool for simulating REST endpoints and other HTTP-based APIs. + * + * + * + * #### Examples + * + * You can seamlessly integrate MockServer with other helpers like REST or Playwright. Here's a configuration example inside the `codecept.conf.js` file: + * + * ```javascript + * { + * helpers: { + * REST: {...}, + * MockServer: { + * // default mock server config + * port: 9393, + * host: '0.0.0.0', + * httpsOpts: { + * key: '', + * cert: '', + * }, + * }, + * } + * } + * ``` + * + * #### Adding Interactions + * + * Interactions add behavior to the mock server. Use the `I.addInteractionToMockServer()` method to include interactions. It takes an interaction object as an argument, containing request and response details. + * + * ```javascript + * I.addInteractionToMockServer({ + * request: { + * method: 'GET', + * path: '/api/hello' + * }, + * response: { + * status: 200, + * body: { + * 'say': 'hello to mock server' + * } + * } + * }); + * ``` + * + * #### Request Matching + * + * When a real request is sent to the mock server, it matches the received request with the interactions. If a match is found, it returns the specified response; otherwise, a 404 status code is returned. + * + * - Strong match on HTTP Method, Path, Query Params & JSON body. + * - Loose match on Headers. + * + * ##### Strong Match on Query Params + * + * You can send different responses based on query parameters: + * + * ```javascript + * I.addInteractionToMockServer({ + * request: { + * method: 'GET', + * path: '/api/users', + * queryParams: { + * id: 1 + * } + * }, + * response: { + * status: 200, + * body: 'user 1' + * } + * }); + * + * I.addInteractionToMockServer({ + * request: { + * method: 'GET', + * path: '/api/users', + * queryParams: { + * id: 2 + * } + * }, + * response: { + * status: 200, + * body: 'user 2' + * } + * }); + * ``` + * + * - GET to `/api/users?id=1` will return 'user 1'. + * - GET to `/api/users?id=2` will return 'user 2'. + * - For all other requests, it returns a 404 status code. + * + * ##### Loose Match on Body + * + * When `strict` is set to false, it performs a loose match on query params and response body: + * + * ```javascript + * I.addInteractionToMockServer({ + * strict: false, + * request: { + * method: 'POST', + * path: '/api/users', + * body: { + * name: 'john' + * } + * }, + * response: { + * status: 200 + * } + * }); + * ``` + * + * - POST to `/api/users` with the body containing `name` as 'john' will return a 200 status code. + * - POST to `/api/users` without the `name` property in the body will return a 404 status code. + * + * Happy testing with MockServer in CodeceptJS! 🚀 + * + * ## Methods + */ +class MockServer { + constructor(passedConfig) { + settings.setLogLevel('SILENT'); + config = { ...passedConfig }; + if (global.debugMode) { + settings.setLogLevel('VERBOSE'); + } + } + + /** + * Start the mock server + * @param {number} [port] start the mock server with given port + * + * @returns void + */ + async startMockServer(port) { + const _config = { ...config }; + if (port) _config.port = port; + await mock.setDefaults(_config); + await mock.start(); + } + + /** + * Stop the mock server + * + * @returns void + * + */ + async stopMockServer() { + await mock.stop(); + } + + /** + * An interaction adds behavior to the mock server + * + * + * ```js + * I.addInteractionToMockServer({ + * request: { + * method: 'GET', + * path: '/api/hello' + * }, + * response: { + * status: 200, + * body: { + * 'say': 'hello to mock server' + * } + * } + * }); + * ``` + * ```js + * // with query params + * I.addInteractionToMockServer({ + * request: { + * method: 'GET', + * path: '/api/hello', + * queryParams: { + * id: 2 + * } + * }, + * response: { + * status: 200, + * body: { + * 'say': 'hello to mock server' + * } + * } + * }); + * ``` + * + * @param {CodeceptJS.MockInteraction|object} interaction add behavior to the mock server + * + * @returns void + * + */ + async addInteractionToMockServer(interaction) { + await mock.addInteraction(interaction); + } +} + +module.exports = MockServer; diff --git a/package.json b/package.json index aee404555..4bef1bfe3 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "test:unit:webbapi:webDriver:devtools": "mocha test/helper/WebDriver_devtools_test.js --exit", "test:unit:webbapi:testCafe": "mocha test/helper/TestCafe_test.js", "test:unit:expect": "mocha test/helper/Expect_test.js", + "test:unit:mockServer": "mocha test/helper/MockServer_test.js", "test:plugin": "mocha test/plugin/plugin_test.js", "def": "./runok.js def", "dev:graphql": "node test/data/graphql/index.js", @@ -104,6 +105,7 @@ "ms": "2.1.3", "openai": "3.2.1", "ora-classic": "5.4.2", + "pactum": "3.6.0", "parse-function": "5.6.4", "parse5": "7.1.2", "promise-retry": "1.1.1", diff --git a/test/helper/MockServer_test.js b/test/helper/MockServer_test.js new file mode 100644 index 000000000..327a81c79 --- /dev/null +++ b/test/helper/MockServer_test.js @@ -0,0 +1,131 @@ +const path = require('path'); +const { expect } = require('chai'); +const { like } = require('pactum-matchers'); +const MockServer = require('../../lib/helper/MockServer'); +const REST = require('../../lib/helper/REST'); + +global.codeceptjs = require('../../lib'); + +let I; +let restClient; +const port = parseInt(Date.now().toString().slice(3, 8), 10); +const api_url = `http://0.0.0.0:${port}`; + +describe('MockServer Helper', function () { + this.timeout(3000); + this.retries(1); + + before(() => { + global.codecept_dir = path.join(__dirname, '/../data'); + + I = new MockServer({ port }); + restClient = new REST({ + endpoint: api_url, + defaultHeaders: { + 'X-Test': 'test', + }, + }); + }); + + beforeEach(async () => { + await I.startMockServer(); + }); + + afterEach(async () => { + await I.stopMockServer(); + }); + + describe('#startMockServer', () => { + it('should start the mock server with custom port', async () => { + global.debugMode = true; + await I.startMockServer(6789); + await I.stopMockServer(); + global.debugMode = undefined; + }); + }); + + describe('#addInteractionToMockServer', () => { + it('should return the correct response', async () => { + await I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/hello', + }, + response: { + status: 200, + body: { + say: 'hello to mock server', + }, + }, + }); + const res = await restClient.sendGetRequest('/api/hello'); + expect(res.data).to.eql({ say: 'hello to mock server' }); + }); + + it('should return 404 when not found route', async () => { + const res = await restClient.sendGetRequest('/api/notfound'); + expect(res.status).to.eql(404); + }); + + it('should return the strong Match on Query Params', async () => { + await I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/users', + queryParams: { + id: 1, + }, + }, + response: { + status: 200, + body: { + user: 1, + }, + }, + }); + + await I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/users', + queryParams: { + id: 2, + }, + }, + response: { + status: 200, + body: { + user: 2, + }, + }, + }); + let res = await restClient.sendGetRequest('/api/users?id=1'); + expect(res.data).to.eql({ user: 1 }); + res = await restClient.sendGetRequest('/api/users?id=2'); + expect(res.data).to.eql({ user: 2 }); + }); + + it('should check the stateful behavior', async () => { + await I.addInteractionToMockServer({ + request: { + method: 'GET', + path: '/api/projects/{id}', + pathParams: { + id: like('random-id'), + }, + }, + stores: { + ProjectId: 'req.pathParams.id', + }, + response: { + status: 200, + body: { + id: '$S{ProjectId}', + }, + }, + }); + const res = await restClient.sendGetRequest('/api/projects/10'); + expect(res.data).to.eql({ id: '10' }); + }); + }); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index b16cfbd79..6c8e32711 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -382,6 +382,22 @@ declare namespace CodeceptJS { [key: string]: any; }; + type MockRequest = { + method: 'GET'|'PUT'|'POST'|'PATCH'|'DELETE'|string; + path: string; + queryParams?: object; + } + + type MockResponse = { + status: number; + body?: object; + } + + type MockInteraction = { + request: MockRequest; + response: MockResponse; + } + interface PageScrollPosition { x: number; y: number; diff --git a/typings/tslint.json b/typings/tslint.json index 9cf38c96e..f4efd62d5 100644 --- a/typings/tslint.json +++ b/typings/tslint.json @@ -15,7 +15,8 @@ "trim-file": false, "no-consecutive-blank-lines": false, "no-redundant-jsdoc": false, - "adjacent-overload-signatures": false + "adjacent-overload-signatures": false, + "no-any-union": false }, "linterOptions": { "exclude": [