From 6cc4f9fc44223c7ddf59fa2f590313d45604c7a3 Mon Sep 17 00:00:00 2001 From: IgorM867 <116740171+IgorM867@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:54:32 +0100 Subject: [PATCH] feat(testing/unstable): support for stubbing properties (#6128) Co-authored-by: Yoshiya Hinosawa --- testing/_mock_utils.ts | 43 ++++ testing/deno.json | 3 +- testing/mock.ts | 44 +--- testing/unstable_stub.ts | 283 ++++++++++++++++++++++++ testing/unstable_stub_test.ts | 394 ++++++++++++++++++++++++++++++++++ 5 files changed, 728 insertions(+), 39 deletions(-) create mode 100644 testing/_mock_utils.ts create mode 100644 testing/unstable_stub.ts create mode 100644 testing/unstable_stub_test.ts diff --git a/testing/_mock_utils.ts b/testing/_mock_utils.ts new file mode 100644 index 000000000..427601b6f --- /dev/null +++ b/testing/_mock_utils.ts @@ -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( + func: ((this: Self, ...args: Args) => Return) | unknown, +): func is Spy { + const spy = func as Spy; + 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>[] = []; + +// deno-lint-ignore no-explicit-any +function getSession(): Set> { + if (sessions.length === 0) sessions.push(new Set()); + return sessions.at(-1)!; +} + +// deno-lint-ignore no-explicit-any +export function registerMock(spy: Spy) { + const session = getSession(); + session.add(spy); +} + +// deno-lint-ignore no-explicit-any +export function unregisterMock(spy: Spy) { + const session = getSession(); + session.delete(spy); +} diff --git a/testing/deno.json b/testing/deno.json index 21c2dbee1..28134dddc 100644 --- a/testing/deno.json +++ b/testing/deno.json @@ -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" } } diff --git a/testing/mock.ts b/testing/mock.ts index 468223068..5ca677af3 100644 --- a/testing/mock.ts +++ b/testing/mock.ts @@ -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. @@ -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( - func: ((this: Self, ...args: Args) => Return) | unknown, -): func is Spy { - const spy = func as Spy; - 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>[] = []; -// deno-lint-ignore no-explicit-any -function getSession(): Set> { - if (sessions.length === 0) sessions.push(new Set()); - return sessions.at(-1)!; -} -// deno-lint-ignore no-explicit-any -function registerMock(spy: Spy) { - const session = getSession(); - session.add(spy); -} -// deno-lint-ignore no-explicit-any -function unregisterMock(spy: Spy) { - 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. diff --git a/testing/unstable_stub.ts b/testing/unstable_stub.ts new file mode 100644 index 000000000..2f503d66f --- /dev/null +++ b/testing/unstable_stub.ts @@ -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 { + /** 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, GetReturnFromProp>; +/** + * 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 + ) => GetReturnFromProp, +): Stub, GetReturnFromProp>; +/** + * 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: Self, + property: Prop, + descriptor: Omit, +): + & Stub< + Self, + GetParametersFromProp, + GetReturnFromProp + > + & { + get: Spy< + Self, + GetParametersFromProp, + GetReturnFromProp + >; + set: Spy< + Self, + GetParametersFromProp, + GetReturnFromProp + >; + }; +export function stub( + self: Self, + property: keyof Self, + descriptorOrFunction?: + | ((this: Self, ...args: Args) => Return) + | Omit, +): Stub { + 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[] = []; + let restored = false; + const stub = function (this: Self, ...args: Args): Return { + const call: SpyCall = { 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; + 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; +} diff --git a/testing/unstable_stub_test.ts b/testing/unstable_stub_test.ts new file mode 100644 index 000000000..103be55da --- /dev/null +++ b/testing/unstable_stub_test.ts @@ -0,0 +1,394 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assertEquals, assertThrows } from "@std/assert"; +import { + assertSpyCall, + assertSpyCallArg, + assertSpyCalls, + MockError, + returnsNext, +} from "./mock.ts"; +import { Point, type PointWithExtra } from "./_test_utils.ts"; +import { type Stub, stub } from "./unstable_stub.ts"; + +Deno.test("stub()", () => { + const point = new Point(2, 3); + const func = stub(point, "action"); + + assertSpyCalls(func, 0); + + assertEquals(func.call(point), undefined); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), undefined); + assertSpyCall(func, 1, { + self: point, + args: [], + returned: undefined, + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +Deno.test("stub() works on function", () => { + const point = new Point(2, 3); + const returns = [1, "b", 2, "d"]; + const func = stub(point, "action", () => returns.shift()); + + assertSpyCalls(func, 0); + + assertEquals(func.call(point), 1); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: 1, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), "b"); + assertSpyCall(func, 1, { + self: point, + args: [], + returned: "b", + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); +Deno.test("stub() works on getter", () => { + const point = new Point(5, 6); + const returns = [1, 2, 3, 4]; + const func = stub(point, "x", { + get: function () { + return returns.shift(); + }, + }); + + assertSpyCalls(func.get, 0); + + assertEquals(point.x, 1); + assertSpyCall(func.get, 0, { + self: point, + args: [], + returned: 1, + }); + assertSpyCalls(func.get, 1); + + assertEquals(point.x, 2); + assertSpyCall(func.get, 1, { + self: point, + args: [], + returned: 2, + }); + assertSpyCalls(func.get, 2); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(point.x, 5); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); +Deno.test("stub() works on setter", () => { + const point = new Point(5, 6); + const func = stub(point, "x", { + set: function (value: number) { + point.y = value; + }, + }); + + assertSpyCalls(func.set, 0); + assertEquals(point.y, 6); + + point.x = 10; + + assertEquals(point.y, 10); + assertSpyCalls(func.set, 1); + assertSpyCallArg(func.set, 0, 0, 10); + + point.x = 15; + + assertEquals(point.y, 15); + assertSpyCalls(func.set, 2); + assertSpyCallArg(func.set, 1, 0, 15); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); +Deno.test("stub() works on getter and setter", () => { + const point = new Point(5, 6); + const returns = [1, 2, 3, 4]; + const func = stub(point, "x", { + get: function () { + return returns.shift(); + }, + set: function (value: number) { + point.y = value; + }, + }); + + assertSpyCalls(func.set, 0); + assertSpyCalls(func.get, 0); + assertEquals(point.y, 6); + assertEquals(point.x, 1); + + point.x = 10; + + assertEquals(point.y, 10); + assertSpyCalls(func.set, 1); + assertSpyCalls(func.get, 1); + assertSpyCallArg(func.set, 0, 0, 10); + assertSpyCall(func.get, 0, { + self: point, + args: [], + returned: 1, + }); + + point.x = 15; + + assertEquals(point.x, 2); + assertEquals(point.y, 15); + assertSpyCalls(func.set, 2); + assertSpyCalls(func.get, 2); + assertSpyCallArg(func.set, 1, 0, 15); + assertSpyCall(func.get, 1, { + self: point, + args: [], + returned: 2, + }); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(point.x, 5); + assertEquals(func.restored, true); +}); +Deno.test("stub() supports explicit resource management", () => { + const point = new Point(2, 3); + const returns = [1, "b", 2, "d"]; + let funcRef: Stub | null = null; + { + using func = stub(point, "action", () => returns.shift()); + funcRef = func; + + assertSpyCalls(func, 0); + + assertEquals(func.call(point), 1); + assertSpyCall(func, 0, { + self: point, + args: [], + returned: 1, + }); + assertSpyCalls(func, 1); + + assertEquals(point.action(), "b"); + assertSpyCall(func, 1, { + self: point, + args: [], + returned: "b", + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, Point.prototype.action); + assertEquals(point.action, func); + + assertEquals(func.restored, false); + } + if (funcRef) { + assertEquals(funcRef.restored, true); + assertEquals(point.action, Point.prototype.action); + assertThrows( + () => { + if (funcRef) funcRef.restore(); + }, + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(funcRef.restored, true); + } +}); +Deno.test("stub() handles non existent function", () => { + const point = new Point(2, 3); + const castPoint = point as PointWithExtra; + let i = 0; + const func = stub(castPoint, "nonExistent", () => { + i++; + return i; + }); + + assertSpyCalls(func, 0); + + assertEquals(func.call(castPoint), 1); + assertSpyCall(func, 0, { + self: castPoint, + args: [], + returned: 1, + }); + assertSpyCalls(func, 1); + + assertEquals(castPoint.nonExistent(), 2); + assertSpyCall(func, 1, { + self: castPoint, + args: [], + returned: 2, + }); + assertSpyCalls(func, 2); + + assertEquals(func.original, undefined); + assertEquals(castPoint.nonExistent, func); + + assertEquals(func.restored, false); + func.restore(); + assertEquals(func.restored, true); + assertEquals(castPoint.nonExistent, undefined); + assertThrows( + () => func.restore(), + MockError, + "Cannot restore: instance method already restored", + ); + assertEquals(func.restored, true); +}); + +// This doesn't test any runtime code, only if the TypeScript types are correct. +Deno.test("stub() correctly handles types", () => { + // @ts-expect-error Stubbing with incorrect argument types should cause a type error + stub(new Point(2, 3), "explicitTypes", (_x: string, _y: number) => true); + + // @ts-expect-error Stubbing with an incorrect return type should cause a type error + stub(new Point(2, 3), "explicitTypes", () => "string"); + + // Stubbing without argument types infers them from the real function + stub(new Point(2, 3), "explicitTypes", (_x, _y) => { + // `toExponential()` only exists on `number`, so this will error if _x is not a number + _x.toExponential(); + // `toLowerCase()` only exists on `string`, so this will error if _y is not a string + _y.toLowerCase(); + return true; + }); + + // Stubbing with returnsNext() should not give any type errors + stub(new Point(2, 3), "explicitTypes", returnsNext([true, false, true])); + + // Stubbing without argument types should not cause any type errors: + const point2 = new Point(2, 3); + const explicitTypesFunc = stub(point2, "explicitTypes", () => true); + + // Check if the returned type is correct: + assertThrows(() => { + assertSpyCall(explicitTypesFunc, 0, { + // @ts-expect-error Test if passing incorrect argument types causes an error + args: ["not a number", "string"], + // @ts-expect-error Test if passing incorrect return type causes an error + returned: "not a boolean", + }); + }); + + // Calling assertSpyCall with the correct types should not cause any type errors + point2.explicitTypes(1, "hello"); + assertSpyCall(explicitTypesFunc, 0, { + args: [1, "hello"], + returned: true, + }); +}); + +Deno.test("stub() works with throwing fake implementation", () => { + const obj = { + fn() { + throw new Error("failed"); + }, + }; + const stubFn = stub(obj, "fn", () => { + throw new Error("failed"); + }); + assertThrows(() => obj.fn(), Error, "failed"); + assertSpyCall(stubFn, 0, { + self: obj, + args: [], + error: { Class: Error, msgIncludes: "failed" }, + }); +}); + +Deno.test("stub() throws when the property is not a method", () => { + const obj = { fn: 1 }; + assertThrows( + // deno-lint-ignore no-explicit-any + () => stub(obj as any, "fn"), + MockError, + "Cannot stub: property is not an instance method", + ); +}); + +Deno.test("stub() throws when try stubbing already stubbed method", () => { + const obj = { fn() {} }; + stub(obj, "fn"); + assertThrows( + () => stub(obj, "fn"), + MockError, + "Cannot stub: already spying on instance method", + ); +}); + +Deno.test("stub() throws when neither setter not getter is defined", () => { + const obj = { prop: "foo" }; + + assertThrows( + () => stub(obj, "prop", {}), + MockError, + "Cannot stub: neither setter nor getter is defined", + ); +}); + +Deno.test("stub() throws then the property is not configurable", () => { + const obj = { fn() {} }; + Object.defineProperty(obj, "fn", { configurable: false }); + assertThrows( + () => stub(obj, "fn"), + MockError, + "Cannot stub: non-configurable instance method", + ); +});