Skip to content

Commit

Permalink
feat(testing/unstable): support for stubbing properties (#6128)
Browse files Browse the repository at this point in the history
Co-authored-by: Yoshiya Hinosawa <stibium121@gmail.com>
  • Loading branch information
IgorM867 and kt3k authored Oct 31, 2024
1 parent 2d9e212 commit 6cc4f9f
Show file tree
Hide file tree
Showing 5 changed files with 728 additions and 39 deletions.
43 changes: 43 additions & 0 deletions testing/_mock_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import type { Spy } from "./mock.ts";

/**
* Checks if a function is a spy.
*
* @typeParam Self The self type of the function.
* @typeParam Args The arguments type of the function.
* @typeParam Return The return type of the function.
* @param func The function to check
* @return `true` if the function is a spy, `false` otherwise.
*/
export function isSpy<Self, Args extends unknown[], Return>(
func: ((this: Self, ...args: Args) => Return) | unknown,
): func is Spy<Self, Args, Return> {
const spy = func as Spy<Self, Args, Return>;
return typeof spy === "function" &&
typeof spy.original === "function" &&
typeof spy.restored === "boolean" &&
typeof spy.restore === "function" &&
Array.isArray(spy.calls);
}

// deno-lint-ignore no-explicit-any
export const sessions: Set<Spy<any, any[], any>>[] = [];

// deno-lint-ignore no-explicit-any
function getSession(): Set<Spy<any, any[], any>> {
if (sessions.length === 0) sessions.push(new Set());
return sessions.at(-1)!;
}

// deno-lint-ignore no-explicit-any
export function registerMock(spy: Spy<any, any[], any>) {
const session = getSession();
session.add(spy);
}

// deno-lint-ignore no-explicit-any
export function unregisterMock(spy: Spy<any, any[], any>) {
const session = getSession();
session.delete(spy);
}
3 changes: 2 additions & 1 deletion testing/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"./snapshot": "./snapshot.ts",
"./time": "./time.ts",
"./types": "./types.ts",
"./unstable-types": "./unstable_types.ts"
"./unstable-types": "./unstable_types.ts",
"./unstable-stub": "./unstable_stub.ts"
}
}
44 changes: 6 additions & 38 deletions testing/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,12 @@ import { assertEquals } from "@std/assert/equals";
import { assertIsError } from "@std/assert/is-error";
import { assertRejects } from "@std/assert/rejects";
import { AssertionError } from "@std/assert/assertion-error";
import {
isSpy,
registerMock,
sessions,
unregisterMock,
} from "./_mock_utils.ts";

/**
* An error related to spying on a function or instance method.
Expand Down Expand Up @@ -444,44 +450,6 @@ function functionSpy<
return spy;
}

/**
* Checks if a function is a spy.
*
* @typeParam Self The self type of the function.
* @typeParam Args The arguments type of the function.
* @typeParam Return The return type of the function.
* @param func The function to check
* @return `true` if the function is a spy, `false` otherwise.
*/
function isSpy<Self, Args extends unknown[], Return>(
func: ((this: Self, ...args: Args) => Return) | unknown,
): func is Spy<Self, Args, Return> {
const spy = func as Spy<Self, Args, Return>;
return typeof spy === "function" &&
typeof spy.original === "function" &&
typeof spy.restored === "boolean" &&
typeof spy.restore === "function" &&
Array.isArray(spy.calls);
}

// deno-lint-ignore no-explicit-any
const sessions: Set<Spy<any, any[], any>>[] = [];
// deno-lint-ignore no-explicit-any
function getSession(): Set<Spy<any, any[], any>> {
if (sessions.length === 0) sessions.push(new Set());
return sessions.at(-1)!;
}
// deno-lint-ignore no-explicit-any
function registerMock(spy: Spy<any, any[], any>) {
const session = getSession();
session.add(spy);
}
// deno-lint-ignore no-explicit-any
function unregisterMock(spy: Spy<any, any[], any>) {
const session = getSession();
session.delete(spy);
}

/**
* Creates a session that tracks all mocks created before it's restored.
* If a callback is provided, it restores all mocks created within it.
Expand Down
283 changes: 283 additions & 0 deletions testing/unstable_stub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { isSpy, registerMock, unregisterMock } from "./_mock_utils.ts";
import {
type GetParametersFromProp,
type GetReturnFromProp,
type MethodSpy,
MockError,
type Spy,
spy,
type SpyCall,
} from "./mock.ts";

/** An instance method replacement that records all calls made to it. */
export interface Stub<
// deno-lint-ignore no-explicit-any
Self = any,
// deno-lint-ignore no-explicit-any
Args extends unknown[] = any[],
// deno-lint-ignore no-explicit-any
Return = any,
> extends MethodSpy<Self, Args, Return> {
/** The function that is used instead of the original. */
fake: (this: Self, ...args: Args) => Return;
}

/**
* Replaces an instance method with a Stub with empty implementation.
*
* @example Usage
* ```ts
* import { assertSpyCalls } from "@std/testing/mock";
* import { stub } from "@std/testing/unstable-stub";
*
* const obj = {
* method() {
* // some inconventient feature for testing
* },
* };
*
* const methodStub = stub(obj, "method");
*
* for (const _ of Array(5)) {
* obj.method();
* }
*
* assertSpyCalls(methodStub, 5);
* ```
*
* @typeParam Self The self type of the instance to replace a method of.
* @typeParam Prop The property of the instance to replace.
* @param self The instance to replace a method of.
* @param property The property of the instance to replace.
* @returns The stub function which replaced the original.
*/
export function stub<
Self,
Prop extends keyof Self,
>(
self: Self,
property: Prop,
): Stub<Self, GetParametersFromProp<Self, Prop>, GetReturnFromProp<Self, Prop>>;
/**
* Replaces an instance method with a Stub with the given implementation.
*
* @example Usage
* ```ts
* import { stub } from "@std/testing/unstable-stub";
* import { assertEquals } from "@std/assert";
*
* const obj = {
* method(): number {
* return Math.random();
* },
* };
*
* const methodStub = stub(obj, "method", () => 0.5);
*
* assertEquals(obj.method(), 0.5);
* ```
*
* @typeParam Self The self type of the instance to replace a method of.
* @typeParam Prop The property of the instance to replace.
* @param self The instance to replace a method of.
* @param property The property of the instance to replace.
* @param func The fake implementation of the function.
* @returns The stub function which replaced the original.
*/
export function stub<
Self,
Prop extends keyof Self,
>(
self: Self,
property: Prop,
func: (
this: Self,
...args: GetParametersFromProp<Self, Prop>
) => GetReturnFromProp<Self, Prop>,
): Stub<Self, GetParametersFromProp<Self, Prop>, GetReturnFromProp<Self, Prop>>;
/**
* Replaces an instance property setter or getter with a Stub with the given implementation.
*
* @example Usage
* ```ts
* import { assertSpyCalls } from "@std/testing/mock";
* import { stub } from "@std/testing/unstable-stub";
* import { assertEquals } from "@std/assert";
*
* const obj = {
* prop: "foo",
* };
*
* const getterStub = stub(obj, "prop", {
* get: function () {
* return "bar";
* },
* });
*
* assertEquals(obj.prop, "bar");
* assertSpyCalls(getterStub.get, 1);
* ```
*
* @typeParam Self The self type of the instance to replace a method of.
* @typeParam Prop The property of the instance to replace.
* @param self The instance to replace a method of.
* @param property The property of the instance to replace.
* @param descriptor The javascript property descriptor with fake implementation of the getter and setter.
* @returns The stub with get and set properties which are spys of the setter and getter.
*/
export function stub<Self, Prop extends keyof Self>(
self: Self,
property: Prop,
descriptor: Omit<PropertyDescriptor, "configurable">,
):
& Stub<
Self,
GetParametersFromProp<Self, Prop>,
GetReturnFromProp<Self, Prop>
>
& {
get: Spy<
Self,
GetParametersFromProp<Self, Prop>,
GetReturnFromProp<Self, Prop>
>;
set: Spy<
Self,
GetParametersFromProp<Self, Prop>,
GetReturnFromProp<Self, Prop>
>;
};
export function stub<Self, Args extends unknown[], Return>(
self: Self,
property: keyof Self,
descriptorOrFunction?:
| ((this: Self, ...args: Args) => Return)
| Omit<PropertyDescriptor, "configurable">,
): Stub<Self, Args, Return> {
if (
self[property] !== undefined &&
typeof self[property] !== "function" &&
(descriptorOrFunction === undefined ||
typeof descriptorOrFunction === "function")
) {
throw new MockError("Cannot stub: property is not an instance method");
}
if (isSpy(self[property])) {
throw new MockError("Cannot stub: already spying on instance method");
}
if (
descriptorOrFunction !== undefined &&
typeof descriptorOrFunction !== "function" &&
descriptorOrFunction.get === undefined &&
descriptorOrFunction.set === undefined
) {
throw new MockError(
"Cannot stub: neither setter nor getter is defined",
);
}

const propertyDescriptor = Object.getOwnPropertyDescriptor(self, property);
if (propertyDescriptor && !propertyDescriptor.configurable) {
throw new MockError("Cannot stub: non-configurable instance method");
}
const fake =
descriptorOrFunction && typeof descriptorOrFunction === "function"
? descriptorOrFunction
: ((() => {}) as (this: Self, ...args: Args) => Return);

const original = self[property] as unknown as (
this: Self,
...args: Args
) => Return;
const calls: SpyCall<Self, Args, Return>[] = [];
let restored = false;
const stub = function (this: Self, ...args: Args): Return {
const call: SpyCall<Self, Args, Return> = { args };
if (this) call.self = this;
try {
call.returned = fake.apply(this, args);
} catch (error) {
call.error = error as Error;
calls.push(call);
throw error;
}
calls.push(call);
return call.returned;
} as Stub<Self, Args, Return>;
Object.defineProperties(stub, {
original: {
enumerable: true,
value: original,
},
fake: {
enumerable: true,
value: fake,
},
calls: {
enumerable: true,
value: calls,
},
restored: {
enumerable: true,
get: () => restored,
},
restore: {
enumerable: true,
value: () => {
if (restored) {
throw new MockError(
"Cannot restore: instance method already restored",
);
}
if (propertyDescriptor) {
Object.defineProperty(self, property, propertyDescriptor);
} else {
delete self[property];
}
restored = true;
unregisterMock(stub);
},
},
[Symbol.dispose]: {
value: () => {
stub.restore();
},
},
});

if (descriptorOrFunction && typeof descriptorOrFunction !== "function") {
const getterSpy = descriptorOrFunction.get
? spy(descriptorOrFunction.get)
: undefined;
const setterSpy = descriptorOrFunction.set
? spy(descriptorOrFunction.set)
: undefined;

Object.defineProperty(self, property, {
configurable: true,
enumerable: propertyDescriptor?.enumerable ?? false,
get: getterSpy!,
set: setterSpy!,
});
Object.defineProperty(stub, "get", {
value: getterSpy,
enumerable: true,
});
Object.defineProperty(stub, "set", {
value: setterSpy,
enumerable: true,
});
} else {
Object.defineProperty(self, property, {
configurable: true,
enumerable: propertyDescriptor?.enumerable ?? false,
writable: propertyDescriptor?.writable ?? false,
value: stub,
});
}

registerMock(stub);
return stub;
}
Loading

0 comments on commit 6cc4f9f

Please sign in to comment.