Skip to content

Commit

Permalink
fix: correctly overload jest spyOn object when no prop is passed (#82)
Browse files Browse the repository at this point in the history
* fix: do not overwrite module properties with spies

* fix: only map on plain objects
  • Loading branch information
iamogbz authored Dec 19, 2020
1 parent 8526278 commit 39faaa3
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 52 deletions.
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@ mock.extend(jest);

jest.spy("src/example");

const spied = require("src/example");
const example = require("src/example");

jest.isMockProp(spied, "testing"); // true
jest.isMockMethod(spied.nested.test); // true
jest.isMockProp(spied.nested, "testing"); // true
// Check module object properties
jest.isMockProp(example, "testing"); // true
console.log(example.testing); // 123

// Respy on module object to get mockable
jest.spyOn(example).testing.mockValueOnce("789");
console.log(example.testing); // 789
console.log(example.testing); // 123

// Check module nested object properties
jest.isMockMethod(example.nested.test); // true
jest.isMockProp(example.nested, "testing"); // true
```

It keeps the same structure of the module but replaces all functions and properties with jest mocks.
Expand Down
94 changes: 72 additions & 22 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,89 @@
import { CreateSpyOn, ExtendJest, SpyOn } from "../typings/globals";
import { SpyOnProp } from "jest-mock-props";
import {
AnyObject,
CreateSpyOn,
ExtendJest,
MockObject,
Spy,
} from "../typings/globals";
import { mapObject } from "./utils";

const SpyMockProp = Symbol("__mock__");

type JestSpyInstance = {
spyOn: typeof jest.spyOn;
spyOnProp: SpyOnProp;
} & Partial<typeof jest>;

function isFunction<T>(o: T): boolean {
return typeof o === "function";
}

function isPlainObject<T>(o: T): o is T & AnyObject {
return typeof o == "object" && (o as AnyObject)?.constructor == Object;
}

export function spyOnProp<T>(
jestInstance: typeof jest,
jestInstance: JestSpyInstance,
object: T,
propName: keyof T,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any {
const propValue = object[propName];
const propType = typeof propValue;
if (propType === "function") {
// @ts-expect-error Jest does not play nice
return jestInstance.spyOn(object, propName);
if (isFunction(object[propName])) {
return jestInstance.spyOn(
object,
propName as jest.FunctionPropertyNames<Required<T>>,
);
}
if (propType === "object" && propValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return spyOnObject(jestInstance, propValue);
if (isPlainObject(object[propName])) {
return spyOnObject(jestInstance, object[propName]);
}
try {
return jestInstance.spyOnProp(object, propName);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e.message);
return propValue;
return object[propName];
}
}

export function spyOnObject<T>(jestInstance: typeof jest, o?: T) {
if (o === undefined || o === null) return o;
export function isMockObject<T>(
o?: T,
): o is T & { [SpyMockProp]: MockObject<T> } {
return !!o && Object.prototype.hasOwnProperty.call(o, SpyMockProp);
}

export function spyOnObject<T>(
jestInstance: JestSpyInstance,
o: T,
): MockObject<T> {
if (isMockObject(o)) return o[SpyMockProp];
return mapObject(o, ([k]) => spyOnProp<T>(jestInstance, o, k));
}

export function spyOnModule<T>(jestInstance: typeof jest, moduleName: string) {
const actualModule = jestInstance.requireActual<T>(moduleName);
return spyOnObject<T>(jestInstance, actualModule);
export function spyOnModule<T>(
jestInstance: typeof jest,
moduleName: string,
): T & { [SpyMockProp]?: MockObject<T> } {
const actual = jestInstance.requireActual<T>(moduleName);
return Object.assign(actual, {
[SpyMockProp]: spyOnObject<T>(jestInstance, actual),
});
}

function bind(jestInstance: typeof jest) {
const createSpyFromModule: CreateSpyOn = <T>(moduleName: string) => {
return spyOnModule<T>(jestInstance, moduleName);
};
const spy: SpyOn = (moduleName: string) => {
const spy: Spy = (moduleName: string) => {
jest.mock(moduleName, () => createSpyFromModule(moduleName));
};
return { createSpyFromModule, spy };
return {
createSpyFromModule,
genSpyFromModule: createSpyFromModule,
spy,
spyOnObject: <T>(o: T) => spyOnObject(jestInstance, o),
};
}

export const extend: ExtendJest = (jestInstance) => {
Expand All @@ -57,10 +97,20 @@ export const extend: ExtendJest = (jestInstance) => {
"spy on non function module properties.",
);
}
const bound = bind(jestInstance);
Object.assign(jestInstance, {
...bound,
genSpyFromModule: bound.createSpyFromModule,
const spyOn = jestInstance.spyOn;
const spyOnProp = jestInstance.spyOnProp;
Object.assign(jestInstance, bind(jestInstance), {
isMockObject,
spyOn: <T>(
object: T,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
propName: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
accessType: any,
) => {
if (!propName) return spyOnObject({ spyOn, spyOnProp }, object);
return spyOn(object, propName, accessType);
},
});
};

Expand Down
5 changes: 5 additions & 0 deletions tests/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

exports[`spies on object nested properties 1`] = `
Object {
"list": Array [
Object {
"propX": 2,
},
],
"nested": Object {
"propA": null,
"propB": true,
Expand Down
38 changes: 30 additions & 8 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
import "jest-mock-props";
import { extend, spyOnObject } from "src/index";
import * as utils from "src/utils";
import { extend, isMockObject, spyOnModule } from "src/index";

extend(jest);
jest.spyOn(utils, "mapObject");
afterEach(jest.clearAllMocks);

it("reuses existing spy when there is one", () => {
const moduleName = "tests/mocks/hello";
expect(jest.createSpyFromModule(moduleName)).toBe(
spyOnModule(jest, moduleName),
);
expect(utils.mapObject).toBeCalledTimes(1);
});

it("spies on methods called", () => {
jest.spy("tests/mocks/hello");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { say, shout } = require("./mocks/hello");
const hello = require("./mocks/hello");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sayHello = require("./mocks/index").default;

expect(isMockObject(hello)).toBe(true);
expect(hello.CALL).toEqual("Hello");
jest.spyOnObject(hello)?.CALL.mockValueOnce("Hi");
expect(sayHello("You")).toEqual("HI YOU!");
expect(sayHello("You")).toEqual("HELLO YOU!");
expect(say).toHaveBeenCalledTimes(1);
expect(shout).toHaveBeenCalledTimes(1);
((say as unknown) as jest.SpyInstance).mockImplementationOnce(
expect(hello.say).toHaveBeenCalledTimes(2);
expect(hello.shout).toHaveBeenCalledTimes(2);
((hello.say as unknown) as jest.SpyInstance).mockImplementationOnce(
() => "something",
);
expect(sayHello("Who")).toEqual("SOMETHING!");
((shout as unknown) as jest.SpyInstance).mockImplementationOnce((s: string) =>
s.toLocaleLowerCase(),
((hello.shout as unknown) as jest.SpyInstance).mockImplementationOnce(
(s: string) => s.toLocaleLowerCase(),
);
expect(sayHello("Gru")).toEqual("hello gru");
});
Expand All @@ -34,17 +49,24 @@ it("spies on object nested properties", () => {
propA: null,
propB: true,
},
list: [{ propX: 2 }],
};
const spied = spyOnObject(jest, obj);
const spied = jest.spyOn(obj);
if (!spied) fail("Expect spied to be defined and not null");
expect(obj).toMatchSnapshot();
expect(jest.isMockProp(obj, "prop1")).toBe(true);
expect(jest.isMockProp(obj, "prop2")).toBe(true);
expect(jest.isMockFunction(obj.propFn)).toBe(true);
expect(jest.isMockProp(obj, "list")).toBe(true);
expect(jest.isMockProp(obj, "nested")).toBe(false);
expect(jest.isMockProp(obj.nested, "propA")).toBe(true);
expect(jest.isMockProp(obj.nested, "propB")).toBe(true);

spied.list.mockValueOnce([]);
expect(obj.list).toHaveLength(0);
expect(obj.list).toHaveLength(1);

// @ts-expect-error allow assignment
spied.nested.propA.mockValueOnce(1);
expect(obj.nested.propA).toEqual(1);
expect(obj.nested.propA).toBeNull();
Expand Down
2 changes: 2 additions & 0 deletions tests/mocks/hello.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const CALL = "Hello";

export function say(what: string, who: string): string {
return `${what} ${who}`;
}
Expand Down
4 changes: 2 additions & 2 deletions tests/mocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { say, shout } from "./hello";
import { CALL, say, shout } from "./hello";

export default function sayHello(who: string): string {
return shout(say("Hello", who));
return shout(say(CALL, who));
}
88 changes: 72 additions & 16 deletions typings/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,91 @@
import "jest-mock-props";
import { MockProp } from "jest-mock-props";

export type Entry<ObjectType> = {
[K in keyof ObjectType]: [K, ObjectType[K]];
}[keyof ObjectType];
declare const SpyMockProp: unique symbol;

export type Entries<ObjectType> = Entry<ObjectType>[];
export type AnyObject = Record<string, unknown>;

export type Mapped<ObjectType, ResultType> = {
[K in keyof ObjectType]: ResultType;
export type Entry<T> = {
[K in keyof T]: [K, T[K]];
}[keyof T];

export type Entries<T> = Entry<T>[];

export type Mapped<T, U> = {
[K in keyof T]: U;
};

export type MapFn<ObjectType, ResultType> = (
value: Entry<ObjectType>,
export type MapFn<T, U> = (
value: Entry<T>,
index: number,
array: Entries<ObjectType>,
) => Mapped<ObjectType, ResultType>[keyof ObjectType];
array: Entries<T>,
) => Mapped<T, U>[keyof T];

export type Mock<ObjectType> = {
[K in keyof ObjectType]: Mock<ObjectType[K]>;
};
type NonMappableTypes =
| bigint
| boolean
| unknown[]
| null
| number
| string
| symbol
| undefined;
type NonMappablePropertyNames<T> = {
[K in keyof T]: T[K] extends NonMappableTypes ? K : never;
}[keyof T] &
string;

type ObjectPropertyNames<T> = Exclude<
keyof T,
| jest.FunctionPropertyNames<T>
| jest.ConstructorPropertyNames<T>
| NonMappablePropertyNames<T>
>;

export type MockObject<T> = {
[K in NonMappablePropertyNames<T>]: MockProp<T, K>;
} &
{
[K in ObjectPropertyNames<T>]: MockObject<T[K]>;
} &
{
[K in jest.FunctionPropertyNames<T>]: Required<T>[K] extends (
...args: never[]
) => unknown
? jest.SpyInstance<
ReturnType<Required<T>[K]>,
jest.ArgsType<Required<T>[K]>
>
: never;
} &
{
[K in jest.ConstructorPropertyNames<T>]: Required<T>[K] extends new (
...args: never[]
) => unknown
? jest.SpyInstance<
InstanceType<Required<T>[K]>,
jest.ConstructorArgsType<Required<T>[K]>
>
: never;
};

export type IsMockObject = <T>(
o: T,
) => o is T & { [SpyMockProp]?: MockObject<T> };

export type SpyOn = (moduleName: string) => void;
export type CreateSpyOn = <T = unknown, U = T>(
moduleName: string,
) => Mapped<T, U> | undefined;

export type Spy = (moduleName: string) => void;

declare global {
namespace jest {
const isMockObject: IsMockObject;
const createSpyFromModule: CreateSpyOn;
const genSpyFromModule: CreateSpyOn;
const spy: SpyOn;
const spy: Spy;
function spyOn<T>(object: T): MockObject<T>;
const spyOnObject: typeof spyOn;
}
}

Expand Down

0 comments on commit 39faaa3

Please sign in to comment.