Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Augmentation of mocking function type #1335

Merged
merged 17 commits into from
Nov 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
- Features:
- `winston` is now optional: supporting any logger having `debug()`, `warn()`, `info()` and `error()` methods;
- Introducing module augmentation approach for setting the type of chosen logger;
- Supporting both `jest` and `vitest` frameworks for `testEndpoint()`.
- Supporting different testing frameworks for `testEndpoint()`:
- Both `jest` and `vitest` are supported automatically;
- With most modern Node.js you can also use the integrated `node:test` module.
- How to migrate while maintaining previous functionality and behavior:
- If you're going to continue using `winston`:
- Near your `const config = createConfig(...)` add the module augmentation statement (see example below).
Expand All @@ -28,7 +30,8 @@
- If you can not use `await` (on the top level of CommonJS):
- Wrap your code with async IIFE or use `.then()` (see example below).
- If you're using `testEndpoint()` method:
- Specify either `fnMethod: jest.fn` or `fnMethod: vi.fn` within its object argument.
- Add module augmentation statement once anywhere in your tests (see below);
- When using testing framework other than `jest` or `vitest`, specify `fnMethod` property to `testEndpoint()`.

```typescript
import type { Logger } from "winston";
Expand All @@ -49,10 +52,19 @@ declare module "express-zod-api" {
// (async () => { await ... })();
const { app, httpServer } = await createServer(config, routing);

// Adjust your tests:
const { responseMock } = await testEndpoint({
// Adjust your tests: place it once anywhere
declare module "express-zod-api" {
interface MockOverrides extends jest.Mock {} // or Mock from vitest
}

// Both jest and vitest are supported automatically
const { responseMock } = await testEndpoint({ endpoint });

// For other testing frameworks: specify fnMethod property
import { mock } from "node:test";
await testEndpoint({
endpoint,
fnMethod: jest.fn, // or vi.fn from vitest, required
fnMethod: mock.fn.bind(mock), // https://nodejs.org/docs/latest-v20.x/api/test.html#mocking
});
```

Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1013,20 +1013,29 @@ const exampleEndpoint = taggedEndpointsFactory.build({
## 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 either `jest` (with `@types/jest`) or `vitest` to be installed, so the test might look this way:
assert the expectations for calls of certain mocked methods. The library provides a special method `testEndpoint` that
makes mocking easier. It requires your either to install `jest` (with `@types/jest`) or `vitest`
(detects automatically), or to specify the `fnMethod` property assigned with a function mocking method of your testing
framework, which can also be `node:test` module of most modern Node.js versions.
However, in order to have proper mocking types in your own tests, you also need to specify `MockOverrides` once in your
tests excplicitly, so the tests should look this way:

```typescript
import { testEndpoint } from "express-zod-api";

// place it once anywhere in your tests
declare module "express-zod-api" {
interface MockOverrides extends jest.Mock {} // or Mock from vitest
}

test("should respond successfully", async () => {
const { responseMock, loggerMock } = await testEndpoint({
fnMethod: jest.fn, // or vi.fn from vitest
endpoint: yourEndpoint,
requestProps: {
method: "POST", // default: GET
body: {}, // incoming data as if after parsing (JSON)
},
// fnMethod — for testing frameworks other than jest or vitest
// responseProps, configProps, loggerProps
});
expect(loggerMock.error).toHaveBeenCalledTimes(0);
Expand Down
2 changes: 1 addition & 1 deletion example/example.swagger.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.0.0
info:
title: Example API
version: 15.0.0-beta1
version: 15.0.0-beta4
paths:
/v1/user/retrieve:
get:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "express-zod-api",
"version": "15.0.0-beta1",
"version": "15.0.0-beta4",
"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": {
Expand Down
19 changes: 2 additions & 17 deletions src/common-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@ import { isHttpError } from "http-errors";
import { createHash } from "node:crypto";
import { z } from "zod";
import { CommonConfig, InputSource, InputSources } from "./config-type";
import {
InputValidationError,
MissingPeerError,
OutputValidationError,
} from "./errors";
import { InputValidationError, OutputValidationError } from "./errors";
import { ZodFile } from "./file-schema";
import { IOSchema } from "./io-schema";
import { AbstractLogger } from "./logger";
import { getMeta } from "./metadata";
import { AuxMethod, Method } from "./method";
import { mimeMultipart } from "./mime";
import { ZodUpload } from "./upload-schema";
import { AbstractLogger } from "./logger";

export type FlatObject = Record<string, unknown>;

Expand Down Expand Up @@ -323,14 +319,3 @@ export type ErrMessage = Exclude<
// the copy of the private Zod errorUtil.errToObj
export const errToObj = (message: ErrMessage | undefined) =>
typeof message === "string" ? { message } : message || {};

export const loadPeer = async <T>(
moduleName: string,
moduleExport: string = "default",
): Promise<T> => {
try {
return (await import(moduleName))[moduleExport];
} catch {
throw new MissingPeerError(moduleName);
}
};
9 changes: 7 additions & 2 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,14 @@ export class ResultHandlerError extends Error {

export class MissingPeerError extends Error {
public override name = "MissingPeerError";
constructor(module: string) {
constructor(module: string | string[]) {
const isArray = Array.isArray(module);
super(
`Missing peer dependency: '${module}'. Please install it to use the feature activated in config.`,
`Missing ${
isArray ? "one of the following peer dependencies" : "peer dependency"
}: ${
isArray ? module.join(" | ") : module
}. Please install it to use the feature.`,
);
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export {
MissingPeerError,
} from "./errors";
export { withMeta } from "./metadata";
export { testEndpoint } from "./testing";
export { testEndpoint, MockOverrides } from "./testing";
export { Integration } from "./integration";

export * as ez from "./proprietary-schemas";
Expand Down
2 changes: 1 addition & 1 deletion src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { inspect } from "node:util";
import type { Format, TransformableInfo } from "logform";
import type Winston from "winston";
import type Transport from "winston-transport";
import { loadPeer } from "./common-helpers";
import { loadPeer } from "./peer-helpers";

/**
* @desc Using module augmentation approach you can set the type of the actual logger used
Expand Down
35 changes: 35 additions & 0 deletions src/peer-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MissingPeerError } from "./errors";

export const loadPeer = async <T>(
moduleName: string,
moduleExport: string = "default",
): Promise<T> => {
try {
return (await import(moduleName))[moduleExport];
} catch {}
try {
return await Promise.resolve().then(
/**
* alternative way for environments that do not support dynamic imports even it's CJS compatible
* @example jest with ts-jest
* @link https://github.com/evanw/esbuild/issues/2651
*/
() => require(moduleName)[moduleExport],
);
} catch {}
throw new MissingPeerError(moduleName);
};

export const loadAlternativePeer = async <T>(
options: {
moduleName: string;
moduleExport?: string;
}[],
) => {
for (const { moduleName, moduleExport } of options) {
try {
return await loadPeer<T>(moduleName, moduleExport);
} catch {}
}
throw new MissingPeerError(options.map(({ moduleName }) => moduleName));
};
3 changes: 2 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
isSimplifiedWinstonConfig,
} from "./logger";
import { ResultHandlerError } from "./errors";
import { loadPeer, makeErrorFromAnything } from "./common-helpers";
import { makeErrorFromAnything } from "./common-helpers";
import { loadPeer } from "./peer-helpers";
import {
AnyResultHandlerDefinition,
defaultResultHandler,
Expand Down
57 changes: 30 additions & 27 deletions src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,32 @@ import { CommonConfig } from "./config-type";
import { AbstractEndpoint } from "./endpoint";
import { AbstractLogger } from "./logger";
import { mimeJson } from "./mime";
import { loadAlternativePeer } from "./peer-helpers";

type MockFunction = <S>(implementation?: (...args: any[]) => any) => S; // kept "any" for easier compatibility
export interface MockOverrides {}

export const makeRequestMock = <
REQ extends Record<string, any>,
FN extends MockFunction,
>({
interface MockFunction {
(implementation?: (...args: any[]) => any): MockOverrides;
}

export const makeRequestMock = <REQ extends Record<string, any>>({
fnMethod,
requestProps,
}: {
fnMethod: FN;
fnMethod: MockFunction;
requestProps?: REQ;
}) =>
({
method: "GET",
header: fnMethod(() => mimeJson),
...requestProps,
}) as { method: string } & Record<"header", ReturnType<FN>> & REQ;
}) as { method: string } & Record<"header", MockOverrides> & REQ;

export const makeResponseMock = <
RES extends Record<string, any>,
FN extends MockFunction,
>({
export const makeResponseMock = <RES extends Record<string, any>>({
fnMethod,
responseProps,
}: {
fnMethod: FN;
fnMethod: MockFunction;
responseProps?: RES;
}) => {
const responseMock = {
Expand Down Expand Up @@ -58,13 +57,13 @@ export const makeResponseMock = <
statusMessage: string;
} & Record<
"set" | "setHeader" | "header" | "status" | "json" | "send" | "end",
ReturnType<FN>
MockOverrides
> &
RES;
return responseMock;
};

interface TestEndpointProps<REQ, RES, LOG, FN> {
interface TestEndpointProps<REQ, RES, LOG> {
/** @desc The endpoint to test */
endpoint: AbstractEndpoint;
/**
Expand All @@ -88,19 +87,15 @@ interface TestEndpointProps<REQ, RES, LOG, FN> {
* */
loggerProps?: LOG;
/**
* @example jest.fn
* @example vi.fn
* @desc Optionally specify the function mocking method of your testing framework
* @default jest.fn || vi.fn // from vitest
* @example mock.fn.bind(mock) // from node:test, binding might be necessary
* */
fnMethod: FN;
fnMethod?: MockFunction;
}

/**
* @desc You need to install either jest (with @types/jest) or vitest in order to use this method
* @requires jest
* @requires vitest
* */
/** @desc Requires either jest (with @types/jest) or vitest or to specify the fnMethod option */
export const testEndpoint = async <
FN extends MockFunction,
LOG extends Record<string, any>,
REQ extends Record<string, any>,
RES extends Record<string, any>,
Expand All @@ -110,8 +105,16 @@ export const testEndpoint = async <
responseProps,
configProps,
loggerProps,
fnMethod,
}: TestEndpointProps<REQ, RES, LOG, FN>) => {
fnMethod: userDefined,
}: TestEndpointProps<REQ, RES, LOG>) => {
const fnMethod =
userDefined ||
(
await loadAlternativePeer<{ fn: MockFunction }>([
{ moduleName: "vitest", moduleExport: "vi" },
{ moduleName: "@jest/globals", moduleExport: "jest" },
])
).fn;
const requestMock = makeRequestMock({ fnMethod: fnMethod, requestProps });
const responseMock = makeResponseMock({ fnMethod: fnMethod, responseProps });
const loggerMock = {
Expand All @@ -120,7 +123,7 @@ export const testEndpoint = async <
error: fnMethod(),
debug: fnMethod(),
...loggerProps,
} as Record<keyof AbstractLogger, ReturnType<FN>> & LOG;
} as Record<keyof AbstractLogger, MockOverrides> & LOG;
const configMock = {
cors: false,
logger: loggerMock,
Expand All @@ -130,7 +133,7 @@ export const testEndpoint = async <
request: requestMock as unknown as Request,
response: responseMock as unknown as Response,
config: configMock as CommonConfig,
logger: loggerMock as AbstractLogger,
logger: loggerMock as unknown as AbstractLogger,
});
return { requestMock, responseMock, loggerMock };
};
1 change: 1 addition & 0 deletions tests/unit/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ exports[`Index Entrypoint exports should have certain entities exposed 1`] = `
"LoggerOverrides",
"Method",
"MissingPeerError",
"MockOverrides",
"OutputValidationError",
"Routing",
"RoutingError",
Expand Down
19 changes: 1 addition & 18 deletions tests/unit/common-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,9 @@ import {
hasTopLevelTransformingEffect,
isCustomHeader,
isValidDate,
loadPeer,
makeErrorFromAnything,
} from "../../src/common-helpers";
import {
InputValidationError,
MissingPeerError,
ez,
withMeta,
} from "../../src";
import { InputValidationError, ez, withMeta } from "../../src";
import { Request } from "express";
import { z } from "zod";
import { ZodUpload } from "../../src/upload-schema";
Expand Down Expand Up @@ -512,15 +506,4 @@ describe("Common Helpers", () => {
},
);
});

describe("loadPeer()", () => {
test("should load the module", async () => {
expect(await loadPeer("compression")).toBeTruthy();
});
test("should throw when module not found", async () => {
await expect(async () => loadPeer("missing")).rejects.toThrow(
new MissingPeerError("missing"),
);
});
});
});
Loading