Repository: RobinTail/express-zod-api
Pull request #252 (2021-12-27 14:07:44)

Endpoint testing helper
```typescript
const { responseMock, loggerMock } = await testEndpoint({
  endpoint,
  requestProps: {
    method: "POST",
    body: {...},
  },
});
expect(loggerMock.error).toBeCalledTimes(0);
expect(responseMock.status).toBeCalledWith(200);
expect(responseMock.json).toBeCalledWith({
  status: "success",
  data: {...},
});
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c9c258de..03ddc728 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,38 @@
 
 ## Version 5
 
+### v5.1.0-beta2
+
+- Fixing a warning message when using `testEndpoint()` method.
+
+### v5.1.0-beta1
+
+- Feature #252: a helper method for testing your endpoints: `testEndpoint()`.
+  - Requires `jest` (and optionally `@types/jest`) to be installed.
+  - The method helps to mock the request, response, config and logger required to execute the endpoint.
+  - The method executes the endpoint and returns the created mocks.
+  - After that you only need to assert your expectations in the test.
+
+```typescript
+import { testEndpoint } from "express-zod-api";
+
+test("should respond successfully", async () => {
+  const { responseMock, loggerMock } = await testEndpoint({
+    endpoint: yourEndpoint,
+    requestProps: {
+      method: "POST",
+      body: { ... },
+    },
+  });
+  expect(loggerMock.error).toBeCalledTimes(0);
+  expect(responseMock.status).toBeCalledWith(200);
+  expect(responseMock.json).toBeCalledWith({
+    status: "success",
+    data: { ... },
+  });
+});
+```
+
 ### v5.0.0
 
 - No changes.
diff --git a/README.md b/README.md
index 348829fa..6f180e5d 100644
--- a/README.md
+++ b/README.md
@@ -41,8 +41,9 @@ Start your API server with I/O schema validation and custom middlewares in minut
    13. [Enabling HTTPS](#enabling-https)
    14. [Exporting endpoint types to frontend](#exporting-endpoint-types-to-frontend)
    15. [Creating a documentation](#creating-a-documentation)
-5. [Known issues](#known-issues)
-   1. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output)
+5. [Additional hints](#additional-hints)
+   1. [How to test endpoints](#how-to-test-endpoints)
+   2. [Excessive properties in endpoint output](#excessive-properties-in-endpoint-output)
 6. [Your input to my output](#your-input-to-my-output)
 
 You can find the release notes in [Changelog](CHANGELOG.md). Along with recommendations for migrating from
@@ -660,7 +661,37 @@ const exampleEndpoint = defaultEndpointsFactory.build({
 _See the example of the generated documentation
 [here](https://github.com/RobinTail/express-zod-api/blob/master/example/example.swagger.yaml)_
 
-# Known issues
+# Additional hints
+
+## How to test endpoints
+
+The way to test endpoints is to mock the request, response, and logger objects, invoke the `execute()` method, and
+assert the expectations for calls of certain mocked methods. The library provides a special method that makes mocking
+easier, it requires `jest` (and optionally `@types/jest`) to be installed, so the test might look the following way:
+
+```typescript
+import { testEndpoint } from "express-zod-api";
+
+test("should respond successfully", async () => {
+  const { responseMock, loggerMock } = await testEndpoint({
+    endpoint: yourEndpoint,
+    requestProps: {
+      method: "POST", // default: GET
+      body: { ... },
+    },
+    // responseProps, configProps, loggerProps
+  });
+  expect(loggerMock.error).toBeCalledTimes(0);
+  expect(responseMock.status).toBeCalledWith(200);
+  expect(responseMock.json).toBeCalledWith({
+    status: "success",
+    data: { ... },
+  });
+});
+```
+
+_This method is optimized for the standard result handler. With the flexibility to customize, you can add additional
+properties as needed._
 
 ## Excessive properties in endpoint output
 
diff --git a/example/example.swagger.yaml b/example/example.swagger.yaml
index f106649f..db8ee571 100644
--- a/example/example.swagger.yaml
+++ b/example/example.swagger.yaml
@@ -1,7 +1,7 @@
 openapi: 3.0.0
 info:
   title: Example API
-  version: 5.0.0
+  version: 5.1.0-beta2
 paths:
   /v1/user/retrieve:
     get:
diff --git a/package.json b/package.json
index bba8cdd5..334d5018 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "express-zod-api",
-  "version": "5.0.0",
+  "version": "5.1.0-beta2",
   "description": "A Typescript library to help you get an API server up and running with I/O schema validation and custom middlewares in minutes.",
   "license": "MIT",
   "scripts": {
@@ -44,6 +44,18 @@
     "winston": "3.3.3",
     "zod": "3.11.6"
   },
+  "peerDependencies": {
+    "@types/jest": "*",
+    "jest": ">=25 <28"
+  },
+  "peerDependenciesMeta": {
+    "jest": {
+      "optional": true
+    },
+    "@types/jest": {
+      "optional": true
+    }
+  },
   "devDependencies": {
     "@tsconfig/node12": "^1.0.9",
     "@types/express": "^4.17.13",
diff --git a/src/index.ts b/src/index.ts
index 0540f351..c219ce84 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -17,6 +17,7 @@ export { createServer, attachRouting } from "./server";
 export { OpenAPI } from "./open-api";
 export { OpenAPIError, DependsOnMethodError, RoutingError } from "./errors";
 export { withMeta } from "./metadata";
+export { testEndpoint } from "./mock";
 
 import * as z from "./extend-zod";
 import createHttpError from "http-errors";
diff --git a/src/mock.ts b/src/mock.ts
new file mode 100644
index 00000000..8a80811c
--- /dev/null
+++ b/src/mock.ts
@@ -0,0 +1,90 @@
+import { Request, Response } from "express";
+import http from "http";
+import { Logger } from "winston";
+import { CommonConfig } from "./config-type";
+import { AbstractEndpoint } from "./endpoint";
+import { mimeJson } from "./mime";
+
+interface TestEndpointProps<REQ, RES, LOG> {
+  endpoint: AbstractEndpoint;
+  requestProps?: REQ;
+  responseProps?: RES;
+  configProps?: Partial<CommonConfig>;
+  loggerProps?: LOG;
+  /** @deprecated for testing purposes only */
+  __noJest?: boolean;
+}
+
+/**
+ * @description You need to install Jest and probably @types/jest to use this method
+ */
+export const testEndpoint = async <
+  REQ extends Partial<Record<keyof Request, any>> | undefined = undefined,
+  RES extends Partial<Record<keyof Response, any>> | undefined = undefined,
+  LOG extends Partial<Record<keyof Logger, any>> | undefined = undefined
+>({
+  endpoint,
+  requestProps,
+  responseProps,
+  configProps,
+  loggerProps,
+  __noJest,
+}: TestEndpointProps<REQ, RES, LOG>) => {
+  if (!jest || __noJest) {
+    throw new Error("You need to install Jest in order to use testEndpoint().");
+  }
+  const requestMock = <
+    { method: string } & Record<"header", jest.Mock> &
+      (REQ extends undefined ? {} : REQ)
+  >{
+    method: "GET",
+    header: jest.fn(() => mimeJson),
+    ...requestProps,
+  };
+  const responseMock = <
+    {
+      writableEnded: boolean;
+      statusCode: number;
+      statusMessage: string;
+    } & Record<"set" | "status" | "json" | "end", jest.Mock> &
+      (RES extends undefined ? {} : RES)
+  >{
+    writableEnded: false,
+    statusCode: 200,
+    statusMessage: http.STATUS_CODES[200],
+    set: jest.fn(() => responseMock),
+    status: jest.fn((code: number) => {
+      responseMock.statusCode = code;
+      responseMock.statusMessage = http.STATUS_CODES[code];
+      return responseMock;
+    }),
+    json: jest.fn(() => responseMock),
+    end: jest.fn(() => {
+      responseMock.writableEnded = true;
+      return responseMock;
+    }),
+    ...responseProps,
+  };
+  const loggerMock = <
+    Record<"info" | "warn" | "error" | "debug", jest.Mock> &
+      (LOG extends undefined ? {} : LOG)
+  >{
+    info: jest.fn(),
+    warn: jest.fn(),
+    error: jest.fn(),
+    debug: jest.fn(),
+    ...loggerProps,
+  };
+  const configMock = {
+    cors: false,
+    logger: loggerMock,
+    ...configProps,
+  };
+  await endpoint.execute({
+    request: requestMock as unknown as Request,
+    response: responseMock as unknown as Response,
+    config: configMock as CommonConfig,
+    logger: loggerMock as unknown as Logger,
+  });
+  return { requestMock, responseMock, loggerMock };
+};


Test patch:
diff --git a/tests/unit/endpoint.spec.ts b/tests/unit/endpoint.spec.ts
index 3079f064..22f515f5 100644
--- a/tests/unit/endpoint.spec.ts
+++ b/tests/unit/endpoint.spec.ts
@@ -1,4 +1,3 @@
-import http from "http";
 import { expectType } from "tsd";
 import {
   z,
@@ -11,25 +10,13 @@ import {
   defaultEndpointsFactory,
   createResultHandler,
   createApiResponse,
+  testEndpoint,
 } from "../../src";
-import { CommonConfig } from "../../src/config-type";
 import { Endpoint } from "../../src/endpoint";
-import { Request, Response } from "express";
 import { mimeJson } from "../../src/mime";
 import { serializeSchemaForTest } from "../helpers";
 
-let loggerMock: any;
-
 describe("Endpoint", () => {
-  beforeEach(() => {
-    loggerMock = {
-      info: jest.fn(),
-      warn: jest.fn(),
-      error: jest.fn(),
-      debug: jest.fn(),
-    };
-  });
-
   describe(".getMethods()", () => {
     test("Should return the correct set of methods", () => {
       const endpointMock = new Endpoint({
@@ -107,32 +94,16 @@ describe("Endpoint", () => {
         }),
         handler: handlerMock,
       });
-      const requestMock = {
-        method: "POST",
-        header: jest.fn(() => mimeJson),
-        body: {
-          n: 453,
+      const { requestMock, responseMock, loggerMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: { n: 453 },
         },
-      };
-      const responseMock: Record<string, jest.Mock> = {
-        set: jest.fn().mockImplementation(() => responseMock),
-        status: jest.fn().mockImplementation(() => responseMock),
-        json: jest.fn().mockImplementation(() => responseMock),
-      };
-      const configMock = {
-        cors: true,
-      };
-      await endpoint.execute({
-        request: requestMock as unknown as Request,
-        response: responseMock as unknown as Response,
-        config: configMock as CommonConfig,
-        logger: loggerMock,
       });
       expect(middlewareMock).toBeCalledTimes(1);
       expect(middlewareMock).toBeCalledWith({
-        input: {
-          n: 453,
-        },
+        input: { n: 453 },
         options: {
           inc: 454, // due to reassignment of options
         },
@@ -142,12 +113,8 @@ describe("Endpoint", () => {
       });
       expect(handlerMock).toBeCalledTimes(1);
       expect(handlerMock).toBeCalledWith({
-        input: {
-          n: 453,
-        },
-        options: {
-          inc: 454,
-        },
+        input: { n: 453 },
+        options: { inc: 454 },
         logger: loggerMock,
       });
       expect(loggerMock.error).toBeCalledTimes(0);
@@ -170,24 +137,14 @@ describe("Endpoint", () => {
         output: z.object({}),
         handler: handlerMock,
       });
-      const requestMock = {
-        method: "OPTIONS",
-        header: jest.fn(() => mimeJson),
-      };
-      const responseMock: Record<string, jest.Mock> = {
-        end: jest.fn(),
-        set: jest.fn().mockImplementation(() => responseMock),
-        status: jest.fn().mockImplementation(() => responseMock),
-        json: jest.fn().mockImplementation(() => responseMock),
-      };
-      const configMock = {
-        cors: true,
-      };
-      await endpoint.execute({
-        request: requestMock as unknown as Request,
-        response: responseMock as unknown as Response,
-        config: configMock as CommonConfig,
-        logger: loggerMock,
+      const { responseMock, loggerMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "OPTIONS",
+        },
+        configProps: {
+          cors: true,
+        },
       });
       expect(loggerMock.error).toBeCalledTimes(0);
       expect(responseMock.status).toBeCalledTimes(0);
@@ -225,24 +182,8 @@ describe("Endpoint", () => {
           test: 123,
         }),
       });
-      const requestMock = {
-        method: "GET",
-        header: jest.fn(() => mimeJson),
-        body: {},
-      };
-      const responseMock: Record<string, jest.Mock> = {
-        set: jest.fn().mockImplementation(() => responseMock),
-        status: jest.fn().mockImplementation(() => responseMock),
-        json: jest.fn().mockImplementation(() => responseMock),
-      };
-      const configMock = {
-        cors: true,
-      };
-      await endpoint.execute({
-        request: requestMock as unknown as Request,
-        response: responseMock as unknown as Response,
-        config: configMock as CommonConfig,
-        logger: loggerMock,
+      const { responseMock, loggerMock } = await testEndpoint({
+        endpoint,
       });
       expect(loggerMock.error).toBeCalledTimes(1);
       expect(responseMock.status).toBeCalledWith(500);
@@ -279,27 +220,12 @@ describe("Endpoint", () => {
         output: z.object({}),
         handler: handlerMock,
       });
-      const configMock = {
-        cors: true,
-      };
-      const requestMock = {
-        method: "POST",
-        header: jest.fn(() => mimeJson),
-        body: {
-          n: 453,
+      const { responseMock, loggerMock } = await testEndpoint({
+        endpoint,
+        requestProps: {
+          method: "POST",
+          body: { n: 453 },
         },
-      };
-      const responseMock: any = new http.ServerResponse(
-        requestMock as unknown as Request
-      );
-      responseMock.set = jest.fn().mockImplementation(() => responseMock);
-      responseMock.status = jest.fn().mockImplementation(() => responseMock);
-      responseMock.json = jest.fn().mockImplementation(() => responseMock);
-      await endpoint.execute({
-        request: requestMock as unknown as Request,
-        response: responseMock as unknown as Response,
-        config: configMock as CommonConfig,
-        logger: loggerMock,
       });
       expect(handlerMock).toHaveBeenCalledTimes(0);
       expect(middlewareMock).toHaveBeenCalledTimes(1);
@@ -335,25 +261,7 @@ describe("Endpoint", () => {
         }),
         handler: async () => ({ test: "OK" }),
       });
-      const requestMock = {
-        method: "GET",
-        header: jest.fn(() => mimeJson),
-      };
-      const responseMock: Record<string, jest.Mock> = {
-        end: jest.fn(),
-        set: jest.fn().mockImplementation(() => responseMock),
-        status: jest.fn().mockImplementation(() => responseMock),
-        json: jest.fn().mockImplementation(() => responseMock),
-      };
-      const configMock = {
-        cors: true,
-      };
-      await endpoint.execute({
-        request: requestMock as unknown as Request,
-        response: responseMock as unknown as Response,
-        config: configMock as CommonConfig,
-        logger: loggerMock,
-      });
+      const { loggerMock, responseMock } = await testEndpoint({ endpoint });
       expect(loggerMock.error).toBeCalledTimes(1);
       expect(loggerMock.error.mock.calls[0][0]).toBe(
         "Result handler failure: Something unexpected happened."
diff --git a/tests/unit/mock.spec.ts b/tests/unit/mock.spec.ts
new file mode 100644
index 00000000..f4eca52b
--- /dev/null
+++ b/tests/unit/mock.spec.ts
@@ -0,0 +1,25 @@
+import { defaultEndpointsFactory, z, testEndpoint } from "../../src";
+
+describe("Mock", () => {
+  describe("testEndpoint()", () => {
+    test("Should throw an error in case Jest is not installed", async () => {
+      const endpoint = defaultEndpointsFactory.build({
+        method: "get",
+        input: z.object({}),
+        output: z.object({}),
+        handler: async () => ({}),
+      });
+      try {
+        await testEndpoint({ endpoint, __noJest: true });
+        fail("Should not be here");
+      } catch (e) {
+        expect(e).toBeInstanceOf(Error);
+        if (e instanceof Error) {
+          expect(e.message).toBe(
+            "You need to install Jest in order to use testEndpoint()."
+          );
+        }
+      }
+    });
+  });
+});