From 165d38f20cb91280ec34f489ff002d96c645b281 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Thu, 21 Apr 2022 08:21:42 -0400 Subject: [PATCH 01/31] feat(spies): initial commit --- mod.ts | 16 ++ src/interfaces.ts | 12 +- src/spy/spy_builder.ts | 281 ++++++++++++++++++++++++++++++++ src/spy/spy_mixin.ts | 274 +++++++++++++++++++++++++++++++ src/types.ts | 3 + tests/deno/unit/mod/spy_test.ts | 114 +++++++++++++ 6 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 src/spy/spy_builder.ts create mode 100644 src/spy/spy_mixin.ts create mode 100644 tests/deno/unit/mod/spy_test.ts diff --git a/mod.ts b/mod.ts index 53715a7d..1d34ed2d 100644 --- a/mod.ts +++ b/mod.ts @@ -1,6 +1,8 @@ import type { Constructor, StubReturnValue } from "./src/types.ts"; import { MockBuilder } from "./src/mock/mock_builder.ts"; import { FakeBuilder } from "./src/fake/fake_builder.ts"; +import { SpyBuilder } from "./src/spy/spy_builder.ts"; +import * as Interfaces from "./src/interfaces.ts"; export * as Types from "./src/types.ts"; export * as Interfaces from "./src/interfaces.ts"; @@ -56,6 +58,20 @@ export function Mock(constructorFn: Constructor): MockBuilder { return new MockBuilder(constructorFn); } +export function Spy( + obj: Constructor +): Interfaces.ISpy & T; + +export function Spy( + obj: T, + dataMember?: keyof T +): unknown { + if (typeof obj === "function" && ("prototype" in obj)) { + // @ts-ignore + return new SpyBuilder(obj).create(); + } +} + /** * Create a stub function that returns "stubbed". */ diff --git a/src/interfaces.ts b/src/interfaces.ts index 9dff525f..1986babf 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import type { MethodCalls, MethodOf } from "./types.ts"; +import type { MethodArguments, MethodCalls, MethodOf } from "./types.ts"; export interface IMethodExpectation { toBeCalled(expectedCalls: number): void; @@ -88,6 +88,16 @@ export interface IMock { verifyExpectations(): void; } +export interface ISpy { + calls: MethodCalls; + calls_arguments: MethodArguments; + is_spy: boolean; + + verify( + methodName: MethodOf, + ): IMethodVerification; +} + export interface ITestDouble { init( original: OriginalObject, diff --git a/src/spy/spy_builder.ts b/src/spy/spy_builder.ts new file mode 100644 index 00000000..2ca37989 --- /dev/null +++ b/src/spy/spy_builder.ts @@ -0,0 +1,281 @@ +import type { Constructor } from "../types.ts"; +import type { ISpy, ITestDouble } from "../interfaces.ts"; +import { createSpy } from "./spy_mixin.ts"; +import { PreProgrammedMethod } from "../pre_programmed_method.ts"; + +/** + * Builder to help build a spy object. This does all of the heavy-lifting to + * create a spy object. Its `create()` method returns an instance of `Spy`, + * which is basically an original object with stubbed data members. + */ +export class SpyBuilder { + /** + * The class object passed into the constructor + */ + #constructor_fn: Constructor; + + /** + * A list of arguments the class constructor takes + */ + #constructor_args: unknown[] = []; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Construct an object of this class. + * + * @param constructorFn - The constructor function of the object to spy. + */ + constructor(constructorFn: Constructor) { + this.#constructor_fn = constructorFn; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Create the spy object. + * + * @returns The original object with capabilities from the Spy class. + */ + public create(): ClassToSpy { + const original = new this.#constructor_fn(...this.#constructor_args); + + const spy = createSpy, ClassToSpy>( + this.#constructor_fn, + ); + (spy as ISpy & ITestDouble).init( + original, + this.#getAllFunctionNames(original), + ); + + // Attach all of the original's properties to the spy + this.#getAllPropertyNames(original).forEach((property: string) => { + this.#addOriginalObjectPropertyToSpyObject( + original, + spy, + property, + ); + }); + + // Attach all of the original's functions to the spy + this.#getAllFunctionNames(original).forEach((method: string) => { + this.#addOriginalObjectMethodToSpyObject( + original, + spy, + method, + ); + }); + + return spy as ClassToSpy & ISpy; + } + + /** + * Before constructing the spy object, track any constructor function args + * that need to be passed in when constructing the spy object. + * + * @param args - A rest parameter of arguments that will get passed in to the + * constructor function of the object being spyed. + * + * @returns `this` so that methods in this class can be chained. + */ + public withConstructorArgs(...args: unknown[]): this { + this.#constructor_args = args; + return this; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Add an original object's method to a spy object without doing anything + * else. + * + * @param original - The original object containing the method to spy. + * @param spy - The spy object receiving the method to spy. + * @param method - The name of the method to spy -- callable via + * `spy[method](...)`. + */ + #addMethodToSpyObject( + original: ClassToSpy, + spy: ISpy, + method: string, + ): void { + Object.defineProperty(spy, method, { + value: original[method as keyof ClassToSpy], + }); + } + + /** + * Add an original object's method to a spy object -- determining whether the + * method should or should not be trackable. + * + * @param original - The original object containing the method to add. + * @param spy - The spy object receiving the method. + * @param method - The name of the method to spy -- callable via + * `spy[method](...)`. + */ + #addOriginalObjectMethodToSpyObject( + original: ClassToSpy, + spy: ISpy, + method: string, + ): void { + const nativeMethods = [ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + ]; + + // If this is a native method, then do not do anything fancy. Just add it to + // the spy. + if (nativeMethods.indexOf(method as string) !== -1) { + return this.#addMethodToSpyObject( + original, + spy, + method, + ); + } + + // Otherwise, make the method trackable via `.calls` usage. + this.#addTrackableMethodToSpyObject( + original, + spy, + method as keyof ClassToSpy, + ); + } + + /** + * Add an original object's property to a spy object. + * + * @param original The original object containing the property. + * @param spy The spy object receiving the property. + * @param property The name of the property -- retrievable via + * `spy[property]`. + */ + #addOriginalObjectPropertyToSpyObject( + original: ClassToSpy, + spy: ISpy, + property: string, + ): void { + const desc = Object.getOwnPropertyDescriptor(original, property) ?? + Object.getOwnPropertyDescriptor( + this.#constructor_fn.prototype, + property, + ); + + // If we do not have a desc, then we have no idea what the value should be. + // Also, we have no idea what we are copying, so we should just not do it. + if (!desc) { + return; + } + + // Basic property (e.g., public test = "hello"). We do not handle get() and + // set() because those are handled by the spy mixin. + if (("value" in desc)) { + Object.defineProperty(spy, property, { + value: desc.value, + writable: true, + }); + } + } + + /** + * Add a trackable method to a spy object. A trackable method is one that can + * be verified using `spy.calls[someMethod]`. + * + * @param original - The original object containing the method to add. + * @param spy - The spy object receiving the method. + * @param method - The name of the method. + */ + #addTrackableMethodToSpyObject( + original: ClassToSpy, + spy: ISpy, + method: keyof ClassToSpy, + ): void { + Object.defineProperty(spy, method, { + value: (...args: unknown[]) => { + // Track that this method was called + spy.calls[method]++; + + // Track what arguments were passed in when this method was called + spy.calls_arguments[method] = args; + + // Make sure the method calls its original self + const methodToCall = + (original[method as keyof ClassToSpy] as unknown as ( + ...params: unknown[] + ) => unknown); + + // When method calls its original self, let the `this` context of the + // original be the spy. Reason being the spy has tracking and the + // original does not. + const bound = methodToCall.bind(spy); + + // Use `return` because the original function could return a value + return bound(...args); + }, + }); + } + + /** + * Get all properties from the original so they can be added to the spy. + * + * @param obj - The object that will be spyed. + * + * @returns An array of the object's properties. + */ + #getAllPropertyNames(obj: ClassToSpy): string[] { + let functions: string[] = []; + let clone = obj; + do { + functions = functions.concat(Object.getOwnPropertyNames(clone)); + } while ((clone = Object.getPrototypeOf(clone))); + + return functions.sort().filter( + function (e: string, i: number, arr: unknown[]) { + if ( + e != arr[i + 1] && typeof obj[e as keyof ClassToSpy] != "function" + ) { + return true; + } + }, + ); + } + + /** + * Get all functions from the original so they can be added to the spy. + * + * @param obj - The object that will be spyed. + * + * @returns An array of the object's functions. + */ + #getAllFunctionNames(obj: ClassToSpy): string[] { + let functions: string[] = []; + let clone = obj; + do { + functions = functions.concat(Object.getOwnPropertyNames(clone)); + } while ((clone = Object.getPrototypeOf(clone))); + + return functions.sort().filter( + function (e: string, i: number, arr: unknown[]) { + if ( + e != arr[i + 1] && typeof obj[e as keyof ClassToSpy] == "function" + ) { + return true; + } + }, + ); + } +} diff --git a/src/spy/spy_mixin.ts b/src/spy/spy_mixin.ts new file mode 100644 index 00000000..1a323f46 --- /dev/null +++ b/src/spy/spy_mixin.ts @@ -0,0 +1,274 @@ +import type { + Constructor, + MethodArguments, + MethodCalls, + MethodOf, +} from "../types.ts"; +import { PreProgrammedMethod } from "../pre_programmed_method.ts"; +import { Stub } from "../../mod.ts"; +import type { IMethodExpectation, ISpy } from "../interfaces.ts"; + +class SpyError extends Error {} + +class MethodVerification { + #method_name: MethodOf; + #calls: number; + #args: unknown[]; + + constructor( + methodName: MethodOf, + calls: number, + args: unknown[], + ) { + this.#method_name = methodName; + this.#calls = calls; + this.#args = args; + } + + /** + * Verify that this method was called. Optionally, verify that it was called a + * specific number of times. + * + * @param expectedCalls - The number of calls this method is expected to have + * received. + * + * @returns this To add chainable verification methods. + */ + public toBeCalled(expectedCalls?: number): this { + if (!expectedCalls) { + if (this.#calls <= 0) { + throw new SpyError( + `Method "${this.#method_name}" expected to be called, but received 0 calls.`, + ); + } + } + + if (this.#calls !== expectedCalls) { + throw new SpyError( + `Method "${this.#method_name}" expected ${expectedCalls} call(s), but received ${this.#calls} call(s).`, + ); + } + + return this; + } + + /** + * Verify that this method was called with the following arguments. + * + * @returns this To add chainable verification methods. + */ + public toBeCalledWith(...expectedArgs: unknown[]): this { + if (expectedArgs.length != this.#args.length) { + throw new SpyError( + `Method "${this.#method_name}" expected ${expectedArgs.length} arguments(s), but received ${this.#args.length} argument(s).` + + `\nActual args: [${this.#args.join(", ")}]` + + `\nExpected args: [${expectedArgs.join(", ")}]`, + ); + } + + expectedArgs.forEach((arg: unknown, index: number) => { + const parameterPosition = index + 1; + if (this.#args[index] !== arg) { + if (this.#comparingArrays(this.#args[index], arg)) { + const match = this.#compareArrays( + this.#args[index] as unknown[], + arg as unknown[], + ); + if (match) { + return; + } + } + + const actualArgType = this.#getArgType(this.#args[index]); + const expectedArgType = this.#getArgType(arg); + + throw new SpyError( + `Method "${this.#method_name}" expected ${arg}${expectedArgType} argument at parameter position ${parameterPosition}, but received ${ + this.#args[index] + }${actualArgType} argument.`, + ); + } + }); + + this.#args.every((arg: unknown) => { + return expectedArgs.indexOf(arg) >= 0; + }); + + return this; + } + + /** + * Get the arg type in string format for the given arg. + * + * @param arg - The arg to evaluate. + * + * @returns The arg type surrounded by brackets (e.g., ). + */ + #getArgType(arg: unknown): string { + if (arg && typeof arg === "object") { + if ("prototype" in arg) { + return "<" + Object.getPrototypeOf(arg) + ">"; + } + return ""; + } + + return "<" + typeof arg + ">"; + } + + /** + * Are we comparing arrays? + * + * @param obj1 - Object to evaluate if it is an array. + * @param obj2 - Object to evaluate if it is an array. + * + * @returns True if yes, false if no. + */ + #comparingArrays(obj1: unknown, obj2: unknown): boolean { + return Array.isArray(obj1) && Array.isArray(obj2); + } + + /** + * Check that the given arrays are exactly equal. + * + * @param a - The first array. + * @param b - The second array (which should match the first array). + * + * @returns True if the arrays match, false if not. + */ + #compareArrays(a: unknown[], b: unknown[]): boolean { + return a.length === b.length && a.every((val, index) => val === b[index]); + } +} + +/** + * Create a spy as an extension of an original object. + */ +export function createSpy( + OriginalClass: OriginalConstructor, +): ISpy { + const Original = OriginalClass as unknown as Constructor< + // deno-lint-ignore no-explicit-any + (...args: any[]) => any + >; + return new class SpyExtension extends Original { + /** + * Helper property to see that this is a mock object and not the original. + */ + is_spy = true; + + /** + * Property to track method calls. + */ + #calls!: MethodCalls; + + /** + * Property to track method calls. + */ + #calls_arguments!: MethodArguments; + + /** + * The original object that this class creates a mock of. + */ + #original!: OriginalObject; + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + get calls(): MethodCalls { + return this.#calls; + } + + get calls_arguments(): MethodArguments { + return this.#calls_arguments; + } + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + /** + * @param original - The original object to mock. + * @param methodsToTrack - The original object's method to make trackable. + */ + public init(original: OriginalObject, methodsToTrack: string[]) { + this.#original = original; + this.#calls = this.#constructCallsProperty(methodsToTrack); + this.#calls_arguments = this.#constructCallsArguments(methodsToTrack); + this.#stubMethods(methodsToTrack); + } + + /** + * Verify all expectations created in this mock. + */ + public verify( + methodName: MethodOf, + ): MethodVerification { + return new MethodVerification( + methodName, + this.#calls[methodName], + this.#calls_arguments[methodName], + ); + } + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE ///////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + /** + * Construct the calls property. Only construct it, do not set it. The + * constructor will set it. + * @param methodsToTrack - All of the methods on the original object to make + * trackable. + * @returns - Key-value object where the key is the method name and the + * value is the number of calls. All calls start at 0. + */ + #constructCallsProperty( + methodsToTrack: string[], + ): Record { + const calls: Partial> = {}; + + methodsToTrack.forEach((key: string) => { + calls[key as keyof OriginalObject] = 0; + }); + + return calls as Record; + } + + /** + * Construct the calls property. Only construct it, do not set it. The + * constructor will set it. + * @param methodsToTrack - All of the methods on the original object to make + * trackable. + * @returns - Key-value object where the key is the method name and the + * value is the number of calls. All calls start at 0. + */ + #constructCallsArguments( + methodsToTrack: string[], + ): Record { + const args: Partial> = {}; + + methodsToTrack.forEach((key: string) => { + args[key as keyof OriginalObject] = []; + }); + + return args as Record; + } + + /** + * Spies are stubs that also record some information on how they were + * called. + * Since they are stubs, we stub every single method and only track how the + * methods are called. + * @param methodsToTrack - All of the methods on the original object to make + * trackable. + */ + #stubMethods( + methodsToTrack: string[], + ): void { + methodsToTrack.forEach((key: string) => { + Stub(this.#original, key as keyof OriginalObject); + }); + } + }(); +} diff --git a/src/types.ts b/src/types.ts index 966cad97..c995f8ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,9 @@ export type MethodOf = { : never; }[keyof Object]; +export type SpyReturnValue = T extends Constructor ? () => R + : string; + export type StubReturnValue = T extends (...args: unknown[]) => unknown ? () => R : string; diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts new file mode 100644 index 00000000..df0abb2e --- /dev/null +++ b/tests/deno/unit/mod/spy_test.ts @@ -0,0 +1,114 @@ +import { Spy } from "../../../../mod.ts"; +import { assertEquals } from "../../../deps.ts"; + +class Resource { + public greeting = "hello"; + + public methodThatLogs() { + return "Resource is running!"; + } + + // This method will be stubbed to return "stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatGets() { + this.methodThatLogs(); + return "Do GET"; + } + + // This method will be stubbed to return "stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatPosts() { + this.methodThatLogs(); + return "Do POST"; + } +} + +class ResourceParameterized { + public greeting = "hello"; + + public methodThatLogs(message: string) { + return message; + } + + // This method will be stubbed to return "stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatGets(paramString1: string, paramString2: string) { + this.methodThatLogs("Handle GET"); + return "Do GET"; + } + + // This method will be stubbed to return "stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatPosts( + paramBool1: boolean, + paramBool2: boolean, + paramArray: string[], + ) { + this.methodThatLogs("Handle POSt"); + return "Do POST"; + } +} + +Deno.test("Spy()", async (t) => { + await t.step("can turn a class into a spy", () => { + const spy = Spy(Resource); + assertEquals(spy.is_spy, true); + }); + + await t.step("stubs all data members", () => { + const spy = Spy(Resource); + assertEquals(spy.methodThatLogs(), "stubbed"); + assertEquals(spy.methodThatGets(), "stubbed"); + assertEquals(spy.methodThatPosts(), "stubbed"); + }); + + await t.step("can verify calls for non-parameterized methods", () => { + const spy = Spy(Resource); + // Verify that `methodThatLogs` was called once (because we called it here) + spy.methodThatLogs(); + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + spy.methodThatGets(); + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + spy.methodThatPosts(); + spy.verify("methodThatLogs").toBeCalled(1); + }); + + await t.step("can verify calls for parameterized methods", () => { + const spy = Spy(ResourceParameterized); + // Verify that `methodThatLogs` was called once (because we called it here) + spy.methodThatLogs("hello"); + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWith("hello"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + spy.methodThatGets("hello", "world"); + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWith("hello"); + spy.verify("methodThatGets") + .toBeCalled(1) + .toBeCalledWith("hello", "world"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + spy.methodThatPosts(true, false, ["test"]); + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWith("hello"); + spy.verify("methodThatPosts") + .toBeCalled(1) + .toBeCalledWith(true, false, ["test"]); + }); +}); From 6e58b29781d21daf70a2e31db1b0cde4d6acb5e2 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Thu, 21 Apr 2022 08:28:06 -0400 Subject: [PATCH 02/31] tmp: spy on method --- mod.ts | 9 +++ tests/deno/unit/mod/spy_test.ts | 121 ++++++++++++++++++-------------- 2 files changed, 76 insertions(+), 54 deletions(-) diff --git a/mod.ts b/mod.ts index 1d34ed2d..20fbcae7 100644 --- a/mod.ts +++ b/mod.ts @@ -58,6 +58,11 @@ export function Mock(constructorFn: Constructor): MockBuilder { return new MockBuilder(constructorFn); } +export function Spy( + obj: T, + dataMember: keyof T +): StubReturnValue; + export function Spy( obj: Constructor ): Interfaces.ISpy & T; @@ -66,6 +71,10 @@ export function Spy( obj: T, dataMember?: keyof T ): unknown { + if (dataMember) { + return Stub(obj, dataMember); + } + if (typeof obj === "function" && ("prototype" in obj)) { // @ts-ignore return new SpyBuilder(obj).create(); diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts index df0abb2e..826cb019 100644 --- a/tests/deno/unit/mod/spy_test.ts +++ b/tests/deno/unit/mod/spy_test.ts @@ -54,61 +54,74 @@ class ResourceParameterized { } Deno.test("Spy()", async (t) => { - await t.step("can turn a class into a spy", () => { - const spy = Spy(Resource); - assertEquals(spy.is_spy, true); + await t.step("using Spy() on methods", async (t) => { + await t.step("can spy on a method", () => { + const resource = new Resource(); + + Spy(resource, "methodThatPosts"); + console.log(resource.methodThatPosts()); + console.log(resource.methodThatPosts()); + console.log(resource.methodThatPosts()); + }); }); - await t.step("stubs all data members", () => { - const spy = Spy(Resource); - assertEquals(spy.methodThatLogs(), "stubbed"); - assertEquals(spy.methodThatGets(), "stubbed"); - assertEquals(spy.methodThatPosts(), "stubbed"); - }); - - await t.step("can verify calls for non-parameterized methods", () => { - const spy = Spy(Resource); - // Verify that `methodThatLogs` was called once (because we called it here) - spy.methodThatLogs(); - spy.verify("methodThatLogs").toBeCalled(1); - - // Verify that `methodThatLogs()` was still only called once since - // `methodThatGets()` is stubbed - spy.methodThatGets(); - spy.verify("methodThatLogs").toBeCalled(1); - - // Verify that `methodThatLogs()` was still only called once since - // `methodThatPosts()` is stubbed - spy.methodThatPosts(); - spy.verify("methodThatLogs").toBeCalled(1); - }); - - await t.step("can verify calls for parameterized methods", () => { - const spy = Spy(ResourceParameterized); - // Verify that `methodThatLogs` was called once (because we called it here) - spy.methodThatLogs("hello"); - spy.verify("methodThatLogs") - .toBeCalled(1) - .toBeCalledWith("hello"); - - // Verify that `methodThatLogs()` was still only called once since - // `methodThatGets()` is stubbed - spy.methodThatGets("hello", "world"); - spy.verify("methodThatLogs") - .toBeCalled(1) - .toBeCalledWith("hello"); - spy.verify("methodThatGets") - .toBeCalled(1) - .toBeCalledWith("hello", "world"); - - // Verify that `methodThatLogs()` was still only called once since - // `methodThatPosts()` is stubbed - spy.methodThatPosts(true, false, ["test"]); - spy.verify("methodThatLogs") - .toBeCalled(1) - .toBeCalledWith("hello"); - spy.verify("methodThatPosts") - .toBeCalled(1) - .toBeCalledWith(true, false, ["test"]); + await t.step("using Spy() on classes", async (t) => { + await t.step("can turn a class into a spy", () => { + const spy = Spy(Resource); + assertEquals(spy.is_spy, true); + }); + + await t.step("stubs all data members", () => { + const spy = Spy(Resource); + assertEquals(spy.methodThatLogs(), "stubbed"); + assertEquals(spy.methodThatGets(), "stubbed"); + assertEquals(spy.methodThatPosts(), "stubbed"); + }); + + await t.step("can verify calls for non-parameterized methods", () => { + const spy = Spy(Resource); + // Verify that `methodThatLogs` was called once (because we called it here) + spy.methodThatLogs(); + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + spy.methodThatGets(); + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + spy.methodThatPosts(); + spy.verify("methodThatLogs").toBeCalled(1); + }); + + await t.step("can verify calls for parameterized methods", () => { + const spy = Spy(ResourceParameterized); + // Verify that `methodThatLogs` was called once (because we called it here) + spy.methodThatLogs("hello"); + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWith("hello"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + spy.methodThatGets("hello", "world"); + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWith("hello"); + spy.verify("methodThatGets") + .toBeCalled(1) + .toBeCalledWith("hello", "world"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + spy.methodThatPosts(true, false, ["test"]); + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWith("hello"); + spy.verify("methodThatPosts") + .toBeCalled(1) + .toBeCalledWith(true, false, ["test"]); + }); }); }); From ed6cb963adfd9ccf654330ff6472672b4afee992 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Thu, 21 Apr 2022 17:24:49 -0400 Subject: [PATCH 03/31] chore: add spies to esm build --- console/build_esm_lib.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/console/build_esm_lib.ts b/console/build_esm_lib.ts index d9c2bccf..828693c8 100644 --- a/console/build_esm_lib.ts +++ b/console/build_esm_lib.ts @@ -11,6 +11,8 @@ const filesToRewrite = [ "tmp/conversion_workspace/src/mock/mock_builder.ts", "tmp/conversion_workspace/src/mock/mock_mixin.ts", "tmp/conversion_workspace/src/pre_programmed_method.ts", + "tmp/conversion_workspace/src/spy/spy_builder.ts", + "tmp/conversion_workspace/src/spy/spy_mixin.ts", "tmp/conversion_workspace/src/test_double_builder.ts", "tmp/conversion_workspace/src/types.ts", ]; From 2f829e84658eb4264d85ba6944d2575827d2286c Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 23 Apr 2022 22:04:24 -0400 Subject: [PATCH 04/31] fix(errors): ignore toBeCalled in error stack trace --- src/errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/errors.ts b/src/errors.ts index bdc1e73d..10cc5edc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -59,6 +59,7 @@ export class MethodVerificationError extends RhumError { "deno:runtime", "method_verifier.ts", "_mixin.ts", + ".toBeCalled", ]; if (!this.stack) { From 7f02c2d6bea0cdb0bdc13c8af332a470026e6d1a Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 23 Apr 2022 22:08:14 -0400 Subject: [PATCH 05/31] feat(method_verifier): make expectedCalls optional in toBeCalled(), add toBeCalledWithArgs(), and toBeCalledWithoutArgs() --- src/errors.ts | 14 +++++++++-- src/interfaces.ts | 7 +++--- src/method_verifier.ts | 53 ++++++++++++++++++++++++++++++++++-------- src/mock/mock_mixin.ts | 13 ++++++++--- 4 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 10cc5edc..2a3157af 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -60,13 +60,15 @@ export class MethodVerificationError extends RhumError { "method_verifier.ts", "_mixin.ts", ".toBeCalled", + ".toBeCalledWithArgs", + ".toBeCalledWithoutArgs", ]; if (!this.stack) { return; } - const conciseStack = this.stack.split("\n").filter((line: string) => { + let conciseStackArray = this.stack.split("\n").filter((line: string) => { try { return ignoredLines.filter((ignoredLine: string) => { return line.includes(ignoredLine); @@ -77,7 +79,15 @@ export class MethodVerificationError extends RhumError { // trace. No biggie. } return false; - }).join("\n"); + }); + + // Sometimes, the error stack will contain the problematic file twice. We only care about showing the problematic file once in this "concise" stack. + // In order to check for this, we check to see if the array contains more than 2 values. The first value should be the MethodVerificationError message. The second value should be the first instance of the problematic file. Knowing this, we can slice the array to contain only the error message and the first instance of the problematic file. + if (conciseStackArray.length > 2) { + conciseStackArray = conciseStackArray.slice(0, 2); + } + + const conciseStack = conciseStackArray.join("\n"); const extractedFilenameWithLineAndColumnNumbers = conciseStack.match( /\/[a-zA-Z0-9\(\)\[\]_-\d.]+\.ts:\d+:\d+/, diff --git a/src/interfaces.ts b/src/interfaces.ts index 1986babf..400a50af 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -2,12 +2,13 @@ import type { MethodArguments, MethodCalls, MethodOf } from "./types.ts"; export interface IMethodExpectation { toBeCalled(expectedCalls: number): void; - // toBeCalledWith(...args: unknown[]): this; + // toBeCalledWithArgs(...args: unknown[]): this; } export interface IMethodVerification { - toBeCalled(expectedCalls: number): this; - toBeCalledWith(...args: unknown[]): this; + toBeCalled(expectedCalls?: number): this; + toBeCalledWithArgs(...args: unknown[]): this; + toBeCalledWithoutArgs(): this; } export interface IPreProgrammedMethod { diff --git a/src/method_verifier.ts b/src/method_verifier.ts index 450691b6..75082f3f 100644 --- a/src/method_verifier.ts +++ b/src/method_verifier.ts @@ -1,6 +1,11 @@ import type { MethodOf } from "./types.ts"; import { MethodVerificationError } from "./errors.ts"; +/** + * Test doubles use this class to verify that their methods were called, were + * called with a number of arguments, were called with specific types of + * arguments, and so on. + */ export class MethodVerifier { #method_name: MethodOf; @@ -25,23 +30,25 @@ export class MethodVerifier { */ public toBeCalled( actualCalls: number, - expectedCalls?: number, + expectedCalls: number, + codeLocation: string, ): void { - if (!expectedCalls) { + if (expectedCalls === -1) { if (actualCalls <= 0) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of calls.`, - `.method("${this.#method_name}").toBeCalled(${expectedCalls})`, + codeLocation, `Expected calls -> 1 (or more)`, - `Actual calls -> 0`, + `Actual calls -> 0`, ); } + return; } if (actualCalls !== expectedCalls) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of calls.`, - `.method("${this.#method_name}").toBeCalled(${expectedCalls})`, + codeLocation, `Expected calls -> ${expectedCalls}`, `Actual calls -> ${actualCalls}`, ); @@ -51,16 +58,24 @@ export class MethodVerifier { /** * Verify that the actual arguments match the expected arguments. */ - public toBeCalledWith( + public toBeCalledWithArgs( actualArgs: unknown[], expectedArgs: unknown[], + codeLocation: string, ): void { + const expectedArgsAsString = JSON.stringify(expectedArgs) + .slice(1, -1) + .replace(/,/g, ", "); + const actualArgsAsString = JSON.stringify(actualArgs) + .slice(1, -1) + .replace(/,/g, ", "); + if (expectedArgs.length != actualArgs.length) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of arguments.`, - `.method("${this.#method_name}").toBeCalledWith(...)`, - `Expected args -> [${expectedArgs.join(", ")}]`, - `Actual args -> [${actualArgs.join(", ")}]`, + codeLocation, + `Expected args -> [${expectedArgsAsString}]`, + `Actual args -> [${actualArgsAsString}]`, ); } @@ -82,7 +97,7 @@ export class MethodVerifier { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect argument type at parameter position ${parameterPosition}.`, - `.method("${this.#method_name}").toBeCalledWith(...)`, + `.method("${this.#method_name}").toBeCalledWithArgs(...)`, `Expected arg type -> ${arg}${expectedArgType}`, `Actual arg type -> ${actualArgs[index]}${actualArgType}`, ); @@ -94,6 +109,24 @@ export class MethodVerifier { }); } + public toBeCalledWithoutArgs( + actualArgs: unknown[], + codeLocation: string, + ): void { + const actualArgsAsString = JSON.stringify(actualArgs) + .slice(1, -1) + .replace(/,/g, ", "); + + if (actualArgs.length > 0) { + throw new MethodVerificationError( + `Method "${this.#method_name}" received incorrect number of arguments.`, + codeLocation, + `Expected args -> none`, + `Actual args -> [${actualArgsAsString}]`, + ); + } + } + /** * Get the arg type in string format for the given arg. * diff --git a/src/mock/mock_mixin.ts b/src/mock/mock_mixin.ts index efc1ec94..39dce3e2 100644 --- a/src/mock/mock_mixin.ts +++ b/src/mock/mock_mixin.ts @@ -24,15 +24,22 @@ class MethodExpectation { * * @param expectedCalls - The number of calls to receive. */ - public toBeCalled(expectedCalls: number): void { - this.#expected_calls = expectedCalls; + public toBeCalled(expectedCalls?: number): void { + this.#expected_calls = expectedCalls ?? -1; } /** * Verify all expected calls were made. */ public verifyCalls(actualCalls: number): void { - this.#verifier.toBeCalled(actualCalls, this.#expected_calls); + const expectedCalls = this.#expected_calls !== -1 + ? "" + : `${this.#expected_calls}`; + this.#verifier.toBeCalled( + actualCalls, + this.#expected_calls, + `.expects("${this.#method_name}").toBeCalled(${expectedCalls})`, + ); } } From 02ceb3f6480db60247db4a9b4a02e85c8740c8a7 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 23 Apr 2022 22:08:31 -0400 Subject: [PATCH 06/31] feat(errors): add SpyError --- src/errors.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/errors.ts b/src/errors.ts index 2a3157af..539a6ef1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -124,3 +124,12 @@ export class MockError extends RhumError { super("MockError", message); } } + +/** + * Error to throw in relation to spy logic. + */ +export class SpyError extends RhumError { + constructor(message: string) { + super("SpyError", message); + } +} From e3d344652b73587a2906ed20bcad79bba6b4ebcb Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 23 Apr 2022 22:08:54 -0400 Subject: [PATCH 07/31] chore(mod): add file marker comments to separate test double logic --- mod.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mod.ts b/mod.ts index 20fbcae7..b5db55b8 100644 --- a/mod.ts +++ b/mod.ts @@ -6,6 +6,10 @@ import * as Interfaces from "./src/interfaces.ts"; export * as Types from "./src/types.ts"; export * as Interfaces from "./src/interfaces.ts"; +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - DUMMY ///////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Create a dummy. * @@ -26,6 +30,10 @@ export function Dummy(constructorFn?: Constructor): T { return dummy; } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - FAKE ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Get the builder to create fake objects. * @@ -41,6 +49,10 @@ export function Fake(constructorFn: Constructor): FakeBuilder { return new FakeBuilder(constructorFn); } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - MOCK ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Get the builder to create mocked objects. * From 7a8efe4a8fbd4fd74c6ac60639a9a6cad193d4ef Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sun, 24 Apr 2022 09:49:51 -0400 Subject: [PATCH 08/31] refactor(spies): semantics; types; interfaces; conslidation --- mod.ts | 151 +++++++++++--- src/errors.ts | 8 +- src/fake/fake_mixin.ts | 6 +- src/interfaces.ts | 40 +++- src/method_verifier.ts | 101 +++++++--- src/mock/mock_mixin.ts | 67 ++++++- src/spy/spy_builder.ts | 254 +++-------------------- src/spy/spy_mixin.ts | 234 +++------------------- src/spy/spy_stub_builder.ts | 289 +++++++++++++++++++++++++++ src/test_double_builder.ts | 14 +- src/types.ts | 22 +- tests/deno/unit/mod/spy_test.ts | 344 +++++++++++++++++++++++++++++--- 12 files changed, 980 insertions(+), 550 deletions(-) create mode 100644 src/spy/spy_stub_builder.ts diff --git a/mod.ts b/mod.ts index b5db55b8..bc886472 100644 --- a/mod.ts +++ b/mod.ts @@ -1,7 +1,8 @@ -import type { Constructor, StubReturnValue } from "./src/types.ts"; +import type { Callable, Constructor, MethodOf, StubReturnValue } from "./src/types.ts"; import { MockBuilder } from "./src/mock/mock_builder.ts"; import { FakeBuilder } from "./src/fake/fake_builder.ts"; import { SpyBuilder } from "./src/spy/spy_builder.ts"; +import { SpyStubBuilder } from "./src/spy/spy_stub_builder.ts"; import * as Interfaces from "./src/interfaces.ts"; export * as Types from "./src/types.ts"; export * as Interfaces from "./src/interfaces.ts"; @@ -70,33 +71,125 @@ export function Mock(constructorFn: Constructor): MockBuilder { return new MockBuilder(constructorFn); } -export function Spy( - obj: T, - dataMember: keyof T -): StubReturnValue; +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - SPY /////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// -export function Spy( - obj: Constructor -): Interfaces.ISpy & T; +export function Spy( + fn: (...args: unknown[]) => ReturnValue, + returnValue?: ReturnValue +): Interfaces.ISpyStubFunctionExpression & Callable; -export function Spy( - obj: T, - dataMember?: keyof T +/** + * Create spy out of a class. Example: + * + * ```ts + * const spy = Spy(MyClass); + * const stubbedReturnValue = spy.someMethod(); // We called it, ... + * spy.verify("someMethod").toBeCalled(); // ... so we can verify it was called ... + * console.log(stubbedReturnValue === "stubbed"); // ... and that the return value is stubbed + * ``` + * + * @param constructorFn - The constructor function to create a spy out of. This + * can be `class Something{ }` or `function Something() { }`. + * + * @returns Instance of `Spy`, which is an extension of the o. + */ +export function Spy( + constructorFn: Constructor +): Interfaces.ISpy & OriginalClass; + +/** + * Create a spy out of an object's data member. Example: + * + * ```ts + * const testSubject = new MyClass(); + * const spyMethod = Spy(testSubject, "doSomething"); + * // or const spyMethod = Spy(testSubject, "doSomething", "some return value"); + * + * spyMethod.verify().toNotBeCalled(); // We can verify it was not called yet + * + * testSubject.doSomething(); // Now we called it, ... + * spyMethod.verify().toBeCalled(); // ... so we can verify it was called + * ``` + * + * @param obj - The object containing the data member to spy on. + * @param dataMember - The data member to spy on. + * @param returnValue - (Optional) Make the data member return a specific value. + * Defaults to "stubbed" if not specified. + * + * @returns A spy stub that can be verified. + */ +export function Spy( + obj: OriginalObject, + dataMember: MethodOf, + returnValue?: ReturnValue +): Interfaces.ISpyStub; + +/** + * Create a spy out of a class, class method, or function. + * + * Per Martin Fowler (based on Gerard Meszaros), "Spies are stubs that also + * record some information based on how they were called. One form of this might be an email service that records how many messages it was sent." + * + * @param obj - (Optional) The object receiving the stub. Defaults to a stub + * function. + * @param arg2 - (Optional) The data member on the object to be stubbed. + * Only used if `obj` is an object. + * @param arg3 - (Optional) What the stub should return. Defaults to + * "stubbed" for class properties and a function that returns "stubbed" for + * class methods. Only used if `object` is an object and `dataMember` is a + * member of that object. + */ +export function Spy( + obj: unknown, + arg2?: unknown, + arg3?: unknown ): unknown { - if (dataMember) { - return Stub(obj, dataMember); + if (typeof obj === "function") { + // If the function has the prototype field, the it's a constructor function. + // + // Examples: + // class Hello { } + // function Hello() { } + // + if ("prototype" in obj) { + return new SpyBuilder(obj as Constructor).create(); + } + + // Otherwise, it's just a function. + // + // Example: + // const hello = () => "world"; + // + // Not that function declarations (e.g., function hello() { }) will have + // "prototype" and will go through the SpyBuilder() flow above. + return new SpyStubBuilder(obj as OriginalObject) + .returnValue(arg2 as ReturnValue) + .createForFunctionExpression(); } - if (typeof obj === "function" && ("prototype" in obj)) { - // @ts-ignore - return new SpyBuilder(obj).create(); + // If we get here, then we are not spying on a class or function. We must be + // spying on an object's method. + if (arg2 !== undefined) { + return new SpyStubBuilder(obj as OriginalObject) + .method(arg2 as MethodOf) + .returnValue(arg3 as ReturnValue) + .createForObjectMethod(); } + + throw new Error(`Incorrect use of Spy().`); } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - STUB ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Create a stub function that returns "stubbed". */ -export function Stub(): () => "stubbed"; +export function Stub(): () => "stubbed"; + /** * Take the given object and stub its given data member to return the given * return value. @@ -106,11 +199,11 @@ export function Stub(): () => "stubbed"; * @param returnValue - (optional) What the stub should return. Defaults to * "stubbed". */ -export function Stub( - obj: T, - dataMember: keyof T, - returnValue?: R, -): StubReturnValue; +export function Stub( + obj: OriginalObject, + dataMember: keyof OriginalObject, + returnValue?: ReturnValue, +): StubReturnValue; /** * Take the given object and stub its given data member to return the given * return value. @@ -119,19 +212,19 @@ export function Stub( * to calls made during the test, usually not responding at all to anything * outside what's programmed in for the test." * - * @param obj - (optional) The object receiving the stub. Defaults to a stub + * @param obj - (Optional) The object receiving the stub. Defaults to a stub * function. - * @param dataMember - (optional) The data member on the object to be stubbed. + * @param dataMember - (Optional) The data member on the object to be stubbed. * Only used if `obj` is an object. - * @param returnValue - (optional) What the stub should return. Defaults to + * @param returnValue - (Optional) What the stub should return. Defaults to * "stubbed" for class properties and a function that returns "stubbed" for * class methods. Only used if `object` is an object and `dataMember` is a * member of that object. */ -export function Stub( - obj?: T, - dataMember?: keyof T, - returnValue?: R, +export function Stub( + obj?: OriginalObject, + dataMember?: keyof OriginalObject, + returnValue?: ReturnValue, ): unknown { if (obj === undefined) { return function stubbed() { diff --git a/src/errors.ts b/src/errors.ts index 539a6ef1..b987bce4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,7 +30,13 @@ export class MethodVerificationError extends RhumError { #expected_results: string; /** - * @param message - The error message. + * @param message - The error message (to be shown in the stack trace). + * @param codeThatThrew - An example of the code (or the exact code) that + * caused this error to be thrown. + * @param actualResults - A message stating the actual results (to be show in + * the stack trace). + * @param expectedResults - A message stating the expected results (to be + * shown in the stack trace). */ constructor( message: string, diff --git a/src/fake/fake_mixin.ts b/src/fake/fake_mixin.ts index d97d4ab6..981421cc 100644 --- a/src/fake/fake_mixin.ts +++ b/src/fake/fake_mixin.ts @@ -29,9 +29,9 @@ export function createFake( */ #original!: OriginalObject; - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// /** * @param original - The original object to fake. diff --git a/src/interfaces.ts b/src/interfaces.ts index 400a50af..8dc3cc19 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import type { MethodArguments, MethodCalls, MethodOf } from "./types.ts"; +import type { MethodCalls, MethodOf } from "./types.ts"; export interface IMethodExpectation { toBeCalled(expectedCalls: number): void; @@ -6,6 +6,17 @@ export interface IMethodExpectation { } export interface IMethodVerification { + /** + * Verify that this method was called. Optionally, verify that it was called a + * specific number of times. + * + * @param expectedCalls - (Optional) The number of calls this method is + * expected to have received. If not provided, then the verification process + * will assume "just verify that the method was called" instead of verifying + * that it was called a specific number of times. + * + * @returns `this` To allow method chaining. + */ toBeCalled(expectedCalls?: number): this; toBeCalledWithArgs(...args: unknown[]): this; toBeCalledWithoutArgs(): this; @@ -90,18 +101,41 @@ export interface IMock { } export interface ISpy { - calls: MethodCalls; - calls_arguments: MethodArguments; is_spy: boolean; + stubbed_methods: Record, ISpyStub>; verify( methodName: MethodOf, ): IMethodVerification; } +export interface ISpyStub { + /** + * Access the method verifier in order to call verification methods like `.toBeCalled()`. Example: + * + * @example + * ```ts + * // Spying on an object's method + * const spy = Spy(obj, "someMethod"); + * obj.someMethod(); + * spy.verify().toBeCalled(); + * + * // Spy on a function + * const spy = Spy(someFunction); + * someFunction(); + * spy.verify().toBeCalled(); + * ``` + */ + verify(): IMethodVerification; +} + export interface ITestDouble { init( original: OriginalObject, methodsToTrack: string[], ): void; } + +export interface ISpyStubFunctionExpression { + verify(): IMethodVerification; +} diff --git a/src/method_verifier.ts b/src/method_verifier.ts index 75082f3f..9c3445ee 100644 --- a/src/method_verifier.ts +++ b/src/method_verifier.ts @@ -7,37 +7,55 @@ import { MethodVerificationError } from "./errors.ts"; * arguments, and so on. */ export class MethodVerifier { - #method_name: MethodOf; + /** + * The name of the method using this class. This is only used for display in + * error stack traces if this class throws. + */ + #method_name: MethodOf | null; ////////////////////////////////////////////////////////////////////////////// // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// - constructor(methodName: MethodOf) { - this.#method_name = methodName; + /** + * @param methodName - See this#method_name. + */ + constructor(methodName?: MethodOf) { + this.#method_name = methodName ?? null; } ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get method_name(): MethodOf | null { + return this.#method_name; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// /** * Verify that the actual calls match the expected calls. * - * @param expectedCalls - (optional) The number of calls expected. If this is - * not specified, then this method checks that the actual calls is greater - * than one -- denoting that there was call. + * @param actualCalls - The number of actual calls. + * @param expectedCalls - The number of calls expected. If this is -1, then + * just verify that the method was called without checking how many times it + * was called. + * @param codeThatThrew - See `MethodVerificationError` constructor's + * `codeThatThrew` param. */ public toBeCalled( actualCalls: number, expectedCalls: number, - codeLocation: string, + codeThatThrew: string, ): void { if (expectedCalls === -1) { if (actualCalls <= 0) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of calls.`, - codeLocation, + codeThatThrew, `Expected calls -> 1 (or more)`, `Actual calls -> 0`, ); @@ -48,7 +66,7 @@ export class MethodVerifier { if (actualCalls !== expectedCalls) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of calls.`, - codeLocation, + codeThatThrew, `Expected calls -> ${expectedCalls}`, `Actual calls -> ${actualCalls}`, ); @@ -56,12 +74,17 @@ export class MethodVerifier { } /** - * Verify that the actual arguments match the expected arguments. + * Verify that this method was called with the given args. + * + * @param actualArgs - The actual args that this method was called with. + * @param expectedArgs - The args this method is expected to have received. + * @param codeThatThrew - See `MethodVerificationError` constructor's + * `codeThatThrew` param. */ public toBeCalledWithArgs( actualArgs: unknown[], expectedArgs: unknown[], - codeLocation: string, + codeThatThrew: string, ): void { const expectedArgsAsString = JSON.stringify(expectedArgs) .slice(1, -1) @@ -73,7 +96,7 @@ export class MethodVerifier { if (expectedArgs.length != actualArgs.length) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of arguments.`, - codeLocation, + codeThatThrew, `Expected args -> [${expectedArgsAsString}]`, `Actual args -> [${actualArgsAsString}]`, ); @@ -109,9 +132,17 @@ export class MethodVerifier { }); } + /** + * Verify that this method was called without arguments. + * + * @param actualArgs - The actual args that this method was called with. This + * method expects it to be an empty array. + * @param codeThatThrew - See `MethodVerificationError` constructor's + * `codeThatThrew` param. + */ public toBeCalledWithoutArgs( actualArgs: unknown[], - codeLocation: string, + codeThatThrew: string, ): void { const actualArgsAsString = JSON.stringify(actualArgs) .slice(1, -1) @@ -120,29 +151,27 @@ export class MethodVerifier { if (actualArgs.length > 0) { throw new MethodVerificationError( `Method "${this.#method_name}" received incorrect number of arguments.`, - codeLocation, + codeThatThrew, `Expected args -> none`, `Actual args -> [${actualArgsAsString}]`, ); } } + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + /** - * Get the arg type in string format for the given arg. + * Check that the given arrays are exactly equal. * - * @param arg - The arg to evaluate. + * @param a - The first array. + * @param b - The second array (which should match the first array). * - * @returns The arg type surrounded by brackets (e.g., ). + * @returns True if the arrays match, false if not. */ - #getArgType(arg: unknown): string { - if (arg && typeof arg === "object") { - if ("prototype" in arg) { - return "<" + Object.getPrototypeOf(arg) + ">"; - } - return ""; - } - - return "<" + typeof arg + ">"; + #compareArrays(a: unknown[], b: unknown[]): boolean { + return a.length === b.length && a.every((val, index) => val === b[index]); } /** @@ -158,14 +187,20 @@ export class MethodVerifier { } /** - * Check that the given arrays are exactly equal. + * Get the arg type in string format for the given arg. * - * @param a - The first array. - * @param b - The second array (which should match the first array). + * @param arg - The arg to evaluate. * - * @returns True if the arrays match, false if not. + * @returns The arg type surrounded by brackets (e.g., ). */ - #compareArrays(a: unknown[], b: unknown[]): boolean { - return a.length === b.length && a.every((val, index) => val === b[index]); + #getArgType(arg: unknown): string { + if (arg && typeof arg === "object") { + if ("prototype" in arg) { + return "<" + Object.getPrototypeOf(arg) + ">"; + } + return ""; + } + + return "<" + typeof arg + ">"; } } diff --git a/src/mock/mock_mixin.ts b/src/mock/mock_mixin.ts index 39dce3e2..c7b9e021 100644 --- a/src/mock/mock_mixin.ts +++ b/src/mock/mock_mixin.ts @@ -5,36 +5,83 @@ import { MethodVerifier } from "../method_verifier.ts"; import { MockError } from "../errors.ts"; import { PreProgrammedMethod } from "../pre_programmed_method.ts"; +/** + * Class to help mocks create method expectations. + */ class MethodExpectation { + /** + * Property to hold the number of expected calls this method should receive. + */ #expected_calls = 0; + + /** + * See `MethodVerifier#method_name`. + */ #method_name: MethodOf; + + /** + * The verifier to use when verifying expectations. + */ #verifier: MethodVerifier; + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param methodName See `MethodVerifier#method_name`. + */ constructor(methodName: MethodOf) { this.#method_name = methodName; this.#verifier = new MethodVerifier(methodName); } + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + get method_name(): MethodOf { return this.#method_name; } + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + /** * Set an expected number of calls. * - * @param expectedCalls - The number of calls to receive. + * @param expectedCalls - (Optional) The number of calls to receive. Defaults + * to -1 to tell `MethodVerifier` to "just check that the method was called". */ public toBeCalled(expectedCalls?: number): void { this.#expected_calls = expectedCalls ?? -1; } + // public verify(actualCalls: number, actualArgs: unknown[]): void { + // this.verifyCalls( + // } + /** * Verify all expected calls were made. + * + * @param actualCalls - The number of actual calls. */ public verifyCalls(actualCalls: number): void { + // If the expected calls is -1, then we do not show anything in the third + // argument to `this.#verifier.toBeCalled()`. Reason being we want to show + // this in the error stack trace ... + // + // .expects("someMethod").toBeCalled() + // + // ... and not this ... + // + // .expects("someMethod").toBeCalled(-1) + // const expectedCalls = this.#expected_calls !== -1 ? "" : `${this.#expected_calls}`; + this.#verifier.toBeCalled( actualCalls, this.#expected_calls, @@ -78,17 +125,17 @@ export function createMock( */ #original!: OriginalObject; - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// get calls(): MethodCalls { return this.#calls; } - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// /** * @param original - The original object to mock. @@ -152,9 +199,9 @@ export function createMock( }); } - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE ///////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// /** * Construct the calls property. Only construct it, do not set it. The diff --git a/src/spy/spy_builder.ts b/src/spy/spy_builder.ts index 2ca37989..8421364a 100644 --- a/src/spy/spy_builder.ts +++ b/src/spy/spy_builder.ts @@ -1,37 +1,20 @@ import type { Constructor } from "../types.ts"; import type { ISpy, ITestDouble } from "../interfaces.ts"; import { createSpy } from "./spy_mixin.ts"; -import { PreProgrammedMethod } from "../pre_programmed_method.ts"; +import { TestDoubleBuilder } from "../test_double_builder.ts"; /** * Builder to help build a spy object. This does all of the heavy-lifting to * create a spy object. Its `create()` method returns an instance of `Spy`, * which is basically an original object with stubbed data members. + * + * This builder differs from the `SpyStub` because it stubs out the + * entire class, whereas the `SpyStub` stubs specific data members. + * + * Under the hood, this builder uses `SpyStub` to stub the data members + * in the class. */ -export class SpyBuilder { - /** - * The class object passed into the constructor - */ - #constructor_fn: Constructor; - - /** - * A list of arguments the class constructor takes - */ - #constructor_args: unknown[] = []; - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Construct an object of this class. - * - * @param constructorFn - The constructor function of the object to spy. - */ - constructor(constructorFn: Constructor) { - this.#constructor_fn = constructorFn; - } - +export class SpyBuilder extends TestDoubleBuilder { ////////////////////////////////////////////////////////////////////////////// // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// @@ -42,240 +25,55 @@ export class SpyBuilder { * @returns The original object with capabilities from the Spy class. */ public create(): ClassToSpy { - const original = new this.#constructor_fn(...this.#constructor_args); + const original = new this.constructor_fn(...this.constructor_args); const spy = createSpy, ClassToSpy>( - this.#constructor_fn, + this.constructor_fn, ); + (spy as ISpy & ITestDouble).init( original, - this.#getAllFunctionNames(original), + this.getAllFunctionNames(original), ); // Attach all of the original's properties to the spy - this.#getAllPropertyNames(original).forEach((property: string) => { - this.#addOriginalObjectPropertyToSpyObject( - original, - spy, - property, - ); - }); + this.addOriginalProperties>(original, spy); - // Attach all of the original's functions to the spy - this.#getAllFunctionNames(original).forEach((method: string) => { - this.#addOriginalObjectMethodToSpyObject( - original, - spy, - method, - ); - }); + // Attach all of the original's native methods to the spy + this.#addNativeMethods(original, spy); return spy as ClassToSpy & ISpy; } - /** - * Before constructing the spy object, track any constructor function args - * that need to be passed in when constructing the spy object. - * - * @param args - A rest parameter of arguments that will get passed in to the - * constructor function of the object being spyed. - * - * @returns `this` so that methods in this class can be chained. - */ - public withConstructorArgs(...args: unknown[]): this { - this.#constructor_args = args; - return this; - } - ////////////////////////////////////////////////////////////////////////////// // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// - /** - * Add an original object's method to a spy object without doing anything - * else. - * - * @param original - The original object containing the method to spy. - * @param spy - The spy object receiving the method to spy. - * @param method - The name of the method to spy -- callable via - * `spy[method](...)`. - */ - #addMethodToSpyObject( - original: ClassToSpy, - spy: ISpy, - method: string, - ): void { - Object.defineProperty(spy, method, { - value: original[method as keyof ClassToSpy], - }); - } - /** * Add an original object's method to a spy object -- determining whether the * method should or should not be trackable. * * @param original - The original object containing the method to add. * @param spy - The spy object receiving the method. - * @param method - The name of the method to spy -- callable via - * `spy[method](...)`. */ - #addOriginalObjectMethodToSpyObject( + #addNativeMethods( original: ClassToSpy, spy: ISpy, - method: string, ): void { - const nativeMethods = [ - "__defineGetter__", - "__defineSetter__", - "__lookupGetter__", - "__lookupSetter__", - "constructor", - "hasOwnProperty", - "isPrototypeOf", - "propertyIsEnumerable", - "toLocaleString", - "toString", - "valueOf", - ]; - - // If this is a native method, then do not do anything fancy. Just add it to - // the spy. - if (nativeMethods.indexOf(method as string) !== -1) { - return this.#addMethodToSpyObject( + this.getAllFunctionNames(original).forEach((method: string) => { + // If this is not a native method, then we do not care to add it because all + // methods should be stubbed in `SpyExtension#init()`. The `init()` method + // sets `#stubbed_methods` to `SpyStub` objects with a return value of + // "stubbed". + if (this.native_methods.indexOf(method as string) === -1) { + return; + } + + return this.addOriginalMethodWithoutTracking( original, spy, method, ); - } - - // Otherwise, make the method trackable via `.calls` usage. - this.#addTrackableMethodToSpyObject( - original, - spy, - method as keyof ClassToSpy, - ); - } - - /** - * Add an original object's property to a spy object. - * - * @param original The original object containing the property. - * @param spy The spy object receiving the property. - * @param property The name of the property -- retrievable via - * `spy[property]`. - */ - #addOriginalObjectPropertyToSpyObject( - original: ClassToSpy, - spy: ISpy, - property: string, - ): void { - const desc = Object.getOwnPropertyDescriptor(original, property) ?? - Object.getOwnPropertyDescriptor( - this.#constructor_fn.prototype, - property, - ); - - // If we do not have a desc, then we have no idea what the value should be. - // Also, we have no idea what we are copying, so we should just not do it. - if (!desc) { - return; - } - - // Basic property (e.g., public test = "hello"). We do not handle get() and - // set() because those are handled by the spy mixin. - if (("value" in desc)) { - Object.defineProperty(spy, property, { - value: desc.value, - writable: true, - }); - } - } - - /** - * Add a trackable method to a spy object. A trackable method is one that can - * be verified using `spy.calls[someMethod]`. - * - * @param original - The original object containing the method to add. - * @param spy - The spy object receiving the method. - * @param method - The name of the method. - */ - #addTrackableMethodToSpyObject( - original: ClassToSpy, - spy: ISpy, - method: keyof ClassToSpy, - ): void { - Object.defineProperty(spy, method, { - value: (...args: unknown[]) => { - // Track that this method was called - spy.calls[method]++; - - // Track what arguments were passed in when this method was called - spy.calls_arguments[method] = args; - - // Make sure the method calls its original self - const methodToCall = - (original[method as keyof ClassToSpy] as unknown as ( - ...params: unknown[] - ) => unknown); - - // When method calls its original self, let the `this` context of the - // original be the spy. Reason being the spy has tracking and the - // original does not. - const bound = methodToCall.bind(spy); - - // Use `return` because the original function could return a value - return bound(...args); - }, }); } - - /** - * Get all properties from the original so they can be added to the spy. - * - * @param obj - The object that will be spyed. - * - * @returns An array of the object's properties. - */ - #getAllPropertyNames(obj: ClassToSpy): string[] { - let functions: string[] = []; - let clone = obj; - do { - functions = functions.concat(Object.getOwnPropertyNames(clone)); - } while ((clone = Object.getPrototypeOf(clone))); - - return functions.sort().filter( - function (e: string, i: number, arr: unknown[]) { - if ( - e != arr[i + 1] && typeof obj[e as keyof ClassToSpy] != "function" - ) { - return true; - } - }, - ); - } - - /** - * Get all functions from the original so they can be added to the spy. - * - * @param obj - The object that will be spyed. - * - * @returns An array of the object's functions. - */ - #getAllFunctionNames(obj: ClassToSpy): string[] { - let functions: string[] = []; - let clone = obj; - do { - functions = functions.concat(Object.getOwnPropertyNames(clone)); - } while ((clone = Object.getPrototypeOf(clone))); - - return functions.sort().filter( - function (e: string, i: number, arr: unknown[]) { - if ( - e != arr[i + 1] && typeof obj[e as keyof ClassToSpy] == "function" - ) { - return true; - } - }, - ); - } } diff --git a/src/spy/spy_mixin.ts b/src/spy/spy_mixin.ts index 1a323f46..35df1884 100644 --- a/src/spy/spy_mixin.ts +++ b/src/spy/spy_mixin.ts @@ -1,144 +1,6 @@ -import type { - Constructor, - MethodArguments, - MethodCalls, - MethodOf, -} from "../types.ts"; -import { PreProgrammedMethod } from "../pre_programmed_method.ts"; -import { Stub } from "../../mod.ts"; -import type { IMethodExpectation, ISpy } from "../interfaces.ts"; - -class SpyError extends Error {} - -class MethodVerification { - #method_name: MethodOf; - #calls: number; - #args: unknown[]; - - constructor( - methodName: MethodOf, - calls: number, - args: unknown[], - ) { - this.#method_name = methodName; - this.#calls = calls; - this.#args = args; - } - - /** - * Verify that this method was called. Optionally, verify that it was called a - * specific number of times. - * - * @param expectedCalls - The number of calls this method is expected to have - * received. - * - * @returns this To add chainable verification methods. - */ - public toBeCalled(expectedCalls?: number): this { - if (!expectedCalls) { - if (this.#calls <= 0) { - throw new SpyError( - `Method "${this.#method_name}" expected to be called, but received 0 calls.`, - ); - } - } - - if (this.#calls !== expectedCalls) { - throw new SpyError( - `Method "${this.#method_name}" expected ${expectedCalls} call(s), but received ${this.#calls} call(s).`, - ); - } - - return this; - } - - /** - * Verify that this method was called with the following arguments. - * - * @returns this To add chainable verification methods. - */ - public toBeCalledWith(...expectedArgs: unknown[]): this { - if (expectedArgs.length != this.#args.length) { - throw new SpyError( - `Method "${this.#method_name}" expected ${expectedArgs.length} arguments(s), but received ${this.#args.length} argument(s).` + - `\nActual args: [${this.#args.join(", ")}]` + - `\nExpected args: [${expectedArgs.join(", ")}]`, - ); - } - - expectedArgs.forEach((arg: unknown, index: number) => { - const parameterPosition = index + 1; - if (this.#args[index] !== arg) { - if (this.#comparingArrays(this.#args[index], arg)) { - const match = this.#compareArrays( - this.#args[index] as unknown[], - arg as unknown[], - ); - if (match) { - return; - } - } - - const actualArgType = this.#getArgType(this.#args[index]); - const expectedArgType = this.#getArgType(arg); - - throw new SpyError( - `Method "${this.#method_name}" expected ${arg}${expectedArgType} argument at parameter position ${parameterPosition}, but received ${ - this.#args[index] - }${actualArgType} argument.`, - ); - } - }); - - this.#args.every((arg: unknown) => { - return expectedArgs.indexOf(arg) >= 0; - }); - - return this; - } - - /** - * Get the arg type in string format for the given arg. - * - * @param arg - The arg to evaluate. - * - * @returns The arg type surrounded by brackets (e.g., ). - */ - #getArgType(arg: unknown): string { - if (arg && typeof arg === "object") { - if ("prototype" in arg) { - return "<" + Object.getPrototypeOf(arg) + ">"; - } - return ""; - } - - return "<" + typeof arg + ">"; - } - - /** - * Are we comparing arrays? - * - * @param obj1 - Object to evaluate if it is an array. - * @param obj2 - Object to evaluate if it is an array. - * - * @returns True if yes, false if no. - */ - #comparingArrays(obj1: unknown, obj2: unknown): boolean { - return Array.isArray(obj1) && Array.isArray(obj2); - } - - /** - * Check that the given arrays are exactly equal. - * - * @param a - The first array. - * @param b - The second array (which should match the first array). - * - * @returns True if the arrays match, false if not. - */ - #compareArrays(a: unknown[], b: unknown[]): boolean { - return a.length === b.length && a.every((val, index) => val === b[index]); - } -} +import type { Constructor, MethodOf } from "../types.ts"; +import type { IMethodVerification, ISpy, ISpyStub } from "../interfaces.ts"; +import { SpyStubBuilder } from "./spy_stub_builder.ts"; /** * Create a spy as an extension of an original object. @@ -157,15 +19,9 @@ export function createSpy( is_spy = true; /** - * Property to track method calls. - */ - #calls!: MethodCalls; - - /** - * Property to track method calls. + * Property of stubbed methods. Each stubbed method has tracking (e.g., `spy.verify("someMethod").toBeCalled()`. */ - #calls_arguments!: MethodArguments; - + #stubbed_methods!: Record, ISpyStub>; /** * The original object that this class creates a mock of. */ @@ -175,12 +31,8 @@ export function createSpy( // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// - get calls(): MethodCalls { - return this.#calls; - } - - get calls_arguments(): MethodArguments { - return this.#calls_arguments; + get stubbed_methods(): Record, ISpyStub> { + return this.#stubbed_methods; } //////////////////////////////////////////////////////////////////////////// @@ -193,22 +45,18 @@ export function createSpy( */ public init(original: OriginalObject, methodsToTrack: string[]) { this.#original = original; - this.#calls = this.#constructCallsProperty(methodsToTrack); - this.#calls_arguments = this.#constructCallsArguments(methodsToTrack); - this.#stubMethods(methodsToTrack); + this.#stubbed_methods = this.#constructStubbedMethodsProperty( + methodsToTrack, + ); } /** - * Verify all expectations created in this mock. + * Get the verifier for the given method to do actual verification using */ public verify( methodName: MethodOf, - ): MethodVerification { - return new MethodVerification( - methodName, - this.#calls[methodName], - this.#calls_arguments[methodName], - ); + ): IMethodVerification { + return this.#stubbed_methods[methodName].verify(); } //////////////////////////////////////////////////////////////////////////// @@ -223,52 +71,22 @@ export function createSpy( * @returns - Key-value object where the key is the method name and the * value is the number of calls. All calls start at 0. */ - #constructCallsProperty( + #constructStubbedMethodsProperty( methodsToTrack: string[], - ): Record { - const calls: Partial> = {}; - - methodsToTrack.forEach((key: string) => { - calls[key as keyof OriginalObject] = 0; + ): Record, ISpyStub> { + const stubbedMethods: Partial< + Record, ISpyStub> + > = {}; + + methodsToTrack.forEach((method: string) => { + const spyMethod = new SpyStubBuilder(this) + .method(method as MethodOf) + .returnValue("stubbed") + .createForObjectMethod(); + stubbedMethods[method as MethodOf] = spyMethod; }); - return calls as Record; - } - - /** - * Construct the calls property. Only construct it, do not set it. The - * constructor will set it. - * @param methodsToTrack - All of the methods on the original object to make - * trackable. - * @returns - Key-value object where the key is the method name and the - * value is the number of calls. All calls start at 0. - */ - #constructCallsArguments( - methodsToTrack: string[], - ): Record { - const args: Partial> = {}; - - methodsToTrack.forEach((key: string) => { - args[key as keyof OriginalObject] = []; - }); - - return args as Record; - } - - /** - * Spies are stubs that also record some information on how they were - * called. - * Since they are stubs, we stub every single method and only track how the - * methods are called. - * @param methodsToTrack - All of the methods on the original object to make - * trackable. - */ - #stubMethods( - methodsToTrack: string[], - ): void { - methodsToTrack.forEach((key: string) => { - Stub(this.#original, key as keyof OriginalObject); - }); + return stubbedMethods as Record, ISpyStub>; } }(); } diff --git a/src/spy/spy_stub_builder.ts b/src/spy/spy_stub_builder.ts new file mode 100644 index 00000000..b4266bba --- /dev/null +++ b/src/spy/spy_stub_builder.ts @@ -0,0 +1,289 @@ +import type { MethodOf } from "../types.ts"; +import type { IMethodVerification, ISpyStub } from "../interfaces.ts"; +import { MethodVerifier } from "../method_verifier.ts"; + +class SpyStubFunctionExpressionVerifier { + #calls: number; + #args: unknown[]; + #verifier: MethodVerifier; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param calls - See `this.#calls`. + * @param args - See `this.#args`. + */ + constructor( + calls: number, + args: unknown[], + ) { + this.#calls = calls; + this.#args = args; + this.#verifier = new MethodVerifier(); + } + public toBeCalled(expectedCalls?: number): this { + this.#verifier.toBeCalled( + this.#calls, + expectedCalls ?? -1, + `.verify().toBeCalled()`, + ); + return this; + } + public toBeCalledWithArgs(...expectedArgs: unknown[]): this { + this.#verifier.toBeCalledWithArgs( + this.#args, + expectedArgs, + `.verify().toBeCalledWithArgs()`, + ); + return this; + } + public toBeCalledWithoutArgs() { + return this; + } +} + +/** + * The `SpyStub` class' verifier. It extends the `MethodVerifier` just to use + * its verification methods. In order to properly show stack traces in the + * context of the `SpyStub`, this verifier is used to provide the + * `codeThatThrew` argument to the `MethodVerifier` class' methods. + */ +class SpyStubMethodVerifier< + OriginalObject, +> extends MethodVerifier { + /** + * Property to hold the arguments this method was called with. + */ + #args: unknown[]; + + /** + * Property to hold the number of time this method was called. + */ + #calls: number; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param methodName - See `MethodVerifier#method_name`. + * @param calls - See `this.#calls`. + * @param args - See `this.#args`. + */ + constructor( + methodName: MethodOf, + calls: number, + args: unknown[], + ) { + super(methodName); + this.#calls = calls; + this.#args = args; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - GETTERS / SETTERS ///////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get args(): unknown[] { + return this.#args; + } + + get calls(): number { + return this.#calls; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Verify that this method was called. Optionally, verify that it was called a + * specific number of times. + * + * @param expectedCalls - (Optional) The number of calls this method is + * expected to have received. If not provided, then the verification process + * will assume "just verify that the method was called" instead of verifying + * that it was called a specific number of times. + * + * @returns this To allow method chaining. + */ + public toBeCalled(expectedCalls?: number): this { + const calls = expectedCalls ?? ""; + + super.toBeCalled( + this.calls, + expectedCalls ?? -1, + `.verify("${this.method_name}").toBeCalled(${calls})`, + ); + + return this; + } + + /** + * Verify that this method was called with the following arguments. + * + * @param expectedArgs - The expected arguments that this method should be + * called with. + * + * @returns this To allow method chaining. + */ + public toBeCalledWithArgs(...expectedArgs: unknown[]): this { + // Make a user friendly version of the expected args. This will be displayed + // in the error stack trace the parent class throws. For example: + // + // [true, false, "hello"] -> true, false, "hello" + // + // The above would result in the following stack trace message: + // + // .toBeCalledWith(true, false, "hello") + // + // If we do not do this, then the following stack trace message will show, + // which is not really clear because the args are in an array and the + // "hello" string has its quotes missing: + // + // .toBeCalledWith([true, false, hello]) + // + const expectedArgsAsString = JSON.stringify(expectedArgs) + .slice(1, -1) + .replace(/,/g, ", "); + + super.toBeCalledWithArgs( + this.args, + expectedArgs, + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + return this; + } + + /** + * Verify that this method was called without arguments. + * + * @returns this To allow method chaining. + */ + public toBeCalledWithoutArgs(): this { + super.toBeCalledWithoutArgs( + this.args, + `.verify().toBeCalledWithoutArgs()`, + ); + + return this; + } +} + +/** + * Builder to help build the following: + * + * - Spies for object methods (e.g., `someObject.someMethod()`). + * - Spies for function expressions (e.g., `const hello = () => "world"`). + */ +export class SpyStubBuilder { + /** + * Property to hold the number of time this method was called. + */ + #calls = 0; + + /** + * Property to hold the arguments this method was last called with. + */ + #last_called_with_args: unknown[] = []; + + /** + * The name of the method this spy is replacing. + */ + #method?: MethodOf; + + /** + * The original object. + */ + #original: OriginalObject; + + /** + * (Optional) The return value to return when the method this spy replaces is + * called. + */ + #return_value?: ReturnValue; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param original - The original object containing the method to spy on. + * @param method - The method to spy on. + * @param returnValue (Optional) Specify the return value of the method. + * Defaults to "stubbed". + */ + constructor(original: OriginalObject) { + this.#original = original; + } + + public method(method: MethodOf): this { + this.#method = method; + return this; + } + + public returnValue(returnValue: ReturnValue): this { + this.#return_value = returnValue; + return this; + } + + public createForObjectMethod(): ISpyStub { + this.#stubOriginalMethodWithTracking(); + + Object.defineProperty(this, "verify", { + value: () => + new SpyStubMethodVerifier( + this.#method!, + this.#calls, + this.#last_called_with_args, + ), + }); + + return this as unknown as ISpyStub & { verify: IMethodVerification }; + } + + public createForFunctionExpression(): ISpyStub { + const ret = (...args: unknown[]) => { + this.#calls++; + this.#last_called_with_args = args; + return this.#return_value ?? "spy-stubbed"; + }; + + ret.verify = () => + new SpyStubFunctionExpressionVerifier( + this.#calls, + this.#last_called_with_args, + ); + return ret; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Stub the original to have tracking. + */ + #stubOriginalMethodWithTracking(): void { + if (!this.#method) { + throw new Error( + `Cannot create a spy stub for an object's method without providing the object and method name.`, + ); + } + + Object.defineProperty(this.#original, this.#method, { + value: (...args: unknown[]) => { + this.#calls++; + this.#last_called_with_args = args; + + return this.#return_value !== undefined + ? this.#return_value + : "stubbed"; + }, + writable: true, + }); + } +} diff --git a/src/test_double_builder.ts b/src/test_double_builder.ts index 1e775869..b204a342 100644 --- a/src/test_double_builder.ts +++ b/src/test_double_builder.ts @@ -6,15 +6,22 @@ import type { Constructor } from "./types.ts"; */ export class TestDoubleBuilder { /** - * The class object passed into the constructor + * The constructor function of the objet to create a test double out of. */ protected constructor_fn: Constructor; /** - * A list of arguments the class constructor takes + * A list of arguments the class constructor takes. This is used to + * instantiate the original with arguments (if needed). */ protected constructor_args: unknown[] = []; + /** + * A list of native methods on an original object that should not be modified. + * When adding an original's methods to a test double, the copying process + * uses this array to skip adding these native methods with tracking. There is + * no reason to track these methods. + */ protected native_methods = [ "__defineGetter__", "__defineSetter__", @@ -36,8 +43,7 @@ export class TestDoubleBuilder { /** * Construct an object of this class. * - * @param constructorFn - The constructor function of the object to create a - * test double out of. + * @param constructorFn - See this#constructor_fn. */ constructor(constructorFn: Constructor) { this.constructor_fn = constructorFn; diff --git a/src/types.ts b/src/types.ts index c995f8ff..c72d8f9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,9 +11,21 @@ export type MethodOf = { : never; }[keyof Object]; -export type SpyReturnValue = T extends Constructor ? () => R - : string; +export type StubReturnValue = Object extends + Constructor ? () => ReturnValue + : ReturnValue; -export type StubReturnValue = T extends (...args: unknown[]) => unknown - ? () => R - : string; +/** + * This type is callable. + * + * @example + * ```ts + * const hello: Callable = () => { + * return "world"; + * } + * + * // Since `hello` is callable, we can call it + * hello(); // => "world" + * ``` + */ +export type Callable = (...args: unknown[]) => ReturnValue; diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts index 826cb019..692e3570 100644 --- a/tests/deno/unit/mod/spy_test.ts +++ b/tests/deno/unit/mod/spy_test.ts @@ -1,4 +1,4 @@ -import { Spy } from "../../../../mod.ts"; +import { Fake, Spy } from "../../../../mod.ts"; import { assertEquals } from "../../../deps.ts"; class Resource { @@ -54,74 +54,366 @@ class ResourceParameterized { } Deno.test("Spy()", async (t) => { - await t.step("using Spy() on methods", async (t) => { - await t.step("can spy on a method", () => { - const resource = new Resource(); - - Spy(resource, "methodThatPosts"); - console.log(resource.methodThatPosts()); - console.log(resource.methodThatPosts()); - console.log(resource.methodThatPosts()); - }); - }); - - await t.step("using Spy() on classes", async (t) => { - await t.step("can turn a class into a spy", () => { + await t.step("can spy on a class", async (t) => { + await t.step("adds is_spy", () => { const spy = Spy(Resource); assertEquals(spy.is_spy, true); }); - await t.step("stubs all data members", () => { + await t.step('stubs all data members with "stubbed" return value', () => { const spy = Spy(Resource); assertEquals(spy.methodThatLogs(), "stubbed"); assertEquals(spy.methodThatGets(), "stubbed"); assertEquals(spy.methodThatPosts(), "stubbed"); + const spy2 = Spy(Resource); + const stubbedReturnValue = spy2.methodThatLogs(); // We called it, ... + spy2.verify("methodThatLogs").toBeCalled(); // ... so we can verify it was called ... }); await t.step("can verify calls for non-parameterized methods", () => { const spy = Spy(Resource); // Verify that `methodThatLogs` was called once (because we called it here) - spy.methodThatLogs(); + const stubbedRetVal1 = spy.methodThatLogs(); + assertEquals(stubbedRetVal1, "stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs").toBeCalled(1); // Verify that `methodThatLogs()` was still only called once since // `methodThatGets()` is stubbed - spy.methodThatGets(); + const stubbedRetVal2 = spy.methodThatGets(); + assertEquals(stubbedRetVal2, "stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs").toBeCalled(1); // Verify that `methodThatLogs()` was still only called once since // `methodThatPosts()` is stubbed - spy.methodThatPosts(); + const stubbedRetVal3 = spy.methodThatPosts(); + assertEquals(stubbedRetVal3, "stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs").toBeCalled(1); }); await t.step("can verify calls for parameterized methods", () => { const spy = Spy(ResourceParameterized); // Verify that `methodThatLogs` was called once (because we called it here) - spy.methodThatLogs("hello"); + const stubbedRetVal1 = spy.methodThatLogs("hello"); + assertEquals(stubbedRetVal1, "stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs") .toBeCalled(1) - .toBeCalledWith("hello"); + .toBeCalledWithArgs("hello"); // Verify that `methodThatLogs()` was still only called once since // `methodThatGets()` is stubbed - spy.methodThatGets("hello", "world"); + const stubbedRetVal2 = spy.methodThatGets("hello", "world"); + assertEquals(stubbedRetVal2, "stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs") .toBeCalled(1) - .toBeCalledWith("hello"); + .toBeCalledWithArgs("hello"); spy.verify("methodThatGets") .toBeCalled(1) - .toBeCalledWith("hello", "world"); + .toBeCalledWithArgs("hello", "world"); // Verify that `methodThatLogs()` was still only called once since // `methodThatPosts()` is stubbed - spy.methodThatPosts(true, false, ["test"]); + const stubbedRetVal3 = spy.methodThatPosts(true, false, ["test"]); + assertEquals(stubbedRetVal3, "stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs") .toBeCalled(1) - .toBeCalledWith("hello"); + .toBeCalledWithArgs("hello"); spy.verify("methodThatPosts") .toBeCalled(1) - .toBeCalledWith(true, false, ["test"]); + .toBeCalledWithArgs(true, false, ["test"]); + }); + }); + + await t.step("can spy on a class method", async (t) => { + await t.step("can stub the class method", () => { + const resource = new Resource(); + Spy(resource, "methodThatPosts"); + assertEquals(resource.methodThatPosts(), "stubbed"); + }); + + await t.step( + "can stub the class method (with stubbed return value)", + () => { + const resource = new Resource(); + Spy(resource, "methodThatPosts", "hello"); + assertEquals(resource.methodThatPosts(), "hello"); + }, + ); + + await t.step("can spy on a class method (non-parameterized)", () => { + // First we create a fake, which has working implementations + const resource = new Resource(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets(), "stubbed"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }); + + await t.step( + "can spy on a class method (non-parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const resource = new Resource(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + { stubbed: "return", value: "here" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets(), { + stubbed: "return", + value: "here", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }, + ); + + await t.step("can spy on a class method (parameterized)", () => { + // First we create a fake, which has working implementations + const resource = new ResourceParameterized(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + resource.methodThatGets("param1", "param2"), + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets("param1", "param2"), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + resource.methodThatGets("anotherParam1", "anotherParam2"), + "Do GET", + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }); + + await t.step( + "can spy on a class method (parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const resource = new ResourceParameterized(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets("param1", "param2"), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + resource.methodThatGets("anotherParam1", "anotherParam2"), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }, + ); + + await t.step("can spy on a fakes's method (non-parameterized)", () => { + // First we create a fake, which has working implementations + const fake = Fake(Resource).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy(fake, "methodThatGets", fake.methodThatGets()); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets(), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }); + + await t.step( + "can spy on a fakes's method (non-parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const fake = Fake(Resource).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets(), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithoutArgs(); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets(), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithoutArgs(); + }, + ); + + await t.step("can spy on a fakes's method (parameterized)", () => { + // First we create a fake, which has working implementations + const fake = Fake(ResourceParameterized).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + fake.methodThatGets("param1", "param2"), + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets("param1", "param2"), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets("anotherParam1", "anotherParam2"), + "Do GET", + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }); + + await t.step( + "can spy on a fakes's method (parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const fake = Fake(ResourceParameterized).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets("param1", "param2"), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets("anotherParam1", "anotherParam2"), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }, + ); + + await t.step("can spy on a function expression", () => { + const hello = (): { some: string } => { + return { + some: "value", + }; + }; + + let spyHello = Spy(hello); + + function thingThatCallsTheSpy() { + spyHello(); + } + + thingThatCallsTheSpy(); // Call 1 + spyHello.verify().toBeCalled(1); + + thingThatCallsTheSpy(); // Call 2 + thingThatCallsTheSpy(); // Call 3 + spyHello.verify().toBeCalled(3); + + // Since no return value was specified, the default "spy-stubbed" should + // be used + assertEquals(spyHello(), "spy-stubbed"); + + // Here we give `hello` a new return value. The return value must have of + // the same type since we cannot reassign a new type. + spyHello = Spy(hello, { some: "other return value" }); + assertEquals(spyHello(), { some: "other return value" }); + // Since we reassigned `spyHello`, its calls should be reset to 0 and we + // should expect the number of calls to be 1 because we called it in the + // above `assertEquals()` call + spyHello.verify().toBeCalled(1); }); }); }); From cddb71e9bb7cc3f7925348bb1d38af0586af4431 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sun, 24 Apr 2022 13:26:40 -0400 Subject: [PATCH 09/31] chore: DRYing verifiers --- mod.ts | 10 +- src/interfaces.ts | 13 +- src/method_verifier.ts | 206 ------------------ src/mock/mock_mixin.ts | 31 +-- src/spy/spy_mixin.ts | 2 +- src/spy/spy_stub_builder.ts | 67 ++---- src/types.ts | 60 +++-- src/verifiers/callable_verifier.ts | 189 ++++++++++++++++ src/verifiers/function_expression_verifier.ts | 155 +++++++++++++ src/verifiers/method_verifier.ts | 119 ++++++++++ tests/deno/unit/mod/spy_test.ts | 70 +++--- 11 files changed, 589 insertions(+), 333 deletions(-) delete mode 100644 src/method_verifier.ts create mode 100644 src/verifiers/callable_verifier.ts create mode 100644 src/verifiers/function_expression_verifier.ts create mode 100644 src/verifiers/method_verifier.ts diff --git a/mod.ts b/mod.ts index bc886472..cbea2731 100644 --- a/mod.ts +++ b/mod.ts @@ -1,4 +1,4 @@ -import type { Callable, Constructor, MethodOf, StubReturnValue } from "./src/types.ts"; +import type { Constructor, MethodOf } from "./src/types.ts"; import { MockBuilder } from "./src/mock/mock_builder.ts"; import { FakeBuilder } from "./src/fake/fake_builder.ts"; import { SpyBuilder } from "./src/spy/spy_builder.ts"; @@ -76,9 +76,11 @@ export function Mock(constructorFn: Constructor): MockBuilder { //////////////////////////////////////////////////////////////////////////////// export function Spy( - fn: (...args: unknown[]) => ReturnValue, + // deno-lint-ignore no-explicit-any + fn: (...args: any[]) => ReturnValue, returnValue?: ReturnValue -): Interfaces.ISpyStubFunctionExpression & Callable; + // deno-lint-ignore no-explicit-any +): Interfaces.ISpyStubFunctionExpression & ((...args: any[]) => ReturnValue); /** * Create spy out of a class. Example: @@ -203,7 +205,7 @@ export function Stub( obj: OriginalObject, dataMember: keyof OriginalObject, returnValue?: ReturnValue, -): StubReturnValue; +): void; /** * Take the given object and stub its given data member to return the given * return value. diff --git a/src/interfaces.ts b/src/interfaces.ts index 8dc3cc19..a810bb1d 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,4 @@ -import type { MethodCalls, MethodOf } from "./types.ts"; +import type { MethodOf, MockMethodCalls } from "./types.ts"; export interface IMethodExpectation { toBeCalled(expectedCalls: number): void; @@ -18,7 +18,7 @@ export interface IMethodVerification { * @returns `this` To allow method chaining. */ toBeCalled(expectedCalls?: number): this; - toBeCalledWithArgs(...args: unknown[]): this; + toBeCalledWithArgs(firstArg: unknown, ...restOfArgs: unknown[]): this; toBeCalledWithoutArgs(): this; } @@ -60,7 +60,14 @@ export interface IFake { } export interface IMock { - calls: MethodCalls; + /** + * Property to track method calls. + */ + calls: MockMethodCalls; + + /** + * Helper property to see that this is a mock object and not the original. + */ is_mock: boolean; /** diff --git a/src/method_verifier.ts b/src/method_verifier.ts deleted file mode 100644 index 9c3445ee..00000000 --- a/src/method_verifier.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { MethodOf } from "./types.ts"; -import { MethodVerificationError } from "./errors.ts"; - -/** - * Test doubles use this class to verify that their methods were called, were - * called with a number of arguments, were called with specific types of - * arguments, and so on. - */ -export class MethodVerifier { - /** - * The name of the method using this class. This is only used for display in - * error stack traces if this class throws. - */ - #method_name: MethodOf | null; - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * @param methodName - See this#method_name. - */ - constructor(methodName?: MethodOf) { - this.#method_name = methodName ?? null; - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - get method_name(): MethodOf | null { - return this.#method_name; - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Verify that the actual calls match the expected calls. - * - * @param actualCalls - The number of actual calls. - * @param expectedCalls - The number of calls expected. If this is -1, then - * just verify that the method was called without checking how many times it - * was called. - * @param codeThatThrew - See `MethodVerificationError` constructor's - * `codeThatThrew` param. - */ - public toBeCalled( - actualCalls: number, - expectedCalls: number, - codeThatThrew: string, - ): void { - if (expectedCalls === -1) { - if (actualCalls <= 0) { - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect number of calls.`, - codeThatThrew, - `Expected calls -> 1 (or more)`, - `Actual calls -> 0`, - ); - } - return; - } - - if (actualCalls !== expectedCalls) { - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect number of calls.`, - codeThatThrew, - `Expected calls -> ${expectedCalls}`, - `Actual calls -> ${actualCalls}`, - ); - } - } - - /** - * Verify that this method was called with the given args. - * - * @param actualArgs - The actual args that this method was called with. - * @param expectedArgs - The args this method is expected to have received. - * @param codeThatThrew - See `MethodVerificationError` constructor's - * `codeThatThrew` param. - */ - public toBeCalledWithArgs( - actualArgs: unknown[], - expectedArgs: unknown[], - codeThatThrew: string, - ): void { - const expectedArgsAsString = JSON.stringify(expectedArgs) - .slice(1, -1) - .replace(/,/g, ", "); - const actualArgsAsString = JSON.stringify(actualArgs) - .slice(1, -1) - .replace(/,/g, ", "); - - if (expectedArgs.length != actualArgs.length) { - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect number of arguments.`, - codeThatThrew, - `Expected args -> [${expectedArgsAsString}]`, - `Actual args -> [${actualArgsAsString}]`, - ); - } - - expectedArgs.forEach((arg: unknown, index: number) => { - const parameterPosition = index + 1; - if (actualArgs[index] !== arg) { - if (this.#comparingArrays(actualArgs[index], arg)) { - const match = this.#compareArrays( - actualArgs[index] as unknown[], - arg as unknown[], - ); - if (match) { - return; - } - } - - const actualArgType = this.#getArgType(actualArgs[index]); - const expectedArgType = this.#getArgType(arg); - - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect argument type at parameter position ${parameterPosition}.`, - `.method("${this.#method_name}").toBeCalledWithArgs(...)`, - `Expected arg type -> ${arg}${expectedArgType}`, - `Actual arg type -> ${actualArgs[index]}${actualArgType}`, - ); - } - }); - - actualArgs.every((arg: unknown) => { - return expectedArgs.indexOf(arg) >= 0; - }); - } - - /** - * Verify that this method was called without arguments. - * - * @param actualArgs - The actual args that this method was called with. This - * method expects it to be an empty array. - * @param codeThatThrew - See `MethodVerificationError` constructor's - * `codeThatThrew` param. - */ - public toBeCalledWithoutArgs( - actualArgs: unknown[], - codeThatThrew: string, - ): void { - const actualArgsAsString = JSON.stringify(actualArgs) - .slice(1, -1) - .replace(/,/g, ", "); - - if (actualArgs.length > 0) { - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect number of arguments.`, - codeThatThrew, - `Expected args -> none`, - `Actual args -> [${actualArgsAsString}]`, - ); - } - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Check that the given arrays are exactly equal. - * - * @param a - The first array. - * @param b - The second array (which should match the first array). - * - * @returns True if the arrays match, false if not. - */ - #compareArrays(a: unknown[], b: unknown[]): boolean { - return a.length === b.length && a.every((val, index) => val === b[index]); - } - - /** - * Are we comparing arrays? - * - * @param obj1 - Object to evaluate if it is an array. - * @param obj2 - Object to evaluate if it is an array. - * - * @returns True if yes, false if no. - */ - #comparingArrays(obj1: unknown, obj2: unknown): boolean { - return Array.isArray(obj1) && Array.isArray(obj2); - } - - /** - * Get the arg type in string format for the given arg. - * - * @param arg - The arg to evaluate. - * - * @returns The arg type surrounded by brackets (e.g., ). - */ - #getArgType(arg: unknown): string { - if (arg && typeof arg === "object") { - if ("prototype" in arg) { - return "<" + Object.getPrototypeOf(arg) + ">"; - } - return ""; - } - - return "<" + typeof arg + ">"; - } -} diff --git a/src/mock/mock_mixin.ts b/src/mock/mock_mixin.ts index c7b9e021..afafefed 100644 --- a/src/mock/mock_mixin.ts +++ b/src/mock/mock_mixin.ts @@ -1,7 +1,7 @@ import type { IMock } from "../interfaces.ts"; -import type { Constructor, MethodCalls, MethodOf } from "../types.ts"; +import type { Constructor, MethodOf, MockMethodCalls } from "../types.ts"; -import { MethodVerifier } from "../method_verifier.ts"; +import { MethodVerifier } from "../verifiers/method_verifier.ts"; import { MockError } from "../errors.ts"; import { PreProgrammedMethod } from "../pre_programmed_method.ts"; @@ -12,7 +12,7 @@ class MethodExpectation { /** * Property to hold the number of expected calls this method should receive. */ - #expected_calls = 0; + #expected_calls?: number | undefined; /** * See `MethodVerifier#method_name`. @@ -55,7 +55,7 @@ class MethodExpectation { * to -1 to tell `MethodVerifier` to "just check that the method was called". */ public toBeCalled(expectedCalls?: number): void { - this.#expected_calls = expectedCalls ?? -1; + this.#expected_calls = expectedCalls; } // public verify(actualCalls: number, actualArgs: unknown[]): void { @@ -68,24 +68,9 @@ class MethodExpectation { * @param actualCalls - The number of actual calls. */ public verifyCalls(actualCalls: number): void { - // If the expected calls is -1, then we do not show anything in the third - // argument to `this.#verifier.toBeCalled()`. Reason being we want to show - // this in the error stack trace ... - // - // .expects("someMethod").toBeCalled() - // - // ... and not this ... - // - // .expects("someMethod").toBeCalled(-1) - // - const expectedCalls = this.#expected_calls !== -1 - ? "" - : `${this.#expected_calls}`; - this.#verifier.toBeCalled( actualCalls, this.#expected_calls, - `.expects("${this.#method_name}").toBeCalled(${expectedCalls})`, ); } } @@ -106,14 +91,14 @@ export function createMock( >; return new class MockExtension extends Original { /** - * Helper property to see that this is a mock object and not the original. + * See `IMock#is_mock`. */ is_mock = true; /** - * Property to track method calls. + * See `IMock#calls`. */ - #calls!: MethodCalls; + #calls!: MockMethodCalls; /** * An array of expectations to verify (if any). @@ -129,7 +114,7 @@ export function createMock( // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// - get calls(): MethodCalls { + get calls(): MockMethodCalls { return this.#calls; } diff --git a/src/spy/spy_mixin.ts b/src/spy/spy_mixin.ts index 35df1884..cafdc4d9 100644 --- a/src/spy/spy_mixin.ts +++ b/src/spy/spy_mixin.ts @@ -81,7 +81,7 @@ export function createSpy( methodsToTrack.forEach((method: string) => { const spyMethod = new SpyStubBuilder(this) .method(method as MethodOf) - .returnValue("stubbed") + .returnValue("spy-stubbed") .createForObjectMethod(); stubbedMethods[method as MethodOf] = spyMethod; }); diff --git a/src/spy/spy_stub_builder.ts b/src/spy/spy_stub_builder.ts index b4266bba..1dfc9277 100644 --- a/src/spy/spy_stub_builder.ts +++ b/src/spy/spy_stub_builder.ts @@ -1,45 +1,49 @@ import type { MethodOf } from "../types.ts"; import type { IMethodVerification, ISpyStub } from "../interfaces.ts"; -import { MethodVerifier } from "../method_verifier.ts"; +import { MethodVerifier } from "../verifiers/method_verifier.ts"; +import { FunctionExpressionVerifier } from "../verifiers/function_expression_verifier.ts"; -class SpyStubFunctionExpressionVerifier { +class SpyStubFunctionExpressionVerifier extends FunctionExpressionVerifier { #calls: number; #args: unknown[]; - #verifier: MethodVerifier; ////////////////////////////////////////////////////////////////////////////// // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// /** + * @param name - The name of the function using this verifier. * @param calls - See `this.#calls`. * @param args - See `this.#args`. */ constructor( + name: string, calls: number, args: unknown[], ) { + super(name); this.#calls = calls; this.#args = args; - this.#verifier = new MethodVerifier(); } + public toBeCalled(expectedCalls?: number): this { - this.#verifier.toBeCalled( + return super.toBeCalled( this.#calls, - expectedCalls ?? -1, - `.verify().toBeCalled()`, + expectedCalls, ); - return this; } + public toBeCalledWithArgs(...expectedArgs: unknown[]): this { - this.#verifier.toBeCalledWithArgs( + return super.toBeCalledWithArgs( this.#args, expectedArgs, - `.verify().toBeCalledWithArgs()`, ); - return this; } public toBeCalledWithoutArgs() { + super.toBeCalledWithoutArgs( + this.#args, + `.verify().toBeCalledWithoutArgs()`, + ); return this; } } @@ -110,15 +114,10 @@ class SpyStubMethodVerifier< * @returns this To allow method chaining. */ public toBeCalled(expectedCalls?: number): this { - const calls = expectedCalls ?? ""; - - super.toBeCalled( + return super.toBeCalled( this.calls, - expectedCalls ?? -1, - `.verify("${this.method_name}").toBeCalled(${calls})`, + expectedCalls, ); - - return this; } /** @@ -130,32 +129,10 @@ class SpyStubMethodVerifier< * @returns this To allow method chaining. */ public toBeCalledWithArgs(...expectedArgs: unknown[]): this { - // Make a user friendly version of the expected args. This will be displayed - // in the error stack trace the parent class throws. For example: - // - // [true, false, "hello"] -> true, false, "hello" - // - // The above would result in the following stack trace message: - // - // .toBeCalledWith(true, false, "hello") - // - // If we do not do this, then the following stack trace message will show, - // which is not really clear because the args are in an array and the - // "hello" string has its quotes missing: - // - // .toBeCalledWith([true, false, hello]) - // - const expectedArgsAsString = JSON.stringify(expectedArgs) - .slice(1, -1) - .replace(/,/g, ", "); - - super.toBeCalledWithArgs( + return super.toBeCalledWithArgs( this.args, expectedArgs, - `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, ); - - return this; } /** @@ -164,12 +141,9 @@ class SpyStubMethodVerifier< * @returns this To allow method chaining. */ public toBeCalledWithoutArgs(): this { - super.toBeCalledWithoutArgs( + return super.toBeCalledWithoutArgs( this.args, - `.verify().toBeCalledWithoutArgs()`, ); - - return this; } } @@ -254,6 +228,7 @@ export class SpyStubBuilder { ret.verify = () => new SpyStubFunctionExpressionVerifier( + (this.#original as unknown as { name: string }).name, this.#calls, this.#last_called_with_args, ); @@ -281,7 +256,7 @@ export class SpyStubBuilder { return this.#return_value !== undefined ? this.#return_value - : "stubbed"; + : "spy-stubbed"; }, writable: true, }); diff --git a/src/types.ts b/src/types.ts index c72d8f9c..9ae6c4a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,31 +1,57 @@ +/** + * Describes the type as a constructable object using the `new` keyword. + */ // deno-lint-ignore no-explicit-any export type Constructor = new (...args: any[]) => T; -export type MethodArguments = Record; - -export type MethodCalls = Record; +//////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// -export type MethodOf = { - // deno-lint-ignore no-explicit-any - [K in keyof Object]: Object[K] extends (...args: any[]) => unknown ? K - : never; -}[keyof Object]; +/** + * Describes the `MockExtension#calls` property. + * + * This is a record where the key is the method that was called and the value is + * the number of times the method was called. + * + * @example + * ```ts + * { + * someMethod: 0, + * someOtherMethod: 3, + * someOtherOtherMethod: 2, + * } + * ``` + */ +export type MockMethodCalls = Record; -export type StubReturnValue = Object extends - Constructor ? () => ReturnValue - : ReturnValue; +//////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// /** - * This type is callable. + * Describes the type as a method of the given generic `Object`. + * + * This is used for type-hinting in places like `.verify("someMethod")`. * * @example * ```ts - * const hello: Callable = () => { - * return "world"; + * class Hello { + * some_property = true; + * someMethod(): boolean { return true;} * } * - * // Since `hello` is callable, we can call it - * hello(); // => "world" + * function callMethod(method: MethodOf) { ... } + * + * callMethod("someMethod") // Ok + * + * // Shows the following error: Argument of type '"some_property"' is not + * // assignable to parameter of type 'MethodOf' + * callMethod("some_property") * ``` */ -export type Callable = (...args: unknown[]) => ReturnValue; +export type MethodOf = { + // deno-lint-ignore no-explicit-any + [K in keyof Object]: Object[K] extends (...args: any[]) => unknown ? K + : never; +}[keyof Object]; diff --git a/src/verifiers/callable_verifier.ts b/src/verifiers/callable_verifier.ts new file mode 100644 index 00000000..83a97cab --- /dev/null +++ b/src/verifiers/callable_verifier.ts @@ -0,0 +1,189 @@ +import { MethodVerificationError } from "../errors.ts"; + +export class CallableVerifier { + /** + * Make a user friendly version of the expected args. This will be displayed + * in the `MethodVerificationError` stack trace. For example: + * + * [true, false, "hello"] -> true, false, "hello" + * + * The above would result in the following stack trace message: + * + * .toBeCalledWith(true, false, "hello") + * + * If we do not do this, then the following stack trace message will show, + * which is not really clear because the args are in an array and the + * "hello" string has its quotes missing: + * + * .toBeCalledWith([true, false, hello]) + */ + protected argsAsString(expectedArgs: unknown[]): string { + return JSON.stringify(expectedArgs) + .slice(1, -1) + .replace(/,/g, ", "); + } + + protected argsAsStringWithTypes(args: unknown[]): string { + return args.map((arg: unknown) => { + return `${JSON.stringify(arg)}${this.getArgType(arg)}`; + }).join(", "); + } + + /** + * Get the arg type in string format for the given arg. + * + * @param arg - The arg to evaluate. + * + * @returns The arg type surrounded by brackets (e.g., ). + */ + protected getArgType(arg: unknown): string { + if (arg && typeof arg === "object") { + if ("prototype" in arg) { + return "<" + Object.getPrototypeOf(arg) + ">"; + } + return ""; + } + + return "<" + typeof arg + ">"; + } + + protected verifyToBeCalled( + actualCalls: number, + expectedCalls: number | undefined, + errorMessage: string, + codeThatThrew: string, + ): void { + if (!expectedCalls) { + if (actualCalls <= 0) { + throw new MethodVerificationError( + errorMessage, + codeThatThrew, + `Expected calls -> 1 (or more)`, + `Actual calls -> 0`, + ); + } + return; + } + + if (actualCalls !== expectedCalls) { + throw new MethodVerificationError( + errorMessage, + codeThatThrew, + `Expected calls -> ${expectedCalls}`, + `Actual calls -> ${actualCalls}`, + ); + } + } + + protected verifyToBeCalledWithArgsTooManyArguments( + actualArgs: unknown[], + expectedArgs: unknown[], + errorMessage: string, + codeThatThrew: string, + ): void { + if (expectedArgs.length > actualArgs.length) { + throw new MethodVerificationError( + errorMessage, + codeThatThrew, + `Expected args -> ${this.argsAsStringWithTypes(expectedArgs)}`, + `Actual args -> ${ + actualArgs.length > 0 + ? this.argsAsStringWithTypes(actualArgs) + : "(no args)" + }`, + ); + } + } + + protected verifyToBeCalledWithArgsTooFewArguments( + actualArgs: unknown[], + expectedArgs: unknown[], + errorMessage: string, + codeThatThrew: string, + ): void { + if (expectedArgs.length < actualArgs.length) { + throw new MethodVerificationError( + errorMessage, + codeThatThrew, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, + `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + ); + } + } + + protected verifyToBeCalledWithArgsUnexpectedValues( + actualArgs: unknown[], + expectedArgs: unknown[], + errorMessage: string, + codeThatThrew: string, + ): void { + expectedArgs.forEach((arg: unknown, index: number) => { + const parameterPosition = index + 1; + if (actualArgs[index] !== arg) { + if (this.#comparingArrays(actualArgs[index], arg)) { + const match = this.#compareArrays( + actualArgs[index] as unknown[], + arg as unknown[], + ); + if (match) { + return; + } + } + + const unexpectedArg = `\`${arg}${this.getArgType(arg)}\``; + + throw new MethodVerificationError( + errorMessage + .replace("{{ unexpected_arg }}", unexpectedArg) + .replace("{{ parameter_position }}", parameterPosition.toString()), + codeThatThrew, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, + `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + ); + } + }); + } + + protected verifyToBeCalledWithoutArgs( + actualArgs: unknown[], + errorMessage: string, + codeThatThrew: string, + ): void { + const actualArgsAsString = JSON.stringify(actualArgs) + .slice(1, -1) + .replace(/,/g, ", "); + + if (actualArgs.length > 0) { + throw new MethodVerificationError( + errorMessage, + codeThatThrew, + `Expected args -> (no args)`, + `Actual args -> (${actualArgsAsString})`, + ); + } + } + + /** + * Check that the given arrays are exactly equal. + * + * @param a - The first array. + * @param b - The second array (which should match the first array). + * + * @returns True if the arrays match, false if not. + */ + #compareArrays(a: unknown[], b: unknown[]): boolean { + return a.length === b.length && a.every((val, index) => val === b[index]); + } + + /** + * Are we comparing arrays? + * + * @param obj1 - Object to evaluate if it is an array. + * @param obj2 - Object to evaluate if it is an array. + * + * @returns True if yes, false if no. + */ + #comparingArrays(obj1: unknown, obj2: unknown): boolean { + return Array.isArray(obj1) && Array.isArray(obj2); + } +} diff --git a/src/verifiers/function_expression_verifier.ts b/src/verifiers/function_expression_verifier.ts new file mode 100644 index 00000000..eb961095 --- /dev/null +++ b/src/verifiers/function_expression_verifier.ts @@ -0,0 +1,155 @@ +import { MethodVerificationError } from "../errors.ts"; +import { CallableVerifier } from "./callable_verifier.ts"; + +/** + * Test doubles use this class to verify that their methods were called, were + * called with a number of arguments, were called with specific types of + * arguments, and so on. + */ +export class FunctionExpressionVerifier extends CallableVerifier { + /** + * The name of this function. + */ + #name: string; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param name - See this#name. + */ + constructor(name: string) { + super(); + this.#name = name ?? null; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get name(): string { + return this.#name; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Verify that the actual calls match the expected calls. + * + * @param actualCalls - The number of actual calls. + * @param expectedCalls - The number of calls expected. If this is -1, then + * just verify that the method was called without checking how many times it + * was called. + */ + protected toBeCalled( + actualCalls: number, + expectedCalls?: number, + ): this { + const calls = expectedCalls ?? ""; + this.verifyToBeCalled( + actualCalls, + expectedCalls, + `Function "${this.#name}" was not called.`, + `.verify().toBeCalled(${calls})`, + ); + + return this; + } + + /** + * Verify that this method was called with the given args. + * + * @param actualArgs - The actual args that this method was called with. + * @param expectedArgs - The args this method is expected to have received. + * @param codeThatThrew - See `MethodVerificationError` constructor's + * `codeThatThrew` param. + */ + protected toBeCalledWithArgs( + actualArgs: unknown[], + expectedArgs: unknown[], + ): this { + const expectedArgsAsString = this.argsAsString(expectedArgs); + + this.verifyToBeCalledWithArgsTooManyArguments( + actualArgs, + expectedArgs, + `Function "${this.#name}" received too many arguments.`, + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + this.verifyToBeCalledWithArgsTooFewArguments( + actualArgs, + expectedArgs, + `Function "${this.#name}" was called with ${actualArgs.length} ${ + actualArgs.length > 1 ? "args" : "arg" + } instead of ${expectedArgs.length}.`, + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + this.verifyToBeCalledWithArgsUnexpectedValues( + actualArgs, + expectedArgs, + `Function "${this.#name}" received unexpected arg {{ unexpected_arg }} at parameter position {{ parameter_position }}.`, + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + return this; + } + + /** + * Verify that this method was called without arguments. + * + * @param actualArgs - The actual args that this method was called with. This + * method expects it to be an empty array. + * @param codeThatThrew - See `MethodVerificationError` constructor's + * `codeThatThrew` param. + */ + public toBeCalledWithoutArgs( + actualArgs: unknown[], + codeThatThrew: string, + ): void { + const actualArgsAsString = JSON.stringify(actualArgs) + .slice(1, -1) + .replace(/,/g, ", "); + + if (actualArgs.length > 0) { + throw new MethodVerificationError( + `Function "${this.#name}" was called with args when expected to receive no args.`, + codeThatThrew, + `Expected -> (no args)`, + `Actual -> (${actualArgsAsString})`, + ); + } + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Check that the given arrays are exactly equal. + * + * @param a - The first array. + * @param b - The second array (which should match the first array). + * + * @returns True if the arrays match, false if not. + */ + #compareArrays(a: unknown[], b: unknown[]): boolean { + return a.length === b.length && a.every((val, index) => val === b[index]); + } + + /** + * Are we comparing arrays? + * + * @param obj1 - Object to evaluate if it is an array. + * @param obj2 - Object to evaluate if it is an array. + * + * @returns True if yes, false if no. + */ + #comparingArrays(obj1: unknown, obj2: unknown): boolean { + return Array.isArray(obj1) && Array.isArray(obj2); + } +} diff --git a/src/verifiers/method_verifier.ts b/src/verifiers/method_verifier.ts new file mode 100644 index 00000000..29ed8f8d --- /dev/null +++ b/src/verifiers/method_verifier.ts @@ -0,0 +1,119 @@ +import type { MethodOf } from "../types.ts"; +import { MethodVerificationError } from "../errors.ts"; +import { CallableVerifier } from "./callable_verifier.ts"; + +/** + * Test doubles use this class to verify that their methods were called, were + * called with a number of arguments, were called with specific types of + * arguments, and so on. + */ +export class MethodVerifier extends CallableVerifier { + /** + * The name of the method using this class. This is only used for display in + * error stack traces if this class throws. + */ + #method_name: MethodOf | null; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param methodName - See this#method_name. + */ + constructor(methodName?: MethodOf) { + super(); + this.#method_name = methodName ?? null; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get method_name(): MethodOf | null { + return this.#method_name; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Verify that the actual calls match the expected calls. + * + * @param actualCalls - The number of actual calls. + * @param expectedCalls - The number of calls expected. If this is -1, then + * just verify that the method was called without checking how many times it + * was called. + */ + public toBeCalled( + actualCalls: number, + expectedCalls?: number, + ): this { + const calls = expectedCalls ?? ""; + this.verifyToBeCalled( + actualCalls, + expectedCalls, + `Method "${this.#method_name}" was not called.`, + `.verify("${this.#method_name}").toBeCalled(${calls})`, + ); + + return this; + } + + /** + * Verify that this method was called with the given args. + * + * @param actualArgs - The actual args that this method was called with. + * @param expectedArgs - The args this method is expected to have received. + */ + public toBeCalledWithArgs( + actualArgs: unknown[], + expectedArgs: unknown[], + ): this { + const expectedArgsAsString = this.argsAsString(expectedArgs); + + this.verifyToBeCalledWithArgsTooManyArguments( + actualArgs, + expectedArgs, + `Method "${this.#method_name}" received too many arguments.`, + `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + this.verifyToBeCalledWithArgsTooFewArguments( + actualArgs, + expectedArgs, + `Method "${this.#method_name}" was called with ${actualArgs.length} ${ + actualArgs.length > 1 ? "args" : "arg" + } instead of ${expectedArgs.length}.`, + `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + this.verifyToBeCalledWithArgsUnexpectedValues( + actualArgs, + expectedArgs, + `Method "${this.#method_name}" received unexpected arg {{ unexpected_arg }} at parameter position {{ parameter_position }}.`, + `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`, + ); + + return this; + } + + /** + * Verify that this method was called without arguments. + * + * @param actualArgs - The actual args that this method was called with. This + * method expects it to be an empty array. + */ + public toBeCalledWithoutArgs( + actualArgs: unknown[], + ): this { + this.verifyToBeCalledWithoutArgs( + actualArgs, + `Method "${this.#method_name}" was called with unexpected args.`, + `.verify("${this.method_name}").toBeCalledWithoutArgs()`, + ); + + return this; + } +} diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts index 692e3570..60d3662f 100644 --- a/tests/deno/unit/mod/spy_test.ts +++ b/tests/deno/unit/mod/spy_test.ts @@ -8,7 +8,7 @@ class Resource { return "Resource is running!"; } - // This method will be stubbed to return "stubbed", so during + // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. public methodThatGets() { @@ -16,7 +16,7 @@ class Resource { return "Do GET"; } - // This method will be stubbed to return "stubbed", so during + // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. public methodThatPosts() { @@ -32,7 +32,7 @@ class ResourceParameterized { return message; } - // This method will be stubbed to return "stubbed", so during + // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. public methodThatGets(paramString1: string, paramString2: string) { @@ -40,7 +40,7 @@ class ResourceParameterized { return "Do GET"; } - // This method will be stubbed to return "stubbed", so during + // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. public methodThatPosts( @@ -60,33 +60,36 @@ Deno.test("Spy()", async (t) => { assertEquals(spy.is_spy, true); }); - await t.step('stubs all data members with "stubbed" return value', () => { - const spy = Spy(Resource); - assertEquals(spy.methodThatLogs(), "stubbed"); - assertEquals(spy.methodThatGets(), "stubbed"); - assertEquals(spy.methodThatPosts(), "stubbed"); - const spy2 = Spy(Resource); - const stubbedReturnValue = spy2.methodThatLogs(); // We called it, ... - spy2.verify("methodThatLogs").toBeCalled(); // ... so we can verify it was called ... - }); + await t.step( + 'stubs all data members with "spy-stubbed" return value', + () => { + const spy = Spy(Resource); + assertEquals(spy.methodThatLogs(), "spy-stubbed"); + assertEquals(spy.methodThatGets(), "spy-stubbed"); + assertEquals(spy.methodThatPosts(), "spy-stubbed"); + const spy2 = Spy(Resource); + const stubbedReturnValue = spy2.methodThatLogs(); // We called it, ... + spy2.verify("methodThatLogs").toBeCalled(); // ... so we can verify it was called ... + }, + ); await t.step("can verify calls for non-parameterized methods", () => { const spy = Spy(Resource); // Verify that `methodThatLogs` was called once (because we called it here) const stubbedRetVal1 = spy.methodThatLogs(); - assertEquals(stubbedRetVal1, "stubbed"); // All spies have stubbed methods + assertEquals(stubbedRetVal1, "spy-stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs").toBeCalled(1); // Verify that `methodThatLogs()` was still only called once since // `methodThatGets()` is stubbed const stubbedRetVal2 = spy.methodThatGets(); - assertEquals(stubbedRetVal2, "stubbed"); // All spies have stubbed methods + assertEquals(stubbedRetVal2, "spy-stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs").toBeCalled(1); // Verify that `methodThatLogs()` was still only called once since // `methodThatPosts()` is stubbed const stubbedRetVal3 = spy.methodThatPosts(); - assertEquals(stubbedRetVal3, "stubbed"); // All spies have stubbed methods + assertEquals(stubbedRetVal3, "spy-stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs").toBeCalled(1); }); @@ -94,7 +97,7 @@ Deno.test("Spy()", async (t) => { const spy = Spy(ResourceParameterized); // Verify that `methodThatLogs` was called once (because we called it here) const stubbedRetVal1 = spy.methodThatLogs("hello"); - assertEquals(stubbedRetVal1, "stubbed"); // All spies have stubbed methods + assertEquals(stubbedRetVal1, "spy-stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs") .toBeCalled(1) .toBeCalledWithArgs("hello"); @@ -102,7 +105,7 @@ Deno.test("Spy()", async (t) => { // Verify that `methodThatLogs()` was still only called once since // `methodThatGets()` is stubbed const stubbedRetVal2 = spy.methodThatGets("hello", "world"); - assertEquals(stubbedRetVal2, "stubbed"); // All spies have stubbed methods + assertEquals(stubbedRetVal2, "spy-stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs") .toBeCalled(1) .toBeCalledWithArgs("hello"); @@ -113,7 +116,7 @@ Deno.test("Spy()", async (t) => { // Verify that `methodThatLogs()` was still only called once since // `methodThatPosts()` is stubbed const stubbedRetVal3 = spy.methodThatPosts(true, false, ["test"]); - assertEquals(stubbedRetVal3, "stubbed"); // All spies have stubbed methods + assertEquals(stubbedRetVal3, "spy-stubbed"); // All spies have stubbed methods spy.verify("methodThatLogs") .toBeCalled(1) .toBeCalledWithArgs("hello"); @@ -127,7 +130,7 @@ Deno.test("Spy()", async (t) => { await t.step("can stub the class method", () => { const resource = new Resource(); Spy(resource, "methodThatPosts"); - assertEquals(resource.methodThatPosts(), "stubbed"); + assertEquals(resource.methodThatPosts(), "spy-stubbed"); }); await t.step( @@ -144,14 +147,14 @@ Deno.test("Spy()", async (t) => { const resource = new Resource(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( resource, "methodThatGets", ); // Fake's have working implementations, so we expect a real call - assertEquals(resource.methodThatGets(), "stubbed"); + assertEquals(resource.methodThatGets(), "spy-stubbed"); // However, since we are spying on the `methodThatGets()` method, // we can verify that it was called @@ -165,7 +168,7 @@ Deno.test("Spy()", async (t) => { const resource = new Resource(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( resource, "methodThatGets", @@ -189,7 +192,7 @@ Deno.test("Spy()", async (t) => { const resource = new ResourceParameterized(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( resource, "methodThatGets", @@ -225,7 +228,7 @@ Deno.test("Spy()", async (t) => { const resource = new ResourceParameterized(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( resource, "methodThatGets", @@ -262,7 +265,7 @@ Deno.test("Spy()", async (t) => { const fake = Fake(Resource).create(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy(fake, "methodThatGets", fake.methodThatGets()); // Fake's have working implementations, so we expect a real call @@ -280,7 +283,7 @@ Deno.test("Spy()", async (t) => { const fake = Fake(Resource).create(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( fake, "methodThatGets", @@ -314,7 +317,7 @@ Deno.test("Spy()", async (t) => { const fake = Fake(ResourceParameterized).create(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( fake, "methodThatGets", @@ -350,7 +353,7 @@ Deno.test("Spy()", async (t) => { const fake = Fake(ResourceParameterized).create(); // Now we're going to spy on the fake's `methodThatLogs()` method. - // We set the return value to what it would return originally so it does not return "stubbed" + // We set the return value to what it would return originally so it does not return "spy-stubbed" const spyMethod = Spy( fake, "methodThatGets", @@ -406,14 +409,15 @@ Deno.test("Spy()", async (t) => { // be used assertEquals(spyHello(), "spy-stubbed"); - // Here we give `hello` a new return value. The return value must have of - // the same type since we cannot reassign a new type. + // Here we give `hello` a new return value. The return value must be of + // the same return type. spyHello = Spy(hello, { some: "other return value" }); + // Assert that the specified return value is actually returned assertEquals(spyHello(), { some: "other return value" }); // Since we reassigned `spyHello`, its calls should be reset to 0 and we // should expect the number of calls to be 1 because we called it in the - // above `assertEquals()` call - spyHello.verify().toBeCalled(1); + // above `assertEquals()` call (also called without args) + spyHello.verify().toBeCalled().toBeCalledWithoutArgs(); }); }); }); From f15ab73776395c0bec4dff712d84b64983cebd1e Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Mon, 25 Apr 2022 20:27:41 -0400 Subject: [PATCH 10/31] chore: update build process to include all new files --- console/build_esm_lib.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/console/build_esm_lib.ts b/console/build_esm_lib.ts index 828693c8..cf6f1d27 100644 --- a/console/build_esm_lib.ts +++ b/console/build_esm_lib.ts @@ -7,14 +7,17 @@ const filesToRewrite = [ "tmp/conversion_workspace/src/fake/fake_builder.ts", "tmp/conversion_workspace/src/fake/fake_mixin.ts", "tmp/conversion_workspace/src/interfaces.ts", - "tmp/conversion_workspace/src/method_verifier.ts", "tmp/conversion_workspace/src/mock/mock_builder.ts", "tmp/conversion_workspace/src/mock/mock_mixin.ts", "tmp/conversion_workspace/src/pre_programmed_method.ts", "tmp/conversion_workspace/src/spy/spy_builder.ts", "tmp/conversion_workspace/src/spy/spy_mixin.ts", + "tmp/conversion_workspace/src/spy/spy_stub_builder.ts", "tmp/conversion_workspace/src/test_double_builder.ts", "tmp/conversion_workspace/src/types.ts", + "tmp/conversion_workspace/src/verifiers/callable_verifier.ts", + "tmp/conversion_workspace/src/verifiers/function_expression_verifier.ts", + "tmp/conversion_workspace/src/verifiers/method_verifier.ts", ]; for (const index in filesToRewrite) { @@ -24,8 +27,8 @@ for (const index in filesToRewrite) { let contents = decoder.decode(Deno.readFileSync(file)); // Step 2: Create an array of import/export statements from the contents - const importStatements = contents.match(/import.*";/g); - const exportStatements = contents.match(/export.*";/g); + const importStatements = contents.match(/(import.+\.ts";)|(import.+(\n\s.+)+\n.+\.ts";)/g); + const exportStatements = contents.match(/(export.+\.ts";)|(export.+(\n\s.+)+\n.+\.ts";)/g); // Step 3: Remove all .ts extensions from the import/export statements const newImportStatements = importStatements?.map((statement: string) => { From 00551a23436f3839f722422d999c5847c29c95f8 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Mon, 25 Apr 2022 21:31:19 -0400 Subject: [PATCH 11/31] chore: hella clean up -- docblocks; DRY; early returns; abstraction --- mod.ts | 105 +++---- src/errors.ts | 55 ++-- src/fake/fake_builder.ts | 2 +- src/interfaces.ts | 282 +++++++++++++----- src/mock/mock_builder.ts | 3 +- src/mock/mock_mixin.ts | 58 ++-- src/spy/spy_builder.ts | 8 +- src/spy/spy_mixin.ts | 30 +- src/spy/spy_stub_builder.ts | 141 ++++++--- src/test_double_builder.ts | 8 +- src/types.ts | 25 -- src/verifiers/callable_verifier.ts | 192 ++++++++---- src/verifiers/function_expression_verifier.ts | 81 ++--- src/verifiers/method_verifier.ts | 31 +- tests/deno/unit/mod/mock_test.ts | 2 +- tests/deno/unit/mod/spy_test.ts | 9 +- 16 files changed, 637 insertions(+), 395 deletions(-) diff --git a/mod.ts b/mod.ts index cbea2731..5bd9b7c2 100644 --- a/mod.ts +++ b/mod.ts @@ -75,78 +75,67 @@ export function Mock(constructorFn: Constructor): MockBuilder { // FILE MARKER - SPY /////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// +/** + * Create a spy out of a function expression. + * + * @param functionExpression - The function expression to turn into a spy. + * @param returnValue - (Optional) The value the spy should return when called. + * Defaults to "spy-stubbed". + * + * @returns The original function expression with spying capabilities. + */ export function Spy( // deno-lint-ignore no-explicit-any - fn: (...args: any[]) => ReturnValue, - returnValue?: ReturnValue + functionExpression: (...args: any[]) => ReturnValue, + returnValue?: ReturnValue, // deno-lint-ignore no-explicit-any ): Interfaces.ISpyStubFunctionExpression & ((...args: any[]) => ReturnValue); /** - * Create spy out of a class. Example: - * - * ```ts - * const spy = Spy(MyClass); - * const stubbedReturnValue = spy.someMethod(); // We called it, ... - * spy.verify("someMethod").toBeCalled(); // ... so we can verify it was called ... - * console.log(stubbedReturnValue === "stubbed"); // ... and that the return value is stubbed - * ``` + * Create a spy out of an object's method. * - * @param constructorFn - The constructor function to create a spy out of. This - * can be `class Something{ }` or `function Something() { }`. + * @param obj - The object containing the method to spy on. + * @param dataMember - The method to spy on. + * @param returnValue - (Optional) The value the spy should return when called. + * Defaults to "spy-stubbed". * - * @returns Instance of `Spy`, which is an extension of the o. + * @returns The original method with spying capabilities. */ -export function Spy( - constructorFn: Constructor -): Interfaces.ISpy & OriginalClass; +export function Spy( + obj: OriginalObject, + dataMember: MethodOf, + returnValue?: ReturnValue, +): Interfaces.ISpyStubMethod; /** - * Create a spy out of an object's data member. Example: - * - * ```ts - * const testSubject = new MyClass(); - * const spyMethod = Spy(testSubject, "doSomething"); - * // or const spyMethod = Spy(testSubject, "doSomething", "some return value"); - * - * spyMethod.verify().toNotBeCalled(); // We can verify it was not called yet - * - * testSubject.doSomething(); // Now we called it, ... - * spyMethod.verify().toBeCalled(); // ... so we can verify it was called - * ``` + * Create spy out of a class. * - * @param obj - The object containing the data member to spy on. - * @param dataMember - The data member to spy on. - * @param returnValue - (Optional) Make the data member return a specific value. - * Defaults to "stubbed" if not specified. + * @param constructorFn - The constructor function of the object to spy on. * - * @returns A spy stub that can be verified. + * @returns The original object with spying capabilities. */ -export function Spy( - obj: OriginalObject, - dataMember: MethodOf, - returnValue?: ReturnValue -): Interfaces.ISpyStub; +export function Spy( + constructorFn: Constructor, +): Interfaces.ISpy & OriginalClass; /** * Create a spy out of a class, class method, or function. * * Per Martin Fowler (based on Gerard Meszaros), "Spies are stubs that also - * record some information based on how they were called. One form of this might be an email service that records how many messages it was sent." - * - * @param obj - (Optional) The object receiving the stub. Defaults to a stub - * function. - * @param arg2 - (Optional) The data member on the object to be stubbed. - * Only used if `obj` is an object. - * @param arg3 - (Optional) What the stub should return. Defaults to - * "stubbed" for class properties and a function that returns "stubbed" for - * class methods. Only used if `object` is an object and `dataMember` is a - * member of that object. + * record some information based on how they were called. One form of this might + * be an email service that records how many messages it was sent." + * + * @param obj - The object to turn into a spy. + * @param methodOrReturnValue - (Optional) If creating a spy out of an object's method, then + * this would be the method name. If creating a spy out of a function + * expression, then this would be the return value. + * @param returnValue - (Optional) If creating a spy out of an object's method, then + * this would be the return value. */ export function Spy( obj: unknown, - arg2?: unknown, - arg3?: unknown + methodOrReturnValue?: unknown, + returnValue?: unknown, ): unknown { if (typeof obj === "function") { // If the function has the prototype field, the it's a constructor function. @@ -167,20 +156,16 @@ export function Spy( // Not that function declarations (e.g., function hello() { }) will have // "prototype" and will go through the SpyBuilder() flow above. return new SpyStubBuilder(obj as OriginalObject) - .returnValue(arg2 as ReturnValue) + .returnValue(methodOrReturnValue as ReturnValue) .createForFunctionExpression(); } // If we get here, then we are not spying on a class or function. We must be // spying on an object's method. - if (arg2 !== undefined) { - return new SpyStubBuilder(obj as OriginalObject) - .method(arg2 as MethodOf) - .returnValue(arg3 as ReturnValue) - .createForObjectMethod(); - } - - throw new Error(`Incorrect use of Spy().`); + return new SpyStubBuilder(obj as OriginalObject) + .method(methodOrReturnValue as MethodOf) + .returnValue(returnValue as ReturnValue) + .createForObjectMethod(); } //////////////////////////////////////////////////////////////////////////////// @@ -236,7 +221,7 @@ export function Stub( // If we get here, then we know for a fact that we are stubbing object // properties. Also, we do not care if `returnValue` was passed in here. If it - // is not passed in, then `returnValue` defaults to "stubbed". Otherwise, use + // is not passed in, then `returnValue` defaults to "spy-stubbed". Otherwise, use // the value of `returnValue`. if (typeof obj === "object" && dataMember !== undefined) { // If we are stubbing a method, then make sure the method is still callable diff --git a/src/errors.ts b/src/errors.ts index b987bce4..1cd16fff 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -19,12 +19,30 @@ export class FakeError extends RhumError { } } +/** + * Error to thrown in relation to mock logic. + */ +export class MockError extends RhumError { + constructor(message: string) { + super("MockError", message); + } +} + +/** + * Error to throw in relation to spy logic. + */ +export class SpyError extends RhumError { + constructor(message: string) { + super("SpyError", message); + } +} + /** * Error to throw in relation to method verification logic. For example, when a * method is being verified that it was called once via * `mock.method("doSomething").toBeCalled(1)`. */ -export class MethodVerificationError extends RhumError { +export class VerificationError extends RhumError { #actual_results: string; #code_that_threw: string; #expected_results: string; @@ -44,7 +62,7 @@ export class MethodVerificationError extends RhumError { actualResults: string, expectedResults: string, ) { - super("MethodVerificationError", message); + super("VerificationError", message); this.#code_that_threw = codeThatThrew; this.#actual_results = actualResults; this.#expected_results = expectedResults; @@ -63,7 +81,9 @@ export class MethodVerificationError extends RhumError { const ignoredLines = [ "", "deno:runtime", + "callable_verifier.ts", "method_verifier.ts", + "function_expression_verifier.ts", "_mixin.ts", ".toBeCalled", ".toBeCalledWithArgs", @@ -87,8 +107,13 @@ export class MethodVerificationError extends RhumError { return false; }); - // Sometimes, the error stack will contain the problematic file twice. We only care about showing the problematic file once in this "concise" stack. - // In order to check for this, we check to see if the array contains more than 2 values. The first value should be the MethodVerificationError message. The second value should be the first instance of the problematic file. Knowing this, we can slice the array to contain only the error message and the first instance of the problematic file. + // Sometimes, the error stack will contain the problematic file twice. We + // only care about showing the problematic file once in this "concise" + // stack. In order to check for this, we check to see if the array contains + // more than 2 values. The first value should be the `VerificationError` + // message. The second value should be the first instance of the problematic + // file. Knowing this, we can slice the array to contain only the error + // message and the first instance of the problematic file. if (conciseStackArray.length > 2) { conciseStackArray = conciseStackArray.slice(0, 2); } @@ -111,9 +136,9 @@ export class MethodVerificationError extends RhumError { newStack += `\n ${this.#expected_results}`; if (lineNumber) { - newStack += `\n\nCheck the above '${ + newStack += `\n\nCheck the above "${ filename.replace("/", "") - }' file at/around line ${lineNumber} for the following code to fix this error:`; + }" file at/around line ${lineNumber} for code like the following to fix this error:`; newStack += `\n ${this.#code_that_threw}`; } newStack += "\n\n\n"; // Give spacing when displayed in the console @@ -121,21 +146,3 @@ export class MethodVerificationError extends RhumError { this.stack = newStack; } } - -/** - * Error to thrown in relation to mock logic. - */ -export class MockError extends RhumError { - constructor(message: string) { - super("MockError", message); - } -} - -/** - * Error to throw in relation to spy logic. - */ -export class SpyError extends RhumError { - constructor(message: string) { - super("SpyError", message); - } -} diff --git a/src/fake/fake_builder.ts b/src/fake/fake_builder.ts index 24125710..ae28c612 100644 --- a/src/fake/fake_builder.ts +++ b/src/fake/fake_builder.ts @@ -17,7 +17,7 @@ export class FakeBuilder extends TestDoubleBuilder { /** * Create the fake object. * - * @returns The original object with capabilities from the fake class. + * @returns The original object with faking capabilities. */ public create(): ClassToFake & IFake { const original = new this.constructor_fn(...this.constructor_args); diff --git a/src/interfaces.ts b/src/interfaces.ts index a810bb1d..f883fb62 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,64 +1,144 @@ import type { MethodOf, MockMethodCalls } from "./types.ts"; -export interface IMethodExpectation { - toBeCalled(expectedCalls: number): void; - // toBeCalledWithArgs(...args: unknown[]): this; +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IERROR //////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface that all errors must follow. This is useful when a client wants to + * throw a custom error class via `.willThrow()` for mocks and fakes. + */ +export interface IError { + /** + * The name of the error (shown before the error message when thrown). + * Example: `ErrorName: `. + */ + name: string; + + /** + * The error message. + */ + message?: string; } -export interface IMethodVerification { +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IFAKE ///////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +export interface IFake { + /** + * Helper property to show that this object is a fake. + */ + is_fake: boolean; + /** - * Verify that this method was called. Optionally, verify that it was called a - * specific number of times. + * Access the method shortener to make the given method take a shortcut. * - * @param expectedCalls - (Optional) The number of calls this method is - * expected to have received. If not provided, then the verification process - * will assume "just verify that the method was called" instead of verifying - * that it was called a specific number of times. + * @param method - The name of the method to shorten. + */ + method( + method: MethodOf, + ): IMethodChanger; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IFUNCTIONEXPRESSIONVERIFIER /////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface of verifier that verifies function expression calls. + */ +export interface IFunctionExpressionVerifier extends IVerifier { + /** + * The args used when called. + */ + args: unknown[]; + + /** + * The number of calls made. + */ + calls: number; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IMETHODEXPECTATION //////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +export interface IMethodExpectation { + /** + * Set an expectation that the given number of expected calls should be made. + * + * @param expectedCalls - (Optional) The number of expected calls. If not + * specified, then verify that there was at least one call. * * @returns `this` To allow method chaining. */ toBeCalled(expectedCalls?: number): this; - toBeCalledWithArgs(firstArg: unknown, ...restOfArgs: unknown[]): this; + + /** + * Set an expectation that the given args should be used during a method call. + * + * @param requiredArg - Used to make this method require at least one arg. + * @param restOfArgs - Any other args to check during verification. + * + * @returns `this` To allow method chaining. + */ + toBeCalledWithArgs(requiredArg: unknown, ...restOfArgs: unknown[]): this; + + /** + * Set an expectation that a method call should be called without args. + * + * @returns `this` To allow method chaining. + */ toBeCalledWithoutArgs(): this; } -export interface IPreProgrammedMethod { - willReturn(returnValue: ReturnValue): void; - willThrow(error: IError): void; -} +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IMETHODCHANGER //////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// -export interface IError { +export interface IMethodChanger { /** - * The name of the error (shown before the error message when thrown). - * Example: `ErrorName: `. + * Make the given method return the given `returnValue`. + * + * @param returnValue - The value to make the method return. */ - name: string; + willReturn(returnValue: T): void; /** - * The error message. + * Make the given method throw the given error. + * + * @param error - An error which extends the `Error` class or has the same + * interface as the `Error` class. */ - message?: string; + willThrow(error: IError & T): void; } -export interface IFake { +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IMETHODVERIFIER /////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface of verifier that verifies method calls. + */ +export interface IMethodVerifier extends IVerifier { /** - * Helper property to show that this object is a fake. + * Property to hold the args used when the method using this verifier was + * called. */ - is_fake: boolean; + args: unknown[]; /** - * Entry point to shortcut a method. Example: - * - * ```ts - * fake.method("methodName").willReturn(...); - * fake.method("methodName").willThrow(...); - * ``` + * Property to hold the number of times the method using this verifier was + * called. */ - method( - methodName: MethodOf, - ): IPreProgrammedMethod; + calls: number; } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IMOCK ///////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + export interface IMock { /** * Property to track method calls. @@ -71,71 +151,93 @@ export interface IMock { is_mock: boolean; /** - * Entry point to set an expectation on a method. Example: + * Access the method expectation creator to create an expectation for the + * given method. * - * ```ts - * mock.expects("doSomething").toBeCalled(1); // Expect to call it once - * mock.doSomething(); // Call it once - * mock.verifyExpectations(); // Verify doSomething() was called once - * ``` + * @param method - The name of the method to create an expectation for. */ expects( method: MethodOf, ): IMethodExpectation; /** - * Entry point to pre-program a method. Example: + * Access the method pre-programmer to change the behavior of the given method. * - * ```ts - * mock.method("methodName").willReturn(someValue); - * mock.method("methodName").willThrow(new Error("Nope.")); - * ``` + * @param method - The name of the method to pre-program. */ - method( - methodName: MethodOf, - ): IPreProgrammedMethod; + method( + method: MethodOf, + ): IMethodChanger; /** - * Call this method after setting expectations on a method. Example: - * - * ```ts - * mock.expects("doSomething").toBeCalled(1); // Expect to call it once - * mock.doSomething(); // Call it once - * mock.verifyExpectations(); // Verify doSomething() was called once - * ``` + * Verify that all expectations from the `.expects()` calls. */ verifyExpectations(): void; } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - ISPY ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + export interface ISpy { + /** + * Helper property to see that this is a spy object and not the original. + */ is_spy: boolean; - stubbed_methods: Record, ISpyStub>; + /** + * Property to track all stubbed methods. This property is used when calling + * `.verify("someMethod")`. The `.verify("someMethod")` call will return the + * `ISpyStubMethod` object via `stubbed_methods["someMethod"]`. + */ + stubbed_methods: Record, ISpyStubMethod>; + + /** + * Access the method verifier. + * + * @returns A verifier to verify method calls. + */ verify( - methodName: MethodOf, - ): IMethodVerification; + method: MethodOf, + ): IMethodVerifier; } -export interface ISpyStub { +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - ISPYSTUBFUNCTIONEXPRESSION //////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface for spies on function expressions. + */ +export interface ISpyStubFunctionExpression { /** - * Access the method verifier in order to call verification methods like `.toBeCalled()`. Example: + * Access the function expression verifier. * - * @example - * ```ts - * // Spying on an object's method - * const spy = Spy(obj, "someMethod"); - * obj.someMethod(); - * spy.verify().toBeCalled(); + * @returns A verifier to verify function expression calls. + */ + verify(): IFunctionExpressionVerifier; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - ISPYSTUBMETHOD //////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface for spies on object methods. + */ +export interface ISpyStubMethod { + /** + * Access the method verifier. * - * // Spy on a function - * const spy = Spy(someFunction); - * someFunction(); - * spy.verify().toBeCalled(); - * ``` + * @returns A verifier to verify method calls. */ - verify(): IMethodVerification; + verify(): IMethodVerifier; } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - ITESTDOUBLE /////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + export interface ITestDouble { init( original: OriginalObject, @@ -143,6 +245,40 @@ export interface ITestDouble { ): void; } -export interface ISpyStubFunctionExpression { - verify(): IMethodVerification; +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IVERIFIER ///////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Base interface for verifiers. + */ +export interface IVerifier { + /** + * Verify that calls were made. + * + * @param expectedCalls - (Optional) The number of expected calls. If not + * specified, then verify that there was at least one call. + * + * @returns `this` To allow method chaining. + */ + toBeCalled(expectedCalls?: number): this; + + /** + * Verify that the given args were used. Takes a rest parameter of args to use + * during verification. At least one arg is required to use this method, which + * is the `requiredArg` param. + * + * @param requiredArg - Used to make this method require at least one arg. + * @param restOfArgs - Any other args to check during verification. + * + * @returns `this` To allow method chaining. + */ + toBeCalledWithArgs(requiredArg: unknown, ...restOfArgs: unknown[]): this; + + /** + * Verify that no args were used. + * + * @returns `this` To allow method chaining. + */ + toBeCalledWithoutArgs(): this; } diff --git a/src/mock/mock_builder.ts b/src/mock/mock_builder.ts index 493d9e4a..89266b1d 100644 --- a/src/mock/mock_builder.ts +++ b/src/mock/mock_builder.ts @@ -19,7 +19,7 @@ export class MockBuilder extends TestDoubleBuilder { /** * Create the mock object. * - * @returns The original object with capabilities from the Mock class. + * @returns The original object with mocking capabilities. */ public create(): ClassToMock & IMock { const original = new this.constructor_fn(...this.constructor_args); @@ -99,6 +99,7 @@ export class MockBuilder extends TestDoubleBuilder { value: (...args: unknown[]) => { // Track that this method was called mock.calls[method]++; + // TODO: copy spy approach because we need mock.expected_args // Make sure the method calls its original self const methodToCall = diff --git a/src/mock/mock_mixin.ts b/src/mock/mock_mixin.ts index afafefed..db17cfa5 100644 --- a/src/mock/mock_mixin.ts +++ b/src/mock/mock_mixin.ts @@ -14,6 +14,16 @@ class MethodExpectation { */ #expected_calls?: number | undefined; + /** + * Property to hold the expected args this method should use. + */ + #expected_args?: unknown[] | undefined; + + /** + * Property to hold the args this method was called with. + */ + #args?: unknown[] | undefined; + /** * See `MethodVerifier#method_name`. */ @@ -29,7 +39,7 @@ class MethodExpectation { ////////////////////////////////////////////////////////////////////////////// /** - * @param methodName See `MethodVerifier#method_name`. + * @param methodName - See `MethodVerifier#method_name`. */ constructor(methodName: MethodOf) { this.#method_name = methodName; @@ -49,18 +59,28 @@ class MethodExpectation { ////////////////////////////////////////////////////////////////////////////// /** - * Set an expected number of calls. - * - * @param expectedCalls - (Optional) The number of calls to receive. Defaults - * to -1 to tell `MethodVerifier` to "just check that the method was called". + * See `IMethodExpectation.toBeCalled()`. */ - public toBeCalled(expectedCalls?: number): void { + public toBeCalled(expectedCalls?: number): this { this.#expected_calls = expectedCalls; + return this; + } + + /** + * See `IMethodExpectation.toBeCalledWithArgs()`. + */ + public toBeCalledWithArgs(...expectedArgs: unknown[]): this { + this.#expected_args = expectedArgs; + return this; } - // public verify(actualCalls: number, actualArgs: unknown[]): void { - // this.verifyCalls( - // } + /** + * See `IMethodExpectation.toBeCalledWithoutArgs()`. + */ + public toBeCalledWithoutArgs(): this { + this.#expected_args = undefined; + return this; + } /** * Verify all expected calls were made. @@ -68,10 +88,8 @@ class MethodExpectation { * @param actualCalls - The number of actual calls. */ public verifyCalls(actualCalls: number): void { - this.#verifier.toBeCalled( - actualCalls, - this.#expected_calls, - ); + this.#verifier.toBeCalled(actualCalls, this.#expected_calls); + this.#verifier.toBeCalledWithoutArgs(this.#args ?? []); } } @@ -132,10 +150,7 @@ export function createMock( } /** - * Create a method expectation, which is basically asserting calls. - * - * @param method - The method to create an expectation for. - * @returns A method expectation. + * See `IMock.expects()`. */ public expects( method: MethodOf, @@ -146,10 +161,7 @@ export function createMock( } /** - * Pre-program a method on the original to return a specific value. - * - * @param methodName The method name on the original. - * @returns A pre-programmed method that will be called instead of original. + * See `IMock.method()`. */ public method( methodName: MethodOf, @@ -176,7 +188,7 @@ export function createMock( } /** - * Verify all expectations created in this mock. + * See `IMock.verifyExpectations()`. */ public verifyExpectations(): void { this.#expectations.forEach((e: MethodExpectation) => { @@ -191,8 +203,10 @@ export function createMock( /** * Construct the calls property. Only construct it, do not set it. The * constructor will set it. + * * @param methodsToTrack - All of the methods on the original object to make * trackable. + * * @returns - Key-value object where the key is the method name and the value * is the number of calls. All calls start at 0. */ diff --git a/src/spy/spy_builder.ts b/src/spy/spy_builder.ts index 8421364a..9b6b7d4c 100644 --- a/src/spy/spy_builder.ts +++ b/src/spy/spy_builder.ts @@ -8,11 +8,11 @@ import { TestDoubleBuilder } from "../test_double_builder.ts"; * create a spy object. Its `create()` method returns an instance of `Spy`, * which is basically an original object with stubbed data members. * - * This builder differs from the `SpyStub` because it stubs out the - * entire class, whereas the `SpyStub` stubs specific data members. + * This builder differs from the `SpyStub` because it stubs out the entire + * class, whereas the `SpyStub` stubs specific data members. * - * Under the hood, this builder uses `SpyStub` to stub the data members - * in the class. + * Under the hood, this builder uses `SpyStub` to stub the data members in the + * class. */ export class SpyBuilder extends TestDoubleBuilder { ////////////////////////////////////////////////////////////////////////////// diff --git a/src/spy/spy_mixin.ts b/src/spy/spy_mixin.ts index cafdc4d9..9ca03031 100644 --- a/src/spy/spy_mixin.ts +++ b/src/spy/spy_mixin.ts @@ -1,5 +1,5 @@ import type { Constructor, MethodOf } from "../types.ts"; -import type { IMethodVerification, ISpy, ISpyStub } from "../interfaces.ts"; +import type { IMethodVerifier, ISpy, ISpyStubMethod } from "../interfaces.ts"; import { SpyStubBuilder } from "./spy_stub_builder.ts"; /** @@ -14,16 +14,17 @@ export function createSpy( >; return new class SpyExtension extends Original { /** - * Helper property to see that this is a mock object and not the original. + * See `ISpy#is_spy`. */ is_spy = true; /** - * Property of stubbed methods. Each stubbed method has tracking (e.g., `spy.verify("someMethod").toBeCalled()`. + * See `ISpy#stubbed_methods`. */ - #stubbed_methods!: Record, ISpyStub>; + #stubbed_methods!: Record, ISpyStubMethod>; + /** - * The original object that this class creates a mock of. + * The original object that this class creates a spy out of. */ #original!: OriginalObject; @@ -31,7 +32,7 @@ export function createSpy( // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// - get stubbed_methods(): Record, ISpyStub> { + get stubbed_methods(): Record, ISpyStubMethod> { return this.#stubbed_methods; } @@ -40,7 +41,7 @@ export function createSpy( //////////////////////////////////////////////////////////////////////////// /** - * @param original - The original object to mock. + * @param original - The original object to create a spy out of. * @param methodsToTrack - The original object's method to make trackable. */ public init(original: OriginalObject, methodsToTrack: string[]) { @@ -51,11 +52,11 @@ export function createSpy( } /** - * Get the verifier for the given method to do actual verification using + * Get the verifier for the given method to do actual verification. */ public verify( methodName: MethodOf, - ): IMethodVerification { + ): IMethodVerifier { return this.#stubbed_methods[methodName].verify(); } @@ -66,16 +67,18 @@ export function createSpy( /** * Construct the calls property. Only construct it, do not set it. The * constructor will set it. + * * @param methodsToTrack - All of the methods on the original object to make * trackable. + * * @returns - Key-value object where the key is the method name and the * value is the number of calls. All calls start at 0. */ #constructStubbedMethodsProperty( methodsToTrack: string[], - ): Record, ISpyStub> { + ): Record, ISpyStubMethod> { const stubbedMethods: Partial< - Record, ISpyStub> + Record, ISpyStubMethod> > = {}; methodsToTrack.forEach((method: string) => { @@ -86,7 +89,10 @@ export function createSpy( stubbedMethods[method as MethodOf] = spyMethod; }); - return stubbedMethods as Record, ISpyStub>; + return stubbedMethods as Record< + MethodOf, + ISpyStubMethod + >; } }(); } diff --git a/src/spy/spy_stub_builder.ts b/src/spy/spy_stub_builder.ts index 1dfc9277..745aeb8e 100644 --- a/src/spy/spy_stub_builder.ts +++ b/src/spy/spy_stub_builder.ts @@ -1,20 +1,38 @@ import type { MethodOf } from "../types.ts"; -import type { IMethodVerification, ISpyStub } from "../interfaces.ts"; +import type { + IFunctionExpressionVerifier, + IMethodVerifier, + ISpyStubFunctionExpression, + ISpyStubMethod, +} from "../interfaces.ts"; import { MethodVerifier } from "../verifiers/method_verifier.ts"; import { FunctionExpressionVerifier } from "../verifiers/function_expression_verifier.ts"; +/** + * This class helps verifying function expression calls. It's a wrapper for + * `FunctionExpressionVerifier` only to make the verification methods (e.g., + * `toBeCalled()`) have a shorter syntax -- allowing the user of this library to + * only pass in expected calls as opposed to expected calls and actual calls. + */ class SpyStubFunctionExpressionVerifier extends FunctionExpressionVerifier { - #calls: number; + /** + * See `IFunctionExpressionVerifier.args`. + */ #args: unknown[]; + /** + * See `IFunctionExpressionVerifier.calls`. + */ + #calls: number; + ////////////////////////////////////////////////////////////////////////////// // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// /** - * @param name - The name of the function using this verifier. - * @param calls - See `this.#calls`. - * @param args - See `this.#args`. + * @param name - See `FunctionExpressionVerifier.#name`. + * @param calls - See `IFunctionExpressionVerifier.calls`. + * @param args - See `IFunctionExpressionVerifier.args`. */ constructor( name: string, @@ -26,6 +44,25 @@ class SpyStubFunctionExpressionVerifier extends FunctionExpressionVerifier { this.#args = args; } + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - GETTERS / SETTERS ///////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get args(): unknown[] { + return this.#args; + } + + get calls(): number { + return this.#calls; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * See `IVerifier.toBeCalled()`. + */ public toBeCalled(expectedCalls?: number): this { return super.toBeCalled( this.#calls, @@ -33,37 +70,42 @@ class SpyStubFunctionExpressionVerifier extends FunctionExpressionVerifier { ); } + /** + * See `IVerifier.toBeCalledWithArgs()`. + */ public toBeCalledWithArgs(...expectedArgs: unknown[]): this { return super.toBeCalledWithArgs( this.#args, expectedArgs, ); } + + /** + * See `IVerifier.toBeCalledWithoutArgs()`. + */ public toBeCalledWithoutArgs() { super.toBeCalledWithoutArgs( this.#args, - `.verify().toBeCalledWithoutArgs()`, ); return this; } } /** - * The `SpyStub` class' verifier. It extends the `MethodVerifier` just to use - * its verification methods. In order to properly show stack traces in the - * context of the `SpyStub`, this verifier is used to provide the - * `codeThatThrew` argument to the `MethodVerifier` class' methods. + * This class helps verifying object method calls. It's a wrapper for + * `MethodVerifier` only to make the verification methods (e.g., `toBeCalled()`) + * have a shorter syntax -- allowing the user of this library to only pass in + * expected calls as opposed to expected calls and actual calls. */ -class SpyStubMethodVerifier< - OriginalObject, -> extends MethodVerifier { +class SpyStubMethodVerifier + extends MethodVerifier { /** - * Property to hold the arguments this method was called with. + * See `IMethodVerifier.args`. */ #args: unknown[]; /** - * Property to hold the number of time this method was called. + * See `IMethodVerifier.calls`. */ #calls: number; @@ -72,9 +114,9 @@ class SpyStubMethodVerifier< ////////////////////////////////////////////////////////////////////////////// /** - * @param methodName - See `MethodVerifier#method_name`. - * @param calls - See `this.#calls`. - * @param args - See `this.#args`. + * @param methodName - See `MethodVerifier.method_name`. + * @param calls - See `IMethodVerifier.calls`. + * @param args - See `IMethodVerifier.args`. */ constructor( methodName: MethodOf, @@ -103,15 +145,7 @@ class SpyStubMethodVerifier< ////////////////////////////////////////////////////////////////////////////// /** - * Verify that this method was called. Optionally, verify that it was called a - * specific number of times. - * - * @param expectedCalls - (Optional) The number of calls this method is - * expected to have received. If not provided, then the verification process - * will assume "just verify that the method was called" instead of verifying - * that it was called a specific number of times. - * - * @returns this To allow method chaining. + * See `IVerifier.toBeCalled()`. */ public toBeCalled(expectedCalls?: number): this { return super.toBeCalled( @@ -121,12 +155,7 @@ class SpyStubMethodVerifier< } /** - * Verify that this method was called with the following arguments. - * - * @param expectedArgs - The expected arguments that this method should be - * called with. - * - * @returns this To allow method chaining. + * See `IVerifier.toBeCalledWithArgs()`. */ public toBeCalledWithArgs(...expectedArgs: unknown[]): this { return super.toBeCalledWithArgs( @@ -136,9 +165,7 @@ class SpyStubMethodVerifier< } /** - * Verify that this method was called without arguments. - * - * @returns this To allow method chaining. + * See `IVerifier.toBeCalledWithoutArgs()`. */ public toBeCalledWithoutArgs(): this { return super.toBeCalledWithoutArgs( @@ -160,7 +187,7 @@ export class SpyStubBuilder { #calls = 0; /** - * Property to hold the arguments this method was last called with. + * Property to hold the args this method was last called with. */ #last_called_with_args: unknown[] = []; @@ -194,17 +221,42 @@ export class SpyStubBuilder { this.#original = original; } + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Set the name of the method this spy stub is stubbing. + * + * @param method - The name of the method. This must be a method of the + * original object. + * + * @returns `this` To allow method chaining. + */ public method(method: MethodOf): this { this.#method = method; return this; } + /** + * Set the return value of this spy stub. + * + * @param returnValue - The value to return when this spy stub is called. + * + * @returns `this` To allow method chaining. + */ public returnValue(returnValue: ReturnValue): this { this.#return_value = returnValue; return this; } - public createForObjectMethod(): ISpyStub { + /** + * Create this spy stub for an object's method. + * + * @returns `this` behind the `ISpyStubMethod` interface so that only + * `ISpyStubMethod` data members can be seen/called. + */ + public createForObjectMethod(): ISpyStubMethod { this.#stubOriginalMethodWithTracking(); Object.defineProperty(this, "verify", { @@ -216,22 +268,29 @@ export class SpyStubBuilder { ), }); - return this as unknown as ISpyStub & { verify: IMethodVerification }; + return this as unknown as ISpyStubMethod & { verify: IMethodVerifier }; } - public createForFunctionExpression(): ISpyStub { + /** + * Create this spy stub for a function expression. + * + * @returns `this` behind the `ISpyStubFunctionExpression` interface so that + * only `ISpyStubFunctionExpression` data members can be seen/called. + */ + public createForFunctionExpression(): ISpyStubFunctionExpression { const ret = (...args: unknown[]) => { this.#calls++; this.#last_called_with_args = args; return this.#return_value ?? "spy-stubbed"; }; - ret.verify = () => + ret.verify = (): IFunctionExpressionVerifier => new SpyStubFunctionExpressionVerifier( (this.#original as unknown as { name: string }).name, this.#calls, this.#last_called_with_args, ); + return ret; } diff --git a/src/test_double_builder.ts b/src/test_double_builder.ts index b204a342..b20cac96 100644 --- a/src/test_double_builder.ts +++ b/src/test_double_builder.ts @@ -11,8 +11,8 @@ export class TestDoubleBuilder { protected constructor_fn: Constructor; /** - * A list of arguments the class constructor takes. This is used to - * instantiate the original with arguments (if needed). + * A list of args the class constructor takes. This is used to instantiate the + * original with args (if needed). */ protected constructor_args: unknown[] = []; @@ -43,7 +43,7 @@ export class TestDoubleBuilder { /** * Construct an object of this class. * - * @param constructorFn - See this#constructor_fn. + * @param constructorFn - See `this.constructor_fn`. */ constructor(constructorFn: Constructor) { this.constructor_fn = constructorFn; @@ -57,7 +57,7 @@ export class TestDoubleBuilder { * Before constructing the fake object, track any constructor function args * that need to be passed in when constructing the fake object. * - * @param args - A rest parameter of arguments that will get passed in to the + * @param args - A rest parameter of args that will get passed in to the * constructor function of the object being faked. * * @returns `this` so that methods in this class can be chained. diff --git a/src/types.ts b/src/types.ts index 9ae6c4a9..ce313ce5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,15 +13,6 @@ export type Constructor = new (...args: any[]) => T; * * This is a record where the key is the method that was called and the value is * the number of times the method was called. - * - * @example - * ```ts - * { - * someMethod: 0, - * someOtherMethod: 3, - * someOtherOtherMethod: 2, - * } - * ``` */ export type MockMethodCalls = Record; @@ -33,22 +24,6 @@ export type MockMethodCalls = Record; * Describes the type as a method of the given generic `Object`. * * This is used for type-hinting in places like `.verify("someMethod")`. - * - * @example - * ```ts - * class Hello { - * some_property = true; - * someMethod(): boolean { return true;} - * } - * - * function callMethod(method: MethodOf) { ... } - * - * callMethod("someMethod") // Ok - * - * // Shows the following error: Argument of type '"some_property"' is not - * // assignable to parameter of type 'MethodOf' - * callMethod("some_property") - * ``` */ export type MethodOf = { // deno-lint-ignore no-explicit-any diff --git a/src/verifiers/callable_verifier.ts b/src/verifiers/callable_verifier.ts index 83a97cab..d1950a69 100644 --- a/src/verifiers/callable_verifier.ts +++ b/src/verifiers/callable_verifier.ts @@ -1,13 +1,21 @@ -import { MethodVerificationError } from "../errors.ts"; +import { VerificationError } from "../errors.ts"; export class CallableVerifier { + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + /** - * Make a user friendly version of the expected args. This will be displayed - * in the `MethodVerificationError` stack trace. For example: + * Make a user friendly version of the args. This is for display in the + * `VerificationError` stack trace. For example, the original args ... + * + * [true, false, "hello"] * - * [true, false, "hello"] -> true, false, "hello" + * ... becomes ... * - * The above would result in the following stack trace message: + * true, false, "hello" + * + * The above will ultimately end up in stack trace messages like: * * .toBeCalledWith(true, false, "hello") * @@ -16,13 +24,30 @@ export class CallableVerifier { * "hello" string has its quotes missing: * * .toBeCalledWith([true, false, hello]) + * + * @param args - The args to convert to a string. + * + * @returns The args as a string. */ - protected argsAsString(expectedArgs: unknown[]): string { - return JSON.stringify(expectedArgs) + protected argsAsString(args: unknown[]): string { + return JSON.stringify(args) .slice(1, -1) .replace(/,/g, ", "); } + /** + * Same as `this.argsAsString()`, but add typings to the args. For example: + * + * [true, false, "hello"] + * + * ... becomes ... + * + * true, false, "hello" + * + * @param args - The args to convert to a string. + * + * @returns The args as a string with typings. + */ protected argsAsStringWithTypes(args: unknown[]): string { return args.map((arg: unknown) => { return `${JSON.stringify(arg)}${this.getArgType(arg)}`; @@ -47,42 +72,69 @@ export class CallableVerifier { return "<" + typeof arg + ">"; } + /** + * Verify that the number of actual calls matches the number of expected + * calls. + * + * @param actualCalls - The actual number of calls. + * @param expectedCalls - The expected number of calls. + * @param errorMessage - The error message to show in the stack trace. + * @param codeThatThrew - The code using this verification. + * + * @returns `this` To allow method chaining. + */ protected verifyToBeCalled( actualCalls: number, expectedCalls: number | undefined, errorMessage: string, codeThatThrew: string, ): void { + // If expected calls were not specified, then just check that the method was + // called at least once if (!expectedCalls) { - if (actualCalls <= 0) { - throw new MethodVerificationError( - errorMessage, - codeThatThrew, - `Expected calls -> 1 (or more)`, - `Actual calls -> 0`, - ); + if (actualCalls > 0) { + return; } - return; - } - if (actualCalls !== expectedCalls) { - throw new MethodVerificationError( + throw new VerificationError( errorMessage, codeThatThrew, - `Expected calls -> ${expectedCalls}`, - `Actual calls -> ${actualCalls}`, + `Expected calls -> 1 (or more)`, + `Actual calls -> 0`, ); } + + // If we get here, then we gucci. No need to process further. + if (actualCalls === expectedCalls) { + return; + } + + // If we get here, then the actual number of calls do not match the expected + // number of calls, so we should throw an error + throw new VerificationError( + errorMessage, + codeThatThrew, + `Expected calls -> ${expectedCalls}`, + `Actual calls -> ${actualCalls}`, + ); } - protected verifyToBeCalledWithArgsTooManyArguments( + /** + * Verify that the number of expected args is not more than the actual args. + * + * @param actualArgs - The actual args. + * @param expectedArgs - The expected args. + * @param errorMessage - The error message to show in the stack trace. + * @param codeThatThrew - The code using this verification. + */ + protected verifyToBeCalledWithArgsTooManyArgs( actualArgs: unknown[], expectedArgs: unknown[], errorMessage: string, codeThatThrew: string, ): void { if (expectedArgs.length > actualArgs.length) { - throw new MethodVerificationError( + throw new VerificationError( errorMessage, codeThatThrew, `Expected args -> ${this.argsAsStringWithTypes(expectedArgs)}`, @@ -95,14 +147,22 @@ export class CallableVerifier { } } - protected verifyToBeCalledWithArgsTooFewArguments( + /** + * Verify that the number of expected args is not less than the actual args. + * + * @param actualArgs - The actual args. + * @param expectedArgs - The expected args. + * @param errorMessage - The error message to show in the stack trace. + * @param codeThatThrew - The code using this verification. + */ + protected verifyToBeCalledWithArgsTooFewArgs( actualArgs: unknown[], expectedArgs: unknown[], errorMessage: string, codeThatThrew: string, ): void { if (expectedArgs.length < actualArgs.length) { - throw new MethodVerificationError( + throw new VerificationError( errorMessage, codeThatThrew, `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, @@ -111,6 +171,14 @@ export class CallableVerifier { } } + /** + * Verify that the expected args match the actual args by value and type. + * + * @param actualArgs - The actual args. + * @param expectedArgs - The expected args. + * @param errorMessage - The error message to show in the stack trace. + * @param codeThatThrew - The code using this verification. + */ protected verifyToBeCalledWithArgsUnexpectedValues( actualArgs: unknown[], expectedArgs: unknown[], @@ -119,50 +187,74 @@ export class CallableVerifier { ): void { expectedArgs.forEach((arg: unknown, index: number) => { const parameterPosition = index + 1; - if (actualArgs[index] !== arg) { - if (this.#comparingArrays(actualArgs[index], arg)) { - const match = this.#compareArrays( - actualArgs[index] as unknown[], - arg as unknown[], - ); - if (match) { - return; - } - } - const unexpectedArg = `\`${arg}${this.getArgType(arg)}\``; + // Args match? We gucci. + if (actualArgs[index] === arg) { + return; + } - throw new MethodVerificationError( - errorMessage - .replace("{{ unexpected_arg }}", unexpectedArg) - .replace("{{ parameter_position }}", parameterPosition.toString()), - codeThatThrew, - `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, - `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + // Args do not match? Check if we are comparing arrays and see if they + // match + if (this.#comparingArrays(actualArgs[index], arg)) { + const match = this.#compareArrays( + actualArgs[index] as unknown[], + arg as unknown[], ); + // Arrays match? We gucci. + if (match) { + return; + } } + + // Alright, we have an unexpected arg, so throw an error + const unexpectedArg = `\`${arg}${this.getArgType(arg)}\``; + + throw new VerificationError( + errorMessage + .replace("{{ unexpected_arg }}", unexpectedArg) + .replace("{{ parameter_position }}", parameterPosition.toString()), + codeThatThrew, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, + `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + ); }); } + /** + * Verify that the no args were used. + * + * @param actualArgs - The actual args (if any). + * @param errorMessage - The error message to show in the stack trace. + * @param codeThatThrew - The code using this verification. + */ protected verifyToBeCalledWithoutArgs( actualArgs: unknown[], errorMessage: string, codeThatThrew: string, - ): void { + ): this { const actualArgsAsString = JSON.stringify(actualArgs) .slice(1, -1) .replace(/,/g, ", "); - if (actualArgs.length > 0) { - throw new MethodVerificationError( - errorMessage, - codeThatThrew, - `Expected args -> (no args)`, - `Actual args -> (${actualArgsAsString})`, - ); + if (actualArgs.length === 0) { + return this; } + + // One arg? Say "arg". More than one arg? Say "args". Yaaaaaarg. + const argNoun = actualArgs.length > 1 ? "args" : "arg"; + + throw new VerificationError( + errorMessage, + codeThatThrew.replace("{{ arg_noun }}", argNoun), + `Expected args -> (no args)`, + `Actual args -> (${actualArgsAsString})`, + ); } + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + /** * Check that the given arrays are exactly equal. * diff --git a/src/verifiers/function_expression_verifier.ts b/src/verifiers/function_expression_verifier.ts index eb961095..74469359 100644 --- a/src/verifiers/function_expression_verifier.ts +++ b/src/verifiers/function_expression_verifier.ts @@ -1,10 +1,9 @@ -import { MethodVerificationError } from "../errors.ts"; import { CallableVerifier } from "./callable_verifier.ts"; /** * Test doubles use this class to verify that their methods were called, were - * called with a number of arguments, were called with specific types of - * arguments, and so on. + * called with a number of args, were called with specific types of args, and so + * on. */ export class FunctionExpressionVerifier extends CallableVerifier { /** @@ -17,7 +16,7 @@ export class FunctionExpressionVerifier extends CallableVerifier { ////////////////////////////////////////////////////////////////////////////// /** - * @param name - See this#name. + * @param name - See `this.#name`. */ constructor(name: string) { super(); @@ -64,92 +63,52 @@ export class FunctionExpressionVerifier extends CallableVerifier { * * @param actualArgs - The actual args that this method was called with. * @param expectedArgs - The args this method is expected to have received. - * @param codeThatThrew - See `MethodVerificationError` constructor's - * `codeThatThrew` param. */ protected toBeCalledWithArgs( actualArgs: unknown[], expectedArgs: unknown[], ): this { const expectedArgsAsString = this.argsAsString(expectedArgs); + const codeThatThrew = + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`; - this.verifyToBeCalledWithArgsTooManyArguments( + this.verifyToBeCalledWithArgsTooManyArgs( actualArgs, expectedArgs, - `Function "${this.#name}" received too many arguments.`, - `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + `Function "${this.#name}" received too many args.`, + codeThatThrew, ); - this.verifyToBeCalledWithArgsTooFewArguments( + this.verifyToBeCalledWithArgsTooFewArgs( actualArgs, expectedArgs, - `Function "${this.#name}" was called with ${actualArgs.length} ${ - actualArgs.length > 1 ? "args" : "arg" - } instead of ${expectedArgs.length}.`, - `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + `Function "${this.#name}" was called with ${actualArgs.length} {{ arg_noun }} instead of ${expectedArgs.length}.`, + codeThatThrew, ); this.verifyToBeCalledWithArgsUnexpectedValues( actualArgs, expectedArgs, `Function "${this.#name}" received unexpected arg {{ unexpected_arg }} at parameter position {{ parameter_position }}.`, - `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + codeThatThrew, ); return this; } /** - * Verify that this method was called without arguments. + * Verify that this method was called without args. * * @param actualArgs - The actual args that this method was called with. This * method expects it to be an empty array. - * @param codeThatThrew - See `MethodVerificationError` constructor's - * `codeThatThrew` param. */ - public toBeCalledWithoutArgs( + protected toBeCalledWithoutArgs( actualArgs: unknown[], - codeThatThrew: string, - ): void { - const actualArgsAsString = JSON.stringify(actualArgs) - .slice(1, -1) - .replace(/,/g, ", "); - - if (actualArgs.length > 0) { - throw new MethodVerificationError( - `Function "${this.#name}" was called with args when expected to receive no args.`, - codeThatThrew, - `Expected -> (no args)`, - `Actual -> (${actualArgsAsString})`, - ); - } - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Check that the given arrays are exactly equal. - * - * @param a - The first array. - * @param b - The second array (which should match the first array). - * - * @returns True if the arrays match, false if not. - */ - #compareArrays(a: unknown[], b: unknown[]): boolean { - return a.length === b.length && a.every((val, index) => val === b[index]); - } - - /** - * Are we comparing arrays? - * - * @param obj1 - Object to evaluate if it is an array. - * @param obj2 - Object to evaluate if it is an array. - * - * @returns True if yes, false if no. - */ - #comparingArrays(obj1: unknown, obj2: unknown): boolean { - return Array.isArray(obj1) && Array.isArray(obj2); + ): this { + return super.verifyToBeCalledWithoutArgs( + actualArgs, + `Function "${this.#name}" was called with args when expected to receive no args.`, + `.verify().toBeCalledWithoutArgs()`, + ); } } diff --git a/src/verifiers/method_verifier.ts b/src/verifiers/method_verifier.ts index 29ed8f8d..a290b5ff 100644 --- a/src/verifiers/method_verifier.ts +++ b/src/verifiers/method_verifier.ts @@ -1,11 +1,10 @@ import type { MethodOf } from "../types.ts"; -import { MethodVerificationError } from "../errors.ts"; import { CallableVerifier } from "./callable_verifier.ts"; /** * Test doubles use this class to verify that their methods were called, were - * called with a number of arguments, were called with specific types of - * arguments, and so on. + * called with a number of args, were called with specific types of args, and so + * on. */ export class MethodVerifier extends CallableVerifier { /** @@ -19,7 +18,7 @@ export class MethodVerifier extends CallableVerifier { ////////////////////////////////////////////////////////////////////////////// /** - * @param methodName - See this#method_name. + * @param methodName - See `this.#method_name`. */ constructor(methodName?: MethodOf) { super(); @@ -35,7 +34,7 @@ export class MethodVerifier extends CallableVerifier { } ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// /** @@ -45,6 +44,8 @@ export class MethodVerifier extends CallableVerifier { * @param expectedCalls - The number of calls expected. If this is -1, then * just verify that the method was called without checking how many times it * was called. + * + * @returns `this` To allow method chaining. */ public toBeCalled( actualCalls: number, @@ -66,44 +67,50 @@ export class MethodVerifier extends CallableVerifier { * * @param actualArgs - The actual args that this method was called with. * @param expectedArgs - The args this method is expected to have received. + * + * @returns `this` To allow method chaining. */ public toBeCalledWithArgs( actualArgs: unknown[], expectedArgs: unknown[], ): this { const expectedArgsAsString = this.argsAsString(expectedArgs); + const codeThatThrew = + `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`; - this.verifyToBeCalledWithArgsTooManyArguments( + this.verifyToBeCalledWithArgsTooManyArgs( actualArgs, expectedArgs, - `Method "${this.#method_name}" received too many arguments.`, - `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`, + `Method "${this.#method_name}" received too many args.`, + codeThatThrew, ); - this.verifyToBeCalledWithArgsTooFewArguments( + this.verifyToBeCalledWithArgsTooFewArgs( actualArgs, expectedArgs, `Method "${this.#method_name}" was called with ${actualArgs.length} ${ actualArgs.length > 1 ? "args" : "arg" } instead of ${expectedArgs.length}.`, - `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`, + codeThatThrew, ); this.verifyToBeCalledWithArgsUnexpectedValues( actualArgs, expectedArgs, `Method "${this.#method_name}" received unexpected arg {{ unexpected_arg }} at parameter position {{ parameter_position }}.`, - `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`, + codeThatThrew, ); return this; } /** - * Verify that this method was called without arguments. + * Verify that this method was called without args. * * @param actualArgs - The actual args that this method was called with. This * method expects it to be an empty array. + * + * @returns `this` To allow method chaining. */ public toBeCalledWithoutArgs( actualArgs: unknown[], diff --git a/tests/deno/unit/mod/mock_test.ts b/tests/deno/unit/mod/mock_test.ts index 1bf2c115..9f7dd9d8 100644 --- a/tests/deno/unit/mod/mock_test.ts +++ b/tests/deno/unit/mod/mock_test.ts @@ -245,7 +245,7 @@ Deno.test("Mock()", async (t) => { const mock = Mock(TestObjectThree).create(); assertEquals(mock.is_mock, true); - mock.expects("hello").toBeCalled(2); + mock.expects("hello").toBeCalled(2).toBeCalledWithoutArgs(); mock.test(); mock.verifyExpectations(); }, diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts index 60d3662f..a8a54d51 100644 --- a/tests/deno/unit/mod/spy_test.ts +++ b/tests/deno/unit/mod/spy_test.ts @@ -35,7 +35,7 @@ class ResourceParameterized { // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. - public methodThatGets(paramString1: string, paramString2: string) { + public methodThatGets(_paramString1: string, _paramString2: string) { this.methodThatLogs("Handle GET"); return "Do GET"; } @@ -44,9 +44,9 @@ class ResourceParameterized { // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. public methodThatPosts( - paramBool1: boolean, - paramBool2: boolean, - paramArray: string[], + _paramBool1: boolean, + _paramBool2: boolean, + _paramArray: string[], ) { this.methodThatLogs("Handle POSt"); return "Do POST"; @@ -70,6 +70,7 @@ Deno.test("Spy()", async (t) => { const spy2 = Spy(Resource); const stubbedReturnValue = spy2.methodThatLogs(); // We called it, ... spy2.verify("methodThatLogs").toBeCalled(); // ... so we can verify it was called ... + assertEquals(stubbedReturnValue, "spy-stubbed"); }, ); From 73f67a51a1d257d1561d34ff25c7ac4d5b72136d Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 06:59:32 -0400 Subject: [PATCH 12/31] feat(types): add Callable type to describe functions --- src/types.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/types.ts b/src/types.ts index ce313ce5..4e15f115 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,17 @@ /** - * Describes the type as a constructable object using the `new` keyword. + * Describes the type as something that is callable. */ // deno-lint-ignore no-explicit-any -export type Constructor = new (...args: any[]) => T; +export type Callable = (...args: any[]) => ReturnValue; //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////////// +/** + * Describes the type as a constructable object using the `new` keyword. + */ +// deno-lint-ignore no-explicit-any +export type Constructor = new (...args: any[]) => Class; /** * Describes the `MockExtension#calls` property. From 63de8a884600e90bb334a2a2b7f6b0a9f4a3631e Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 06:59:42 -0400 Subject: [PATCH 13/31] chore: remove file markers --- src/types.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/types.ts b/src/types.ts index 4e15f115..81b6320b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,9 +4,6 @@ // deno-lint-ignore no-explicit-any export type Callable = (...args: any[]) => ReturnValue; -//////////////////////////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////////////////////////// /** * Describes the type as a constructable object using the `new` keyword. */ @@ -21,10 +18,6 @@ export type Constructor = new (...args: any[]) => Class; */ export type MockMethodCalls = Record; -//////////////////////////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////////////////////////// - /** * Describes the type as a method of the given generic `Object`. * From a8da8453639561d204284cf334b9d065fd1e16b8 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 07:03:25 -0400 Subject: [PATCH 14/31] refactor(spies): improve type-hinting for function expression spy --- mod.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mod.ts b/mod.ts index 5bd9b7c2..a5a1cfe1 100644 --- a/mod.ts +++ b/mod.ts @@ -1,4 +1,4 @@ -import type { Constructor, MethodOf } from "./src/types.ts"; +import type { Callable, Constructor, MethodOf } from "./src/types.ts"; import { MockBuilder } from "./src/mock/mock_builder.ts"; import { FakeBuilder } from "./src/fake/fake_builder.ts"; import { SpyBuilder } from "./src/spy/spy_builder.ts"; @@ -84,12 +84,15 @@ export function Mock(constructorFn: Constructor): MockBuilder { * * @returns The original function expression with spying capabilities. */ -export function Spy( +export function Spy< + OriginalFunction extends Callable, + ReturnValue, +>( // deno-lint-ignore no-explicit-any - functionExpression: (...args: any[]) => ReturnValue, + functionExpression: OriginalFunction, returnValue?: ReturnValue, // deno-lint-ignore no-explicit-any -): Interfaces.ISpyStubFunctionExpression & ((...args: any[]) => ReturnValue); +): Interfaces.ISpyStubFunctionExpression & OriginalFunction; /** * Create a spy out of an object's method. From 60d72b15dc4062d97726ef00a05024f91583b3c6 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 07:04:18 -0400 Subject: [PATCH 15/31] test(spies): test parameterized function expressions --- tests/deno/unit/mod/spy_test.ts | 62 +++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts index a8a54d51..0db3c81f 100644 --- a/tests/deno/unit/mod/spy_test.ts +++ b/tests/deno/unit/mod/spy_test.ts @@ -386,7 +386,7 @@ Deno.test("Spy()", async (t) => { }, ); - await t.step("can spy on a function expression", () => { + await t.step("can spy on a function expression (non-parameterized)", () => { const hello = (): { some: string } => { return { some: "value", @@ -395,6 +395,8 @@ Deno.test("Spy()", async (t) => { let spyHello = Spy(hello); + // Create a function to call the spy to see if the spy works when not + // directly called function thingThatCallsTheSpy() { spyHello(); } @@ -413,12 +415,66 @@ Deno.test("Spy()", async (t) => { // Here we give `hello` a new return value. The return value must be of // the same return type. spyHello = Spy(hello, { some: "other return value" }); + // Assert that the specified return value is actually returned assertEquals(spyHello(), { some: "other return value" }); + // Since we reassigned `spyHello`, its calls should be reset to 0 and we - // should expect the number of calls to be 1 because we called it in the - // above `assertEquals()` call (also called without args) + // should expect the number of calls to be 1 because we called it above. + // Also, we called it without args. spyHello.verify().toBeCalled().toBeCalledWithoutArgs(); }); + + await t.step("can spy on a function expression (parameterized)", () => { + const hello = (param1: string, param2: boolean): { + param1Val: string; + param2Val: boolean; + } => { + return { + param1Val: param1, + param2Val: param2, + }; + }; + + let spyHello = Spy(hello); + + // Create a function to call the spy to see if the spy works when not + // directly called + function thingThatCallsTheSpy() { + spyHello("doth mother know you weareth her drapes", true); + } + + thingThatCallsTheSpy(); // Call 1 + spyHello.verify().toBeCalled(1); + + thingThatCallsTheSpy(); // Call 2 + thingThatCallsTheSpy(); // Call 3 + spyHello.verify().toBeCalled(3); + + // Since no return value was specified, the default "spy-stubbed" should + // be used + assertEquals( + spyHello("doth mother know you weareth her drapes", true), + "spy-stubbed", + ); + + // Here we give `hello` a new return value. The return value must be of + // the same return type. + spyHello = Spy(hello, { + param1Val: "YOU GOT CHANGED!", + param2Val: false, + }); + + // Assert that the specified return value is actually returned + assertEquals(spyHello("sup", true), { + param1Val: "YOU GOT CHANGED!", + param2Val: false, + }); + + // Since we reassigned `spyHello`, its calls should be reset to 0 and we + // should expect the number of calls to be 1 because we called it above. + // Also, we called it with args. + spyHello.verify().toBeCalled().toBeCalledWithArgs("sup", true); + }); }); }); From ce71033a21615c48041fa2ab27258c9db7c8146e Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 07:04:29 -0400 Subject: [PATCH 16/31] chore: semantics; comments --- mod.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/mod.ts b/mod.ts index a5a1cfe1..feebb6f0 100644 --- a/mod.ts +++ b/mod.ts @@ -128,7 +128,7 @@ export function Spy( * record some information based on how they were called. One form of this might * be an email service that records how many messages it was sent." * - * @param obj - The object to turn into a spy. + * @param original - The original to turn into a spy. * @param methodOrReturnValue - (Optional) If creating a spy out of an object's method, then * this would be the method name. If creating a spy out of a function * expression, then this would be the return value. @@ -136,19 +136,19 @@ export function Spy( * this would be the return value. */ export function Spy( - obj: unknown, + original: unknown, methodOrReturnValue?: unknown, returnValue?: unknown, ): unknown { - if (typeof obj === "function") { + if (typeof original === "function") { // If the function has the prototype field, the it's a constructor function. // // Examples: // class Hello { } // function Hello() { } // - if ("prototype" in obj) { - return new SpyBuilder(obj as Constructor).create(); + if ("prototype" in original) { + return new SpyBuilder(original as Constructor).create(); } // Otherwise, it's just a function. @@ -158,14 +158,14 @@ export function Spy( // // Not that function declarations (e.g., function hello() { }) will have // "prototype" and will go through the SpyBuilder() flow above. - return new SpyStubBuilder(obj as OriginalObject) + return new SpyStubBuilder(original as OriginalObject) .returnValue(methodOrReturnValue as ReturnValue) .createForFunctionExpression(); } // If we get here, then we are not spying on a class or function. We must be // spying on an object's method. - return new SpyStubBuilder(obj as OriginalObject) + return new SpyStubBuilder(original as OriginalObject) .method(methodOrReturnValue as MethodOf) .returnValue(returnValue as ReturnValue) .createForObjectMethod(); @@ -177,6 +177,8 @@ export function Spy( /** * Create a stub function that returns "stubbed". + * + * @returns A function that returns "stubbed". */ export function Stub(): () => "stubbed"; From ca20a868647816e00949ee040604c4dc780f9e27 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 07:54:19 -0400 Subject: [PATCH 17/31] chore: stop jest from running everything --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8d4ed42a..97e99100 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build:cjs": "tsc --project tsconfig.cjs.json", "build:esm": "tsc --project tsconfig.esm.json", "check": "rm -rf node_modules/ && rm yarn.lock && yarn install && yarn build && yarn test", - "test": "jest tests" + "test": "jest" }, "devDependencies": { "@babel/preset-env": "7.x", From ed897305e8fa85f41cd1bb211d02140c893e9b44 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 07:59:28 -0400 Subject: [PATCH 18/31] test(spies): cjs tests --- tests/cjs/jest_assertions.js | 16 + tests/cjs/unit/mod/spy.test.js | 515 +++++++++++++++++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 tests/cjs/jest_assertions.js create mode 100644 tests/cjs/unit/mod/spy.test.js diff --git a/tests/cjs/jest_assertions.js b/tests/cjs/jest_assertions.js new file mode 100644 index 00000000..ec8240a2 --- /dev/null +++ b/tests/cjs/jest_assertions.js @@ -0,0 +1,16 @@ +function assertEquals(actual, expected) { + expect(actual).toStrictEqual(expected); +} + +function assertThrows( + actual, + expected, + message, +) { + expect(actual).toThrow(new expected(message)); +} + +module.exports = { + assertEquals, + assertThrows, +}; diff --git a/tests/cjs/unit/mod/spy.test.js b/tests/cjs/unit/mod/spy.test.js new file mode 100644 index 00000000..ecd1508d --- /dev/null +++ b/tests/cjs/unit/mod/spy.test.js @@ -0,0 +1,515 @@ +const { Fake, Spy } = require("../../../../lib/cjs/mod"); +const { assertEquals } = require("../../jest_assertions"); + +class Resource { + greeting = "hello"; + + methodThatLogs() { + return "Resource is running!"; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + methodThatGets() { + this.methodThatLogs(); + return "Do GET"; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + methodThatPosts() { + this.methodThatLogs(); + return "Do POST"; + } +} + +class ResourceParameterized { + greeting = "hello"; + + methodThatLogs(message) { + return message; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + methodThatGets(_paramString1, _paramString2) { + this.methodThatLogs("Handle GET"); + return "Do GET"; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + methodThatPosts( + _paramBool1, + _paramBool2, + _paramArray, + ) { + this.methodThatLogs("Handle POSt"); + return "Do POST"; + } +} + +describe("Spy()", () => { + describe("can spy on a class", () => { + it("adds is_spy", () => { + const spy = Spy(Resource); + assertEquals(spy.is_spy, true); + }); + + it( + 'stubs all data members with "spy-stubbed" return value', + () => { + const spy = Spy(Resource); + assertEquals(spy.methodThatLogs(), "spy-stubbed"); + assertEquals(spy.methodThatGets(), "spy-stubbed"); + assertEquals(spy.methodThatPosts(), "spy-stubbed"); + const spy2 = Spy(Resource); + const stubbedReturnValue = spy2.methodThatLogs(); // We called it, ... + spy2.verify("methodThatLogs").toBeCalled(); // ... so we can verify it was called ... + assertEquals(stubbedReturnValue, "spy-stubbed"); + }, + ); + + it("can verify calls for non-parameterized methods", () => { + const spy = Spy(Resource); + // Verify that `methodThatLogs` was called once (because we called it here) + const stubbedRetVal1 = spy.methodThatLogs(); + assertEquals(stubbedRetVal1, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + const stubbedRetVal2 = spy.methodThatGets(); + assertEquals(stubbedRetVal2, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + const stubbedRetVal3 = spy.methodThatPosts(); + assertEquals(stubbedRetVal3, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs").toBeCalled(1); + }); + + it("can verify calls for parameterized methods", () => { + const spy = Spy(ResourceParameterized); + // Verify that `methodThatLogs` was called once (because we called it here) + const stubbedRetVal1 = spy.methodThatLogs("hello"); + assertEquals(stubbedRetVal1, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWithArgs("hello"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + const stubbedRetVal2 = spy.methodThatGets("hello", "world"); + assertEquals(stubbedRetVal2, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWithArgs("hello"); + spy.verify("methodThatGets") + .toBeCalled(1) + .toBeCalledWithArgs("hello", "world"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + const stubbedRetVal3 = spy.methodThatPosts(true, false, ["test"]); + assertEquals(stubbedRetVal3, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWithArgs("hello"); + spy.verify("methodThatPosts") + .toBeCalled(1) + .toBeCalledWithArgs(true, false, ["test"]); + }); + }); + + describe("can spy on a class method", () => { + it("can stub the class method", () => { + const resource = new Resource(); + Spy(resource, "methodThatPosts"); + assertEquals(resource.methodThatPosts(), "spy-stubbed"); + }); + + it( + "can stub the class method (with stubbed return value)", + () => { + const resource = new Resource(); + Spy(resource, "methodThatPosts", "hello"); + assertEquals(resource.methodThatPosts(), "hello"); + }, + ); + + it("can spy on a class method (non-parameterized)", () => { + // First we create a fake, which has working implementations + const resource = new Resource(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets(), "spy-stubbed"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }); + + it( + "can spy on a class method (non-parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const resource = new Resource(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + { stubbed: "return", value: "here" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets(), { + stubbed: "return", + value: "here", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }, + ); + + it("can spy on a class method (parameterized)", () => { + // First we create a fake, which has working implementations + const resource = new ResourceParameterized(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + resource.methodThatGets("param1", "param2"), + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets("param1", "param2"), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are + // calling the same method again, the call count should be incremented by 1. + assertEquals( + resource.methodThatGets("anotherParam1", "anotherParam2"), + "Do GET", + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }); + + it( + "can spy on a class method (parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const resource = new ResourceParameterized(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets("param1", "param2"), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are + // calling the same method again, the call count should be incremented + // by 1. + assertEquals( + resource.methodThatGets("anotherParam1", "anotherParam2"), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }, + ); + + it("can spy on a fakes's method (non-parameterized)", () => { + // First we create a fake, which has working implementations + const fake = Fake(Resource).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy(fake, "methodThatGets", fake.methodThatGets()); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets(), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }); + + it( + "can spy on a fakes's method (non-parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const fake = Fake(Resource).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets(), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithoutArgs(); + + // Also, you can call it again and do further verification. Since we are + // calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets(), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithoutArgs(); + }, + ); + + it("can spy on a fakes's method (parameterized)", () => { + // First we create a fake, which has working implementations + const fake = Fake(ResourceParameterized).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + fake.methodThatGets("param1", "param2"), + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets("param1", "param2"), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are + // calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets("anotherParam1", "anotherParam2"), + "Do GET", + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }); + + it( + "can spy on a fakes's method (parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const fake = Fake(ResourceParameterized).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does + // not return "spy-stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets("param1", "param2"), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are + // calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets("anotherParam1", "anotherParam2"), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }, + ); + + /** + * This test case verifies that Rhum does not support spying on function + * expressions when using CJS. Rhum cannot differentiate between function + * expressions and constructor functions. + */ + it("[NOT SUPPORTED - SHOULD THROW] can spy on a function expression (non-parameterized)", () => { + try { + const hello = () => { + return { + some: "value", + }; + }; + + let spyHello = Spy(hello); + + // Create a function to call the spy to see if the spy works when not + // directly called + function thingThatCallsTheSpy() { + spyHello(); + } + + thingThatCallsTheSpy(); // Call 1 + spyHello.verify().toBeCalled(1); + + thingThatCallsTheSpy(); // Call 2 + thingThatCallsTheSpy(); // Call 3 + spyHello.verify().toBeCalled(3); + + // Since no return value was specified, the default "spy-stubbed" should + // be used + assertEquals(spyHello(), "spy-stubbed"); + + // Here we give `hello` a new return value. The return value must be of + // the same return type. + spyHello = Spy(hello, { some: "other return value" }); + + // Assert that the specified return value is actually returned + assertEquals(spyHello(), { some: "other return value" }); + + // Since we reassigned `spyHello`, its calls should be reset to 0 and we + // should expect the number of calls to be 1 because we called it above. + // Also, we called it without args. + spyHello.verify().toBeCalled().toBeCalledWithoutArgs(); + } catch (error) { + // spy.init is called because the mod.ts' Spy() function thinks the + // function expression in this test case is a constructor function. + // spy.init is only used when spying on classes + assertEquals(error.message, "spy.init is not a function"); + } + }); + + /** + * This test case verifies that Rhum does not support spying on function + * expressions when using CJS. Rhum cannot differentiate between function + * expressions and constructor functions. + */ + it("[NOT SUPPORTED - SHOULD THROW] can spy on a function expression (parameterized)", () => { + try { + const hello = (param1, param2) => { + return { + param1Val: param1, + param2Val: param2, + }; + }; + + let spyHello = Spy(hello); + + // Create a function to call the spy to see if the spy works when not + // directly called + function thingThatCallsTheSpy() { + spyHello("doth mother know you weareth her drapes", true); + } + + thingThatCallsTheSpy(); // Call 1 + spyHello.verify().toBeCalled(1); + + thingThatCallsTheSpy(); // Call 2 + thingThatCallsTheSpy(); // Call 3 + spyHello.verify().toBeCalled(3); + + // Since no return value was specified, the default "spy-stubbed" should + // be used + assertEquals( + spyHello("doth mother know you weareth her drapes", true), + "spy-stubbed", + ); + + // Here we give `hello` a new return value. The return value must be of + // the same return type. + spyHello = Spy(hello, { + param1Val: "YOU GOT CHANGED!", + param2Val: false, + }); + + // Assert that the specified return value is actually returned + assertEquals(spyHello("sup", true), { + param1Val: "YOU GOT CHANGED!", + param2Val: false, + }); + + // Since we reassigned `spyHello`, its calls should be reset to 0 and we + // should expect the number of calls to be 1 because we called it above. + // Also, we called it with args. + spyHello.verify().toBeCalled().toBeCalledWithArgs("sup", true); + } catch (error) { + // spy.init is called because the mod.ts' Spy() function thinks the + // function expression in this test case is a constructor function. + // spy.init is only used when spying on classes + assertEquals(error.message, "spy.init is not a function"); + } + }); + }); +}); From d832efb5bd3a20387570d02c6ce0993bbce6aeab Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Tue, 26 Apr 2022 08:01:09 -0400 Subject: [PATCH 19/31] test(spies): esm tests --- tests/esm/unit/mod/spy.test.ts | 480 +++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 tests/esm/unit/mod/spy.test.ts diff --git a/tests/esm/unit/mod/spy.test.ts b/tests/esm/unit/mod/spy.test.ts new file mode 100644 index 00000000..aed82398 --- /dev/null +++ b/tests/esm/unit/mod/spy.test.ts @@ -0,0 +1,480 @@ +import { Fake, Spy } from "../../../../lib/esm/mod"; +import { assertEquals } from "../../jest_assertions"; + +class Resource { + public greeting = "hello"; + + public methodThatLogs() { + return "Resource is running!"; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatGets() { + this.methodThatLogs(); + return "Do GET"; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatPosts() { + this.methodThatLogs(); + return "Do POST"; + } +} + +class ResourceParameterized { + public greeting = "hello"; + + public methodThatLogs(message: string) { + return message; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatGets(_paramString1: string, _paramString2: string) { + this.methodThatLogs("Handle GET"); + return "Do GET"; + } + + // This method will be stubbed to return "spy-stubbed", so during + // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected + // to be called. + public methodThatPosts( + _paramBool1: boolean, + _paramBool2: boolean, + _paramArray: string[], + ) { + this.methodThatLogs("Handle POSt"); + return "Do POST"; + } +} + +describe("Spy()", () => { + describe("can spy on a class", () => { + it("adds is_spy", () => { + const spy = Spy(Resource); + assertEquals(spy.is_spy, true); + }); + + it( + 'stubs all data members with "spy-stubbed" return value', + () => { + const spy = Spy(Resource); + assertEquals(spy.methodThatLogs(), "spy-stubbed"); + assertEquals(spy.methodThatGets(), "spy-stubbed"); + assertEquals(spy.methodThatPosts(), "spy-stubbed"); + const spy2 = Spy(Resource); + const stubbedReturnValue = spy2.methodThatLogs(); // We called it, ... + spy2.verify("methodThatLogs").toBeCalled(); // ... so we can verify it was called ... + assertEquals(stubbedReturnValue, "spy-stubbed"); + }, + ); + + it("can verify calls for non-parameterized methods", () => { + const spy = Spy(Resource); + // Verify that `methodThatLogs` was called once (because we called it here) + const stubbedRetVal1 = spy.methodThatLogs(); + assertEquals(stubbedRetVal1, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + const stubbedRetVal2 = spy.methodThatGets(); + assertEquals(stubbedRetVal2, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs").toBeCalled(1); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + const stubbedRetVal3 = spy.methodThatPosts(); + assertEquals(stubbedRetVal3, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs").toBeCalled(1); + }); + + it("can verify calls for parameterized methods", () => { + const spy = Spy(ResourceParameterized); + // Verify that `methodThatLogs` was called once (because we called it here) + const stubbedRetVal1 = spy.methodThatLogs("hello"); + assertEquals(stubbedRetVal1, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWithArgs("hello"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatGets()` is stubbed + const stubbedRetVal2 = spy.methodThatGets("hello", "world"); + assertEquals(stubbedRetVal2, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWithArgs("hello"); + spy.verify("methodThatGets") + .toBeCalled(1) + .toBeCalledWithArgs("hello", "world"); + + // Verify that `methodThatLogs()` was still only called once since + // `methodThatPosts()` is stubbed + const stubbedRetVal3 = spy.methodThatPosts(true, false, ["test"]); + assertEquals(stubbedRetVal3, "spy-stubbed"); // All spies have stubbed methods + spy.verify("methodThatLogs") + .toBeCalled(1) + .toBeCalledWithArgs("hello"); + spy.verify("methodThatPosts") + .toBeCalled(1) + .toBeCalledWithArgs(true, false, ["test"]); + }); + }); + + describe("can spy on a class method", () => { + it("can stub the class method", () => { + const resource = new Resource(); + Spy(resource, "methodThatPosts"); + assertEquals(resource.methodThatPosts(), "spy-stubbed"); + }); + + it( + "can stub the class method (with stubbed return value)", + () => { + const resource = new Resource(); + Spy(resource, "methodThatPosts", "hello"); + assertEquals(resource.methodThatPosts(), "hello"); + }, + ); + + it("can spy on a class method (non-parameterized)", () => { + // First we create a fake, which has working implementations + const resource = new Resource(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets(), "spy-stubbed"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }); + + it( + "can spy on a class method (non-parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const resource = new Resource(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + { stubbed: "return", value: "here" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets(), { + stubbed: "return", + value: "here", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }, + ); + + it("can spy on a class method (parameterized)", () => { + // First we create a fake, which has working implementations + const resource = new ResourceParameterized(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + resource.methodThatGets("param1", "param2"), + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets("param1", "param2"), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + resource.methodThatGets("anotherParam1", "anotherParam2"), + "Do GET", + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }); + + it( + "can spy on a class method (parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const resource = new ResourceParameterized(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + resource, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(resource.methodThatGets("param1", "param2"), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + resource.methodThatGets("anotherParam1", "anotherParam2"), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }, + ); + + it("can spy on a fakes's method (non-parameterized)", () => { + // First we create a fake, which has working implementations + const fake = Fake(Resource).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy(fake, "methodThatGets", fake.methodThatGets()); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets(), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify().toBeCalled(1).toBeCalledWithoutArgs(); + }); + + it( + "can spy on a fakes's method (non-parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const fake = Fake(Resource).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets(), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithoutArgs(); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets(), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithoutArgs(); + }, + ); + + it("can spy on a fakes's method (parameterized)", () => { + // First we create a fake, which has working implementations + const fake = Fake(ResourceParameterized).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + fake.methodThatGets("param1", "param2"), + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets("param1", "param2"), "Do GET"); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets("anotherParam1", "anotherParam2"), + "Do GET", + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }); + + it( + "can spy on a fakes's method (parameterized with stubbed return value)", + () => { + // First we create a fake, which has working implementations + const fake = Fake(ResourceParameterized).create(); + + // Now we're going to spy on the fake's `methodThatLogs()` method. + // We set the return value to what it would return originally so it does not return "spy-stubbed" + const spyMethod = Spy( + fake, + "methodThatGets", + { hello: "world" }, + ); + + // Fake's have working implementations, so we expect a real call + assertEquals(fake.methodThatGets("param1", "param2"), { + hello: "world", + }); + + // However, since we are spying on the `methodThatGets()` method, + // we can verify that it was called + spyMethod.verify() + .toBeCalled(1) + .toBeCalledWithArgs("param1", "param2"); + + // Also, you can call it again and do further verification. Since we are calling the same method again, the call count should be incremented by 1. + assertEquals( + fake.methodThatGets("anotherParam1", "anotherParam2"), + { hello: "world" }, + ); + spyMethod.verify() + .toBeCalled(2) + .toBeCalledWithArgs( + "anotherParam1", + "anotherParam2", + ); + }, + ); + + it("can spy on a function expression (non-parameterized)", () => { + const hello = (): { some: string } => { + return { + some: "value", + }; + }; + + let spyHello = Spy(hello); + + // Create a function to call the spy to see if the spy works when not + // directly called + function thingThatCallsTheSpy() { + spyHello(); + } + + thingThatCallsTheSpy(); // Call 1 + spyHello.verify().toBeCalled(1); + + thingThatCallsTheSpy(); // Call 2 + thingThatCallsTheSpy(); // Call 3 + spyHello.verify().toBeCalled(3); + + // Since no return value was specified, the default "spy-stubbed" should + // be used + assertEquals(spyHello(), "spy-stubbed"); + + // Here we give `hello` a new return value. The return value must be of + // the same return type. + spyHello = Spy(hello, { some: "other return value" }); + + // Assert that the specified return value is actually returned + assertEquals(spyHello(), { some: "other return value" }); + + // Since we reassigned `spyHello`, its calls should be reset to 0 and we + // should expect the number of calls to be 1 because we called it above. + // Also, we called it without args. + spyHello.verify().toBeCalled().toBeCalledWithoutArgs(); + }); + + it("can spy on a function expression (parameterized)", () => { + const hello = (param1: string, param2: boolean): { + param1Val: string; + param2Val: boolean; + } => { + return { + param1Val: param1, + param2Val: param2, + }; + }; + + let spyHello = Spy(hello); + + // Create a function to call the spy to see if the spy works when not + // directly called + function thingThatCallsTheSpy() { + spyHello("doth mother know you weareth her drapes", true); + } + + thingThatCallsTheSpy(); // Call 1 + spyHello.verify().toBeCalled(1); + + thingThatCallsTheSpy(); // Call 2 + thingThatCallsTheSpy(); // Call 3 + spyHello.verify().toBeCalled(3); + + // Since no return value was specified, the default "spy-stubbed" should + // be used + assertEquals( + spyHello("doth mother know you weareth her drapes", true), + "spy-stubbed", + ); + + // Here we give `hello` a new return value. The return value must be of + // the same return type. + spyHello = Spy(hello, { + param1Val: "YOU GOT CHANGED!", + param2Val: false, + }); + + // Assert that the specified return value is actually returned + assertEquals(spyHello("sup", true), { + param1Val: "YOU GOT CHANGED!", + param2Val: false, + }); + + // Since we reassigned `spyHello`, its calls should be reset to 0 and we + // should expect the number of calls to be 1 because we called it above. + // Also, we called it with args. + spyHello.verify().toBeCalled().toBeCalledWithArgs("sup", true); + }); + }); +}); From f4426b1e123cffa60e1adc0167523754aa0dcb1d Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Wed, 27 Apr 2022 21:11:27 -0400 Subject: [PATCH 20/31] chore: lint; fm --- tests/cjs/unit/mod/spy.test.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/cjs/unit/mod/spy.test.js b/tests/cjs/unit/mod/spy.test.js index ecd1508d..949b3b3b 100644 --- a/tests/cjs/unit/mod/spy.test.js +++ b/tests/cjs/unit/mod/spy.test.js @@ -2,16 +2,16 @@ const { Fake, Spy } = require("../../../../lib/cjs/mod"); const { assertEquals } = require("../../jest_assertions"); class Resource { - greeting = "hello"; + greeting = "hello"; - methodThatLogs() { + methodThatLogs() { return "Resource is running!"; } // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. - methodThatGets() { + methodThatGets() { this.methodThatLogs(); return "Do GET"; } @@ -19,23 +19,23 @@ class Resource { // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. - methodThatPosts() { + methodThatPosts() { this.methodThatLogs(); return "Do POST"; } } class ResourceParameterized { - greeting = "hello"; + greeting = "hello"; - methodThatLogs(message) { + methodThatLogs(message) { return message; } // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. - methodThatGets(_paramString1, _paramString2) { + methodThatGets(_paramString1, _paramString2) { this.methodThatLogs("Handle GET"); return "Do GET"; } @@ -43,7 +43,7 @@ class ResourceParameterized { // This method will be stubbed to return "spy-stubbed", so during // `spy.verify().toBeCalled()`, `this.methodThatLogs()` should not be expected // to be called. - methodThatPosts( + methodThatPosts( _paramBool1, _paramBool2, _paramArray, From 2823b2c9ecb737600d27d27b5fa23558d235b7b4 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Wed, 27 Apr 2022 21:12:01 -0400 Subject: [PATCH 21/31] fix(verifiers): issues surfaced from tests --- src/verifiers/callable_verifier.ts | 14 +++++++------- src/verifiers/function_expression_verifier.ts | 6 +++--- src/verifiers/method_verifier.ts | 6 ++---- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/verifiers/callable_verifier.ts b/src/verifiers/callable_verifier.ts index d1950a69..9c56da7c 100644 --- a/src/verifiers/callable_verifier.ts +++ b/src/verifiers/callable_verifier.ts @@ -99,8 +99,8 @@ export class CallableVerifier { throw new VerificationError( errorMessage, codeThatThrew, - `Expected calls -> 1 (or more)`, `Actual calls -> 0`, + `Expected calls -> 1 (or more)`, ); } @@ -114,8 +114,8 @@ export class CallableVerifier { throw new VerificationError( errorMessage, codeThatThrew, - `Expected calls -> ${expectedCalls}`, `Actual calls -> ${actualCalls}`, + `Expected calls -> ${expectedCalls}`, ); } @@ -137,12 +137,12 @@ export class CallableVerifier { throw new VerificationError( errorMessage, codeThatThrew, - `Expected args -> ${this.argsAsStringWithTypes(expectedArgs)}`, `Actual args -> ${ actualArgs.length > 0 - ? this.argsAsStringWithTypes(actualArgs) + ? `(${this.argsAsStringWithTypes(actualArgs)})` : "(no args)" }`, + `Expected args -> ${this.argsAsStringWithTypes(expectedArgs)}`, ); } } @@ -165,8 +165,8 @@ export class CallableVerifier { throw new VerificationError( errorMessage, codeThatThrew, - `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, ); } } @@ -214,8 +214,8 @@ export class CallableVerifier { .replace("{{ unexpected_arg }}", unexpectedArg) .replace("{{ parameter_position }}", parameterPosition.toString()), codeThatThrew, - `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, ); }); } @@ -246,8 +246,8 @@ export class CallableVerifier { throw new VerificationError( errorMessage, codeThatThrew.replace("{{ arg_noun }}", argNoun), - `Expected args -> (no args)`, `Actual args -> (${actualArgsAsString})`, + `Expected args -> (no args)`, ); } diff --git a/src/verifiers/function_expression_verifier.ts b/src/verifiers/function_expression_verifier.ts index 74469359..6bfd6409 100644 --- a/src/verifiers/function_expression_verifier.ts +++ b/src/verifiers/function_expression_verifier.ts @@ -43,7 +43,7 @@ export class FunctionExpressionVerifier extends CallableVerifier { * just verify that the method was called without checking how many times it * was called. */ - protected toBeCalled( + public toBeCalled( actualCalls: number, expectedCalls?: number, ): this { @@ -64,7 +64,7 @@ export class FunctionExpressionVerifier extends CallableVerifier { * @param actualArgs - The actual args that this method was called with. * @param expectedArgs - The args this method is expected to have received. */ - protected toBeCalledWithArgs( + public toBeCalledWithArgs( actualArgs: unknown[], expectedArgs: unknown[], ): this { @@ -102,7 +102,7 @@ export class FunctionExpressionVerifier extends CallableVerifier { * @param actualArgs - The actual args that this method was called with. This * method expects it to be an empty array. */ - protected toBeCalledWithoutArgs( + public toBeCalledWithoutArgs( actualArgs: unknown[], ): this { return super.verifyToBeCalledWithoutArgs( diff --git a/src/verifiers/method_verifier.ts b/src/verifiers/method_verifier.ts index a290b5ff..32bf268f 100644 --- a/src/verifiers/method_verifier.ts +++ b/src/verifiers/method_verifier.ts @@ -88,9 +88,7 @@ export class MethodVerifier extends CallableVerifier { this.verifyToBeCalledWithArgsTooFewArgs( actualArgs, expectedArgs, - `Method "${this.#method_name}" was called with ${actualArgs.length} ${ - actualArgs.length > 1 ? "args" : "arg" - } instead of ${expectedArgs.length}.`, + `Method "${this.#method_name}" was called with ${actualArgs.length} {{ arg_noun }} instead of ${expectedArgs.length}.`, codeThatThrew, ); @@ -117,7 +115,7 @@ export class MethodVerifier extends CallableVerifier { ): this { this.verifyToBeCalledWithoutArgs( actualArgs, - `Method "${this.#method_name}" was called with unexpected args.`, + `Method "${this.#method_name}" was called with args when expected to receive no args.`, `.verify("${this.method_name}").toBeCalledWithoutArgs()`, ); From ae42330b43ea3e6e6c8c409593ae8d69644b349e Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Wed, 27 Apr 2022 21:12:18 -0400 Subject: [PATCH 22/31] test(verifiers): function expression verifier and method verifier --- .../function_expression_verifier_test.ts | 118 +++++++++++++++++ .../src/verifiers/method_verifier_test.ts | 124 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 tests/deno/src/verifiers/function_expression_verifier_test.ts create mode 100644 tests/deno/src/verifiers/method_verifier_test.ts diff --git a/tests/deno/src/verifiers/function_expression_verifier_test.ts b/tests/deno/src/verifiers/function_expression_verifier_test.ts new file mode 100644 index 00000000..d4d11d2d --- /dev/null +++ b/tests/deno/src/verifiers/function_expression_verifier_test.ts @@ -0,0 +1,118 @@ +import { FunctionExpressionVerifier } from "../../../../src/verifiers/function_expression_verifier.ts"; +import { assertEquals } from "../../deps.ts"; + +function throwError( + cb: (...args: unknown[]) => void, +): { message: string; stack: string } { + let e; + + try { + cb(); + } catch (error) { + e = error; + } + + return { + message: e.message, + stack: e.stack + .replace(/\.ts:\d+:\d+/g, ".ts:{line}:{column}") + .replace(/line \d+/g, "line {line}"), + }; +} + +Deno.test("FunctionExpressionVerifier", async (t) => { + await t.step("toBeCalled()", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const mv = new FunctionExpressionVerifier("doSomething"); + + const error = throwError(() => mv.toBeCalled(1, 2)); + + const expected = ` + +VerificationError: Function "doSomething" was not called. + at /deno/src/verifiers/function_expression_verifier_test.ts:{line}:{column} + +Verification Results: + Actual calls -> 1 + Expected calls -> 2 + +Check the above "function_expression_verifier_test.ts" file at/around line {line} for code like the following to fix this error: + .verify().toBeCalled(2) + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); + }); + + await t.step("toBeCalledWithArgs()", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const mv = new FunctionExpressionVerifier("doSomething"); + + const error = throwError(() => mv.toBeCalledWithArgs([1], [2])); + + const expected = ` + +VerificationError: Function "doSomething" received unexpected arg \`2\` at parameter position 1. + at /deno/src/verifiers/function_expression_verifier_test.ts:{line}:{column} + +Verification Results: + Actual call -> (1) + Expected call -> (2) + +Check the above "function_expression_verifier_test.ts" file at/around line {line} for code like the following to fix this error: + .verify().toBeCalledWithArgs(2) + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); + }); + + await t.step("toBeCalledWithoutArgs()", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const mv = new FunctionExpressionVerifier("doSomething"); + + const error = throwError(() => + mv.toBeCalledWithoutArgs([2, "hello", {}]) + ); + + const expected = ` + +VerificationError: Function "doSomething" was called with args when expected to receive no args. + at /deno/src/verifiers/function_expression_verifier_test.ts:{line}:{column} + +Verification Results: + Actual args -> (2, "hello", {}) + Expected args -> (no args) + +Check the above "function_expression_verifier_test.ts" file at/around line {line} for code like the following to fix this error: + .verify().toBeCalledWithoutArgs() + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); + }); +}); diff --git a/tests/deno/src/verifiers/method_verifier_test.ts b/tests/deno/src/verifiers/method_verifier_test.ts new file mode 100644 index 00000000..91cb7885 --- /dev/null +++ b/tests/deno/src/verifiers/method_verifier_test.ts @@ -0,0 +1,124 @@ +import { MethodVerifier } from "../../../../src/verifiers/method_verifier.ts"; +import { assertEquals } from "../../deps.ts"; + +class MyClass { + public doSomething(): void { + return; + } +} + +function throwError( + cb: (...args: unknown[]) => void, +): { message: string; stack: string } { + let e; + + try { + cb(); + } catch (error) { + e = error; + } + + return { + message: e.message, + stack: e.stack + .replace(/\.ts:\d+:\d+/g, ".ts:{line}:{column}") + .replace(/line \d+/g, "line {line}"), + }; +} + +Deno.test("MethodVerifier", async (t) => { + await t.step("toBeCalled()", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const mv = new MethodVerifier("doSomething"); + + const error = throwError(() => mv.toBeCalled(1, 2)); + + const expected = ` + +VerificationError: Method "doSomething" was not called. + at /deno/src/verifiers/method_verifier_test.ts:{line}:{column} + +Verification Results: + Actual calls -> 1 + Expected calls -> 2 + +Check the above "method_verifier_test.ts" file at/around line {line} for code like the following to fix this error: + .verify("doSomething").toBeCalled(2) + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); + }); + + await t.step("toBeCalledWithArgs()", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const mv = new MethodVerifier("doSomething"); + + const error = throwError(() => mv.toBeCalledWithArgs([1], [2])); + + const expected = ` + +VerificationError: Method "doSomething" received unexpected arg \`2\` at parameter position 1. + at /deno/src/verifiers/method_verifier_test.ts:{line}:{column} + +Verification Results: + Actual call -> (1) + Expected call -> (2) + +Check the above "method_verifier_test.ts" file at/around line {line} for code like the following to fix this error: + .verify("doSomething").toBeCalledWithArgs(2) + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); + }); + + await t.step("toBeCalledWithoutArgs()", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const mv = new MethodVerifier("doSomething"); + + const error = throwError(() => + mv.toBeCalledWithoutArgs([2, "hello", {}]) + ); + + const expected = ` + +VerificationError: Method "doSomething" was called with args when expected to receive no args. + at /deno/src/verifiers/method_verifier_test.ts:{line}:{column} + +Verification Results: + Actual args -> (2, "hello", {}) + Expected args -> (no args) + +Check the above "method_verifier_test.ts" file at/around line {line} for code like the following to fix this error: + .verify("doSomething").toBeCalledWithoutArgs() + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); + }); +}); From 30413d224a5f63d91f8ef0e177e5b57d61747915 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Wed, 27 Apr 2022 21:15:40 -0400 Subject: [PATCH 23/31] test(errors): test that the VerificationError stack trace shows as expected --- tests/deno/src/errors_test.ts | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/deno/src/errors_test.ts diff --git a/tests/deno/src/errors_test.ts b/tests/deno/src/errors_test.ts new file mode 100644 index 00000000..3ffeed0d --- /dev/null +++ b/tests/deno/src/errors_test.ts @@ -0,0 +1,68 @@ +import { VerificationError } from "../../../src/errors.ts"; +import { assertEquals, assertThrows } from "../../deps.ts"; + +function throwError( + message: string, + codeThatThrew: string, + actualResults: string, + expectedResults: string, +): { message: string; stack: string } { + let e; + + try { + throw new VerificationError( + message, + codeThatThrew, + actualResults, + expectedResults, + ); + } catch (error) { + e = error; + } + + return { + message: e.message, + stack: e.stack + .replace(/\.ts:\d+:\d+/g, ".ts:{line}:{column}") + .replace(/line \d+/g, "line {line}"), + }; +} + +Deno.test("VerificationError", async (t) => { + await t.step( + "shows error message, code that threw, actual and expected results", + () => { + const error = throwError( + "Some message", + ".some().code()", + "Actual: 1 call made", + "Expected: 2 calls made ah ah ah", + ); + + assertEquals( + error.message, + "Some message", + ); + + const expected = ` + +VerificationError: Some message + at throwError (/deno/src/errors_test.ts:{line}:{column}) + +Verification Results: + Actual: 1 call made + Expected: 2 calls made ah ah ah + +Check the above "errors_test.ts" file at/around line {line} for code like the following to fix this error: + .some().code() + + +`; + + assertEquals( + error.stack.replace(/file.+tests/g, ""), // Remove file://path/to/local/rhum/tests + expected, + ); + }, + ); +}); From 0620bc1e2a21d9a63ea641c4f7f7ed3fa1566bf7 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 30 Apr 2022 07:42:25 -0400 Subject: [PATCH 24/31] refactor: remove try-catch (not needed) --- src/errors.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 1cd16fff..f186ff83 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -95,16 +95,9 @@ export class VerificationError extends RhumError { } let conciseStackArray = this.stack.split("\n").filter((line: string) => { - try { - return ignoredLines.filter((ignoredLine: string) => { - return line.includes(ignoredLine); - }).length === 0; - } catch (_error) { - // Do nothing. If we can't filter because the `ignoredLines.filter()` - // call errored out. Whatev... we just move on and show a full stack - // trace. No biggie. - } - return false; + return ignoredLines.filter((ignoredLine: string) => { + return line.includes(ignoredLine); + }).length === 0; }); // Sometimes, the error stack will contain the problematic file twice. We From c53938caaf827565024619daa0a1eaae3cc8d9b8 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 30 Apr 2022 07:43:08 -0400 Subject: [PATCH 25/31] chore: make concise error stack code more readable; provide example --- src/errors.ts | 55 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index f186ff83..367bedfc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -38,9 +38,23 @@ export class SpyError extends RhumError { } /** - * Error to throw in relation to method verification logic. For example, when a - * method is being verified that it was called once via - * `mock.method("doSomething").toBeCalled(1)`. + * Error to throw in relation to verification logic. For example, when a method + * or function expression is being verified that it was called once via + * `.verify("someMethod").toBeCalled(1)`. The stack trace shown with this error + * is modified to look like the following example: + * + * @example + * ```text + * VerificationError: Method "someMethod" was called with args when expected to receive no args. + * at file:///path/to/some/file.ts:99:14 + * + * Verification Results: + * Actual args -> (2, "hello", {}) + * Expected args -> (no args) + * + * Check the above "file.ts" file at/around line 99 for code like the following to fix this error: + * .verify("someMethod").toBeCalledWithoutArgs() + * ``` */ export class VerificationError extends RhumError { #actual_results: string; @@ -70,12 +84,8 @@ export class VerificationError extends RhumError { } /** - * Shorten the stack trace to show exactly what file threw the error. For - * example: - * - * ```ts - * - * ``` + * Shorten the stack trace to show exactly what file threw the error. This + * redefines the stack trace (if there is a stack trace). */ #makeStackConcise(): void { const ignoredLines = [ @@ -117,24 +127,29 @@ export class VerificationError extends RhumError { /\/[a-zA-Z0-9\(\)\[\]_-\d.]+\.ts:\d+:\d+/, ); - const [filename, lineNumber] = extractedFilenameWithLineAndColumnNumbers + let [filename, lineNumber] = extractedFilenameWithLineAndColumnNumbers ? extractedFilenameWithLineAndColumnNumbers[0].split(":") : ""; - let newStack = "\n\n"; // Give spacing when displayed in the console - newStack += conciseStack; + filename = filename.replace("/", ""); - newStack += `\n\nVerification Results:`; - newStack += `\n ${this.#actual_results}`; - newStack += `\n ${this.#expected_results}`; + let newStack = ` + +${conciseStack} + +Verification Results: + ${this.#actual_results} + ${this.#expected_results} +`; if (lineNumber) { - newStack += `\n\nCheck the above "${ - filename.replace("/", "") - }" file at/around line ${lineNumber} for code like the following to fix this error:`; - newStack += `\n ${this.#code_that_threw}`; + newStack += ` +Check the above "${filename}" file at/around line ${lineNumber} for code like the following to fix this error: + ${this.#code_that_threw} + + +`; } - newStack += "\n\n\n"; // Give spacing when displayed in the console this.stack = newStack; } From c0be6b57fcb8ceea8b7da6b938735b2d55e9b586 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 30 Apr 2022 22:00:57 -0400 Subject: [PATCH 26/31] fix: issue with original being constructed without constructor args --- src/fake/fake_builder.ts | 1 + src/fake/fake_mixin.ts | 9 ++++++ src/mock/mock_builder.ts | 1 + src/mock/mock_mixin.ts | 9 ++++++ tests/deno/unit/mod/fake_test.ts | 33 ++++++++++++++++++++- tests/deno/unit/mod/mock_test.ts | 49 ++++++++++++++++++++++++++++++-- 6 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/fake/fake_builder.ts b/src/fake/fake_builder.ts index ae28c612..2e234aa0 100644 --- a/src/fake/fake_builder.ts +++ b/src/fake/fake_builder.ts @@ -24,6 +24,7 @@ export class FakeBuilder extends TestDoubleBuilder { const fake = createFake, ClassToFake>( this.constructor_fn, + ...this.constructor_args, ); (fake as IFake & ITestDouble).init( diff --git a/src/fake/fake_mixin.ts b/src/fake/fake_mixin.ts index 981421cc..7afb0f15 100644 --- a/src/fake/fake_mixin.ts +++ b/src/fake/fake_mixin.ts @@ -13,6 +13,7 @@ import { PreProgrammedMethod } from "../pre_programmed_method.ts"; */ export function createFake( OriginalClass: OriginalConstructor, + ...originalConstructorArgs: unknown[] ): IFake { const Original = OriginalClass as unknown as Constructor< // deno-lint-ignore no-explicit-any @@ -29,6 +30,14 @@ export function createFake( */ #original!: OriginalObject; + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR /////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + constructor() { + super(...originalConstructorArgs); + } + //////////////////////////////////////////////////////////////////////////// // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// diff --git a/src/mock/mock_builder.ts b/src/mock/mock_builder.ts index 89266b1d..832a6d96 100644 --- a/src/mock/mock_builder.ts +++ b/src/mock/mock_builder.ts @@ -26,6 +26,7 @@ export class MockBuilder extends TestDoubleBuilder { const mock = createMock, ClassToMock>( this.constructor_fn, + ...this.constructor_args, ); (mock as IMock & ITestDouble).init( diff --git a/src/mock/mock_mixin.ts b/src/mock/mock_mixin.ts index db17cfa5..bf950e59 100644 --- a/src/mock/mock_mixin.ts +++ b/src/mock/mock_mixin.ts @@ -102,6 +102,7 @@ class MethodExpectation { */ export function createMock( OriginalClass: OriginalConstructor, + ...originalConstructorArgs: unknown[] ): IMock { const Original = OriginalClass as unknown as Constructor< // deno-lint-ignore no-explicit-any @@ -128,6 +129,14 @@ export function createMock( */ #original!: OriginalObject; + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR /////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + constructor() { + super(...originalConstructorArgs); + } + //////////////////////////////////////////////////////////////////////////// // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// diff --git a/tests/deno/unit/mod/fake_test.ts b/tests/deno/unit/mod/fake_test.ts index 7575c61f..2e64e07f 100644 --- a/tests/deno/unit/mod/fake_test.ts +++ b/tests/deno/unit/mod/fake_test.ts @@ -1,4 +1,4 @@ -import { Fake } from "../../../../mod.ts"; +import { Fake, Mock } from "../../../../mod.ts"; import { assertEquals, assertThrows } from "../../../deps.ts"; Deno.test("Fake()", async (t) => { @@ -44,6 +44,21 @@ Deno.test("Fake()", async (t) => { assertEquals(fake.is_fake, true); }, }); + + await t.step({ + name: "can set test double as arg and call it", + fn(): void { + const mock = Mock(PrivateService).create(); + + const fake = Fake(TestObjectFour) + .withConstructorArgs(mock) + .create(); + + mock.expects("doSomething").toBeCalled(1); + fake.callPrivateService(); + mock.verifyExpectations(); + }, + }); }); await t.step(".method(...)", async (t) => { @@ -286,6 +301,22 @@ class TestObjectThree { } } +class TestObjectFour { + #private_service: PrivateService; + constructor(privateService: PrivateService) { + this.#private_service = privateService; + } + public callPrivateService() { + this.#private_service.doSomething(); + } +} + +class PrivateService { + public doSomething(): boolean { + return true; + } +} + class Resource { #repository: Repository; diff --git a/tests/deno/unit/mod/mock_test.ts b/tests/deno/unit/mod/mock_test.ts index 9f7dd9d8..f04359e0 100644 --- a/tests/deno/unit/mod/mock_test.ts +++ b/tests/deno/unit/mod/mock_test.ts @@ -45,14 +45,43 @@ Deno.test("Mock()", async (t) => { const mockMathService = Mock(MathService) .create(); + const mockPrivateService = Mock(PrivateService) + .create(); const mockTestObject = Mock(TestObjectLotsOfDataMembers) - .withConstructorArgs("has mocked math service", mockMathService) + .withConstructorArgs( + "has mocked math service", + mockMathService, + mockPrivateService, + ) .create(); assertEquals(mockMathService.calls.add, 0); mockTestObject.sum(1, 1); assertEquals(mockMathService.calls.add, 1); }, }); + + await t.step({ + name: "can set test double as arg and call it", + fn(): void { + const mock = Mock(TestObjectTwoMore) + .withConstructorArgs("some name", ["hello"]) + .create(); + + const mockPrivateService = Mock(PrivateService) + .create(); + const mockTestObject = Mock(TestObjectLotsOfDataMembers) + .withConstructorArgs( + "has mocked math service", + Mock(MathService).create(), + mockPrivateService, + ) + .create(); + + mockPrivateService.expects("doSomething").toBeCalled(1); + mockTestObject.callPrivateService(); + mockPrivateService.verifyExpectations(); + }, + }); }); await t.step("method(...)", async (t) => { @@ -420,11 +449,17 @@ class MathService { class TestObjectLotsOfDataMembers { public name: string; #age = 0; + #private_service: PrivateService; protected math_service: MathService; protected protected_property = "I AM PROTECTED PROPERTY."; - constructor(name: string, mathService: MathService) { + constructor( + name: string, + mathService: MathService, + privateService: PrivateService, + ) { this.math_service = mathService; this.name = name; + this.#private_service = privateService; } public sum( num1: number, @@ -445,6 +480,10 @@ class TestObjectLotsOfDataMembers { public set age(val: number) { this.#age = val; } + + public callPrivateService() { + this.#private_service.doSomething(); + } } class TestRequestHandler { @@ -464,3 +503,9 @@ class TestRequestHandler { return "posted"; } } + +class PrivateService { + public doSomething(): boolean { + return true; + } +} From 0bbc6390967d674ba0c6b76705c55fb5eefb1f08 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 30 Apr 2022 22:01:28 -0400 Subject: [PATCH 27/31] fix: issue with original being constructed without constructor args --- src/spy/spy_builder.ts | 1 + src/spy/spy_mixin.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/spy/spy_builder.ts b/src/spy/spy_builder.ts index 9b6b7d4c..97970dd9 100644 --- a/src/spy/spy_builder.ts +++ b/src/spy/spy_builder.ts @@ -29,6 +29,7 @@ export class SpyBuilder extends TestDoubleBuilder { const spy = createSpy, ClassToSpy>( this.constructor_fn, + ...this.constructor_args, ); (spy as ISpy & ITestDouble).init( diff --git a/src/spy/spy_mixin.ts b/src/spy/spy_mixin.ts index 9ca03031..6e53b657 100644 --- a/src/spy/spy_mixin.ts +++ b/src/spy/spy_mixin.ts @@ -7,6 +7,7 @@ import { SpyStubBuilder } from "./spy_stub_builder.ts"; */ export function createSpy( OriginalClass: OriginalConstructor, + ...originalConstructorArgs: unknown[] ): ISpy { const Original = OriginalClass as unknown as Constructor< // deno-lint-ignore no-explicit-any @@ -28,6 +29,14 @@ export function createSpy( */ #original!: OriginalObject; + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR /////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + constructor() { + super(...originalConstructorArgs); + } + //////////////////////////////////////////////////////////////////////////// // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// From f096f28fe5ea94fef2ab19483cdbb1e8319f74ca Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sat, 30 Apr 2022 22:19:12 -0400 Subject: [PATCH 28/31] chore: deno lint --- tests/deno/src/errors_test.ts | 2 +- tests/deno/src/verifiers/method_verifier_test.ts | 8 ++++---- tests/deno/unit/mod/mock_test.ts | 4 ---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/deno/src/errors_test.ts b/tests/deno/src/errors_test.ts index 3ffeed0d..0a0cc960 100644 --- a/tests/deno/src/errors_test.ts +++ b/tests/deno/src/errors_test.ts @@ -1,5 +1,5 @@ import { VerificationError } from "../../../src/errors.ts"; -import { assertEquals, assertThrows } from "../../deps.ts"; +import { assertEquals } from "../../deps.ts"; function throwError( message: string, diff --git a/tests/deno/src/verifiers/method_verifier_test.ts b/tests/deno/src/verifiers/method_verifier_test.ts index 91cb7885..112d2a34 100644 --- a/tests/deno/src/verifiers/method_verifier_test.ts +++ b/tests/deno/src/verifiers/method_verifier_test.ts @@ -1,7 +1,7 @@ import { MethodVerifier } from "../../../../src/verifiers/method_verifier.ts"; import { assertEquals } from "../../deps.ts"; -class MyClass { +class _MyClass { public doSomething(): void { return; } @@ -31,7 +31,7 @@ Deno.test("MethodVerifier", async (t) => { await t.step( "shows error message, code that threw, actual and expected results", () => { - const mv = new MethodVerifier("doSomething"); + const mv = new MethodVerifier<_MyClass>("doSomething"); const error = throwError(() => mv.toBeCalled(1, 2)); @@ -62,7 +62,7 @@ Check the above "method_verifier_test.ts" file at/around line {line} for code li await t.step( "shows error message, code that threw, actual and expected results", () => { - const mv = new MethodVerifier("doSomething"); + const mv = new MethodVerifier<_MyClass>("doSomething"); const error = throwError(() => mv.toBeCalledWithArgs([1], [2])); @@ -93,7 +93,7 @@ Check the above "method_verifier_test.ts" file at/around line {line} for code li await t.step( "shows error message, code that threw, actual and expected results", () => { - const mv = new MethodVerifier("doSomething"); + const mv = new MethodVerifier<_MyClass>("doSomething"); const error = throwError(() => mv.toBeCalledWithoutArgs([2, "hello", {}]) diff --git a/tests/deno/unit/mod/mock_test.ts b/tests/deno/unit/mod/mock_test.ts index f04359e0..1b7bb152 100644 --- a/tests/deno/unit/mod/mock_test.ts +++ b/tests/deno/unit/mod/mock_test.ts @@ -63,10 +63,6 @@ Deno.test("Mock()", async (t) => { await t.step({ name: "can set test double as arg and call it", fn(): void { - const mock = Mock(TestObjectTwoMore) - .withConstructorArgs("some name", ["hello"]) - .create(); - const mockPrivateService = Mock(PrivateService) .create(); const mockTestObject = Mock(TestObjectLotsOfDataMembers) From 2364a4a75283b760688a957442c354d19f1e083c Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sun, 8 May 2022 10:20:29 -0400 Subject: [PATCH 29/31] fix: issue with error message stating expected arg as unexpected arg (unexpected arg is the actual arg) --- src/verifiers/callable_verifier.ts | 7 ++----- src/verifiers/function_expression_verifier.ts | 9 +++++++-- src/verifiers/method_verifier.ts | 9 +++++++-- .../src/verifiers/function_expression_verifier_test.ts | 4 ++-- tests/deno/src/verifiers/method_verifier_test.ts | 4 ++-- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/verifiers/callable_verifier.ts b/src/verifiers/callable_verifier.ts index 9c56da7c..9e6b13bd 100644 --- a/src/verifiers/callable_verifier.ts +++ b/src/verifiers/callable_verifier.ts @@ -207,7 +207,7 @@ export class CallableVerifier { } // Alright, we have an unexpected arg, so throw an error - const unexpectedArg = `\`${arg}${this.getArgType(arg)}\``; + const unexpectedArg = `\`${actualArgs[index]}${this.getArgType(actualArgs[index])}\``; throw new VerificationError( errorMessage @@ -240,12 +240,9 @@ export class CallableVerifier { return this; } - // One arg? Say "arg". More than one arg? Say "args". Yaaaaaarg. - const argNoun = actualArgs.length > 1 ? "args" : "arg"; - throw new VerificationError( errorMessage, - codeThatThrew.replace("{{ arg_noun }}", argNoun), + codeThatThrew, `Actual args -> (${actualArgsAsString})`, `Expected args -> (no args)`, ); diff --git a/src/verifiers/function_expression_verifier.ts b/src/verifiers/function_expression_verifier.ts index 6bfd6409..73c5c288 100644 --- a/src/verifiers/function_expression_verifier.ts +++ b/src/verifiers/function_expression_verifier.ts @@ -48,10 +48,15 @@ export class FunctionExpressionVerifier extends CallableVerifier { expectedCalls?: number, ): this { const calls = expectedCalls ?? ""; + + const errorMessage = expectedCalls + ? `Function "${this.#name}" was not called ${calls} time(s).` + : `Function "${this.#name}" was not called.`; + this.verifyToBeCalled( actualCalls, expectedCalls, - `Function "${this.#name}" was not called.`, + errorMessage, `.verify().toBeCalled(${calls})`, ); @@ -82,7 +87,7 @@ export class FunctionExpressionVerifier extends CallableVerifier { this.verifyToBeCalledWithArgsTooFewArgs( actualArgs, expectedArgs, - `Function "${this.#name}" was called with ${actualArgs.length} {{ arg_noun }} instead of ${expectedArgs.length}.`, + `Function "${this.#name}" was called with ${actualArgs.length} arg(s) instead of ${expectedArgs.length}.`, codeThatThrew, ); diff --git a/src/verifiers/method_verifier.ts b/src/verifiers/method_verifier.ts index 32bf268f..1964f3e9 100644 --- a/src/verifiers/method_verifier.ts +++ b/src/verifiers/method_verifier.ts @@ -52,10 +52,15 @@ export class MethodVerifier extends CallableVerifier { expectedCalls?: number, ): this { const calls = expectedCalls ?? ""; + + const errorMessage = expectedCalls + ? `Method "${this.#method_name}" was not called ${calls} time(s).` + : `Method "${this.#method_name}" was not called.`; + this.verifyToBeCalled( actualCalls, expectedCalls, - `Method "${this.#method_name}" was not called.`, + errorMessage, `.verify("${this.#method_name}").toBeCalled(${calls})`, ); @@ -88,7 +93,7 @@ export class MethodVerifier extends CallableVerifier { this.verifyToBeCalledWithArgsTooFewArgs( actualArgs, expectedArgs, - `Method "${this.#method_name}" was called with ${actualArgs.length} {{ arg_noun }} instead of ${expectedArgs.length}.`, + `Method "${this.#method_name}" was called with ${actualArgs.length} arg(s) instead of ${expectedArgs.length}.`, codeThatThrew, ); diff --git a/tests/deno/src/verifiers/function_expression_verifier_test.ts b/tests/deno/src/verifiers/function_expression_verifier_test.ts index d4d11d2d..ee1504bd 100644 --- a/tests/deno/src/verifiers/function_expression_verifier_test.ts +++ b/tests/deno/src/verifiers/function_expression_verifier_test.ts @@ -31,7 +31,7 @@ Deno.test("FunctionExpressionVerifier", async (t) => { const expected = ` -VerificationError: Function "doSomething" was not called. +VerificationError: Function "doSomething" was not called 2 time(s). at /deno/src/verifiers/function_expression_verifier_test.ts:{line}:{column} Verification Results: @@ -62,7 +62,7 @@ Check the above "function_expression_verifier_test.ts" file at/around line {line const expected = ` -VerificationError: Function "doSomething" received unexpected arg \`2\` at parameter position 1. +VerificationError: Function "doSomething" received unexpected arg \`1\` at parameter position 1. at /deno/src/verifiers/function_expression_verifier_test.ts:{line}:{column} Verification Results: diff --git a/tests/deno/src/verifiers/method_verifier_test.ts b/tests/deno/src/verifiers/method_verifier_test.ts index 112d2a34..54ec9e38 100644 --- a/tests/deno/src/verifiers/method_verifier_test.ts +++ b/tests/deno/src/verifiers/method_verifier_test.ts @@ -37,7 +37,7 @@ Deno.test("MethodVerifier", async (t) => { const expected = ` -VerificationError: Method "doSomething" was not called. +VerificationError: Method "doSomething" was not called 2 time(s). at /deno/src/verifiers/method_verifier_test.ts:{line}:{column} Verification Results: @@ -68,7 +68,7 @@ Check the above "method_verifier_test.ts" file at/around line {line} for code li const expected = ` -VerificationError: Method "doSomething" received unexpected arg \`2\` at parameter position 1. +VerificationError: Method "doSomething" received unexpected arg \`1\` at parameter position 1. at /deno/src/verifiers/method_verifier_test.ts:{line}:{column} Verification Results: From 8776cfec6c2c811e023b057664a5c1fb1573ed29 Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sun, 8 May 2022 10:21:24 -0400 Subject: [PATCH 30/31] fix: hide mock.toBeCalledWithArgs and .toBeCalledWithoutArgs() (these are not yet implemented properly) --- src/interfaces.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces.ts b/src/interfaces.ts index f883fb62..d18ebe99 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -83,14 +83,14 @@ export interface IMethodExpectation { * * @returns `this` To allow method chaining. */ - toBeCalledWithArgs(requiredArg: unknown, ...restOfArgs: unknown[]): this; + // toBeCalledWithArgs(requiredArg: unknown, ...restOfArgs: unknown[]): this; /** * Set an expectation that a method call should be called without args. * * @returns `this` To allow method chaining. */ - toBeCalledWithoutArgs(): this; + // toBeCalledWithoutArgs(): this; } //////////////////////////////////////////////////////////////////////////////// From f7cde320eba1a29e2e5185ce4156c5f16277f9fe Mon Sep 17 00:00:00 2001 From: Eric Crooks Date: Sun, 8 May 2022 11:22:56 -0400 Subject: [PATCH 31/31] fix(spies): show correct code that threw --- src/spy/spy_stub_builder.ts | 7 +++++++ src/verifiers/method_verifier.ts | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/spy/spy_stub_builder.ts b/src/spy/spy_stub_builder.ts index 745aeb8e..1b409c96 100644 --- a/src/spy/spy_stub_builder.ts +++ b/src/spy/spy_stub_builder.ts @@ -148,9 +148,12 @@ class SpyStubMethodVerifier * See `IVerifier.toBeCalled()`. */ public toBeCalled(expectedCalls?: number): this { + const calls = expectedCalls ?? ""; + return super.toBeCalled( this.calls, expectedCalls, + `.verify().toBeCalled(${calls})`, ); } @@ -158,9 +161,12 @@ class SpyStubMethodVerifier * See `IVerifier.toBeCalledWithArgs()`. */ public toBeCalledWithArgs(...expectedArgs: unknown[]): this { + const expectedArgsAsString = this.argsAsString(expectedArgs); + return super.toBeCalledWithArgs( this.args, expectedArgs, + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, ); } @@ -170,6 +176,7 @@ class SpyStubMethodVerifier public toBeCalledWithoutArgs(): this { return super.toBeCalledWithoutArgs( this.args, + `.verify().toBeCalledWithoutArgs()`, ); } } diff --git a/src/verifiers/method_verifier.ts b/src/verifiers/method_verifier.ts index 1964f3e9..7647fdf4 100644 --- a/src/verifiers/method_verifier.ts +++ b/src/verifiers/method_verifier.ts @@ -44,14 +44,17 @@ export class MethodVerifier extends CallableVerifier { * @param expectedCalls - The number of calls expected. If this is -1, then * just verify that the method was called without checking how many times it * was called. + * @param codeThatThrew - (Optional) A custom display of code that throws. * * @returns `this` To allow method chaining. */ public toBeCalled( actualCalls: number, expectedCalls?: number, + codeThatThrew?: string ): this { const calls = expectedCalls ?? ""; + codeThatThrew = codeThatThrew ?? `.verify("${this.#method_name}").toBeCalled(${calls})`; const errorMessage = expectedCalls ? `Method "${this.#method_name}" was not called ${calls} time(s).` @@ -61,7 +64,7 @@ export class MethodVerifier extends CallableVerifier { actualCalls, expectedCalls, errorMessage, - `.verify("${this.#method_name}").toBeCalled(${calls})`, + codeThatThrew ); return this; @@ -72,16 +75,17 @@ export class MethodVerifier extends CallableVerifier { * * @param actualArgs - The actual args that this method was called with. * @param expectedArgs - The args this method is expected to have received. + * @param codeThatThrew - (Optional) A custom display of code that throws. * * @returns `this` To allow method chaining. */ public toBeCalledWithArgs( actualArgs: unknown[], expectedArgs: unknown[], + codeThatThrew?: string ): this { const expectedArgsAsString = this.argsAsString(expectedArgs); - const codeThatThrew = - `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`; + codeThatThrew = codeThatThrew ?? `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`; this.verifyToBeCalledWithArgsTooManyArgs( actualArgs, @@ -112,16 +116,20 @@ export class MethodVerifier extends CallableVerifier { * * @param actualArgs - The actual args that this method was called with. This * method expects it to be an empty array. + * @param codeThatThrew - (Optional) A custom display of code that throws. * * @returns `this` To allow method chaining. */ public toBeCalledWithoutArgs( actualArgs: unknown[], + codeThatThrew?: string ): this { + codeThatThrew = codeThatThrew ?? `.verify("${this.method_name}").toBeCalledWithoutArgs()`; + this.verifyToBeCalledWithoutArgs( actualArgs, `Method "${this.#method_name}" was called with args when expected to receive no args.`, - `.verify("${this.method_name}").toBeCalledWithoutArgs()`, + codeThatThrew ); return this;