diff --git a/console/build_esm_lib.ts b/console/build_esm_lib.ts index d9c2bccf..cf6f1d27 100644 --- a/console/build_esm_lib.ts +++ b/console/build_esm_lib.ts @@ -7,12 +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) { @@ -22,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) => { diff --git a/mod.ts b/mod.ts index 53715a7d..feebb6f0 100644 --- a/mod.ts +++ b/mod.ts @@ -1,9 +1,16 @@ -import type { Constructor, StubReturnValue } 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"; +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"; +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - DUMMY ///////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Create a dummy. * @@ -24,6 +31,10 @@ export function Dummy(constructorFn?: Constructor): T { return dummy; } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - FAKE ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Get the builder to create fake objects. * @@ -39,6 +50,10 @@ export function Fake(constructorFn: Constructor): FakeBuilder { return new FakeBuilder(constructorFn); } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - MOCK ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Get the builder to create mocked objects. * @@ -56,10 +71,117 @@ export function Mock(constructorFn: Constructor): MockBuilder { return new MockBuilder(constructorFn); } +//////////////////////////////////////////////////////////////////////////////// +// 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< + OriginalFunction extends Callable, + ReturnValue, +>( + // deno-lint-ignore no-explicit-any + functionExpression: OriginalFunction, + returnValue?: ReturnValue, + // deno-lint-ignore no-explicit-any +): Interfaces.ISpyStubFunctionExpression & OriginalFunction; + +/** + * Create a spy out of an object's method. + * + * @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 The original method with spying capabilities. + */ +export function Spy( + obj: OriginalObject, + dataMember: MethodOf, + returnValue?: ReturnValue, +): Interfaces.ISpyStubMethod; + +/** + * Create spy out of a class. + * + * @param constructorFn - The constructor function of the object to spy on. + * + * @returns The original object with spying capabilities. + */ +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 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. + * @param returnValue - (Optional) If creating a spy out of an object's method, then + * this would be the return value. + */ +export function Spy( + original: unknown, + methodOrReturnValue?: unknown, + returnValue?: unknown, +): unknown { + 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 original) { + return new SpyBuilder(original 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(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(original as OriginalObject) + .method(methodOrReturnValue as MethodOf) + .returnValue(returnValue as ReturnValue) + .createForObjectMethod(); +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - STUB ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + /** * Create a stub function that returns "stubbed". + * + * @returns A 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. @@ -69,11 +191,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, +): void; /** * Take the given object and stub its given data member to return the given * return value. @@ -82,19 +204,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() { @@ -104,7 +226,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/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", diff --git a/src/errors.ts b/src/errors.ts index a1b6705f..367bedfc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,17 +20,55 @@ export class FakeError 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 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 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 MethodVerificationError extends RhumError { +export class VerificationError extends RhumError { #actual_results: string; #code_that_threw: string; #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, @@ -38,7 +76,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; @@ -46,63 +84,73 @@ export class MethodVerificationError 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 = [ "", "deno:runtime", + "callable_verifier.ts", "method_verifier.ts", + "function_expression_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) => { return ignoredLines.filter((ignoredLine: string) => { return line.includes(ignoredLine); }).length === 0; - }).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 `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); + } + + const conciseStack = conciseStackArray.join("\n"); const extractedFilenameWithLineAndColumnNumbers = conciseStack.match( /\/[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("/", ""); + + let newStack = ` + +${conciseStack} - newStack += `\n\nVerification Results:`; - newStack += `\n ${this.#actual_results}`; - newStack += `\n ${this.#expected_results}`; +Verification Results: + ${this.#actual_results} + ${this.#expected_results} +`; if (lineNumber) { - newStack += `\n\nCheck the above '${ - filename.replace("/", "") - }' file at/around line ${lineNumber} for the following code 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; } } - -/** - * Error to thrown in relation to mock logic. - */ -export class MockError extends RhumError { - constructor(message: string) { - super("MockError", message); - } -} diff --git a/src/fake/fake_builder.ts b/src/fake/fake_builder.ts index 24125710..2e234aa0 100644 --- a/src/fake/fake_builder.ts +++ b/src/fake/fake_builder.ts @@ -17,13 +17,14 @@ 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); 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 d97d4ab6..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,9 +30,17 @@ export function createFake( */ #original!: OriginalObject; - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR /////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + constructor() { + super(...originalConstructorArgs); + } + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// /** * @param original - The original object to fake. diff --git a/src/interfaces.ts b/src/interfaces.ts index 9dff525f..d18ebe99 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,20 +1,13 @@ -import type { MethodCalls, MethodOf } from "./types.ts"; +import type { MethodOf, MockMethodCalls } from "./types.ts"; -export interface IMethodExpectation { - toBeCalled(expectedCalls: number): void; - // toBeCalledWith(...args: unknown[]): this; -} - -export interface IMethodVerification { - toBeCalled(expectedCalls: number): this; - toBeCalledWith(...args: unknown[]): this; -} - -export interface IPreProgrammedMethod { - willReturn(returnValue: ReturnValue): void; - willThrow(error: IError): void; -} +//////////////////////////////////////////////////////////////////////////////// +// 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). @@ -28,6 +21,10 @@ export interface IError { message?: string; } +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IFAKE ///////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + export interface IFake { /** * Helper property to show that this object is a fake. @@ -35,62 +32,253 @@ export interface IFake { is_fake: boolean; /** - * Entry point to shortcut a method. Example: + * Access the method shortener to make the given method take a shortcut. + * + * @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. * - * ```ts - * fake.method("methodName").willReturn(...); - * fake.method("methodName").willThrow(...); - * ``` + * @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; + + /** + * 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; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IMETHODCHANGER //////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +export interface IMethodChanger { + /** + * Make the given method return the given `returnValue`. + * + * @param returnValue - The value to make the method return. + */ + willReturn(returnValue: T): void; + + /** + * 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. + */ + willThrow(error: IError & T): void; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - IMETHODVERIFIER /////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface of verifier that verifies method calls. + */ +export interface IMethodVerifier extends IVerifier { + /** + * Property to hold the args used when the method using this verifier was + * called. + */ + args: unknown[]; + + /** + * 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 { - 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; /** - * 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; + + /** + * 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( + method: MethodOf, + ): IMethodVerifier; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - ISPYSTUBFUNCTIONEXPRESSION //////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface for spies on function expressions. + */ +export interface ISpyStubFunctionExpression { + /** + * Access the function expression verifier. + * + * @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. + * + * @returns A verifier to verify method calls. + */ + verify(): IMethodVerifier; +} + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - ITESTDOUBLE /////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + export interface ITestDouble { init( original: OriginalObject, methodsToTrack: string[], ): void; } + +//////////////////////////////////////////////////////////////////////////////// +// 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/method_verifier.ts b/src/method_verifier.ts deleted file mode 100644 index 450691b6..00000000 --- a/src/method_verifier.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { MethodOf } from "./types.ts"; -import { MethodVerificationError } from "./errors.ts"; - -export class MethodVerifier { - #method_name: MethodOf; - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - constructor(methodName: MethodOf) { - this.#method_name = methodName; - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * 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. - */ - public toBeCalled( - actualCalls: number, - expectedCalls?: number, - ): void { - if (!expectedCalls) { - if (actualCalls <= 0) { - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect number of calls.`, - `.method("${this.#method_name}").toBeCalled(${expectedCalls})`, - `Expected calls -> 1 (or more)`, - `Actual calls -> 0`, - ); - } - } - - if (actualCalls !== expectedCalls) { - throw new MethodVerificationError( - `Method "${this.#method_name}" received incorrect number of calls.`, - `.method("${this.#method_name}").toBeCalled(${expectedCalls})`, - `Expected calls -> ${expectedCalls}`, - `Actual calls -> ${actualCalls}`, - ); - } - } - - /** - * Verify that the actual arguments match the expected arguments. - */ - public toBeCalledWith( - actualArgs: unknown[], - expectedArgs: unknown[], - ): void { - 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(", ")}]`, - ); - } - - 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}").toBeCalledWith(...)`, - `Expected arg type -> ${arg}${expectedArgType}`, - `Actual arg type -> ${actualArgs[index]}${actualArgType}`, - ); - } - }); - - actualArgs.every((arg: unknown) => { - return expectedArgs.indexOf(arg) >= 0; - }); - } - - /** - * 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]); - } -} diff --git a/src/mock/mock_builder.ts b/src/mock/mock_builder.ts index 493d9e4a..832a6d96 100644 --- a/src/mock/mock_builder.ts +++ b/src/mock/mock_builder.ts @@ -19,13 +19,14 @@ 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); const mock = createMock, ClassToMock>( this.constructor_fn, + ...this.constructor_args, ); (mock as IMock & ITestDouble).init( @@ -99,6 +100,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 efc1ec94..bf950e59 100644 --- a/src/mock/mock_mixin.ts +++ b/src/mock/mock_mixin.ts @@ -1,38 +1,95 @@ 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"; +/** + * Class to help mocks create method expectations. + */ class MethodExpectation { - #expected_calls = 0; + /** + * Property to hold the number of expected calls this method should receive. + */ + #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`. + */ #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. + * 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; + } + + /** + * See `IMethodExpectation.toBeCalledWithoutArgs()`. + */ + public toBeCalledWithoutArgs(): this { + this.#expected_args = undefined; + return this; } /** * Verify all expected calls were made. + * + * @param actualCalls - The number of actual calls. */ public verifyCalls(actualCalls: number): void { this.#verifier.toBeCalled(actualCalls, this.#expected_calls); + this.#verifier.toBeCalledWithoutArgs(this.#args ?? []); } } @@ -45,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 @@ -52,14 +110,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). @@ -71,17 +129,25 @@ export function createMock( */ #original!: OriginalObject; - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR /////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + constructor() { + super(...originalConstructorArgs); + } + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// - get calls(): MethodCalls { + get calls(): MockMethodCalls { return this.#calls; } - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// /** * @param original - The original object to mock. @@ -93,10 +159,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, @@ -107,10 +170,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, @@ -137,7 +197,7 @@ export function createMock( } /** - * Verify all expectations created in this mock. + * See `IMock.verifyExpectations()`. */ public verifyExpectations(): void { this.#expectations.forEach((e: MethodExpectation) => { @@ -145,15 +205,17 @@ export function createMock( }); } - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + // 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. */ diff --git a/src/spy/spy_builder.ts b/src/spy/spy_builder.ts new file mode 100644 index 00000000..97970dd9 --- /dev/null +++ b/src/spy/spy_builder.ts @@ -0,0 +1,80 @@ +import type { Constructor } from "../types.ts"; +import type { ISpy, ITestDouble } from "../interfaces.ts"; +import { createSpy } from "./spy_mixin.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 extends TestDoubleBuilder { + ////////////////////////////////////////////////////////////////////////////// + // 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, + ...this.constructor_args, + ); + + (spy as ISpy & ITestDouble).init( + original, + this.getAllFunctionNames(original), + ); + + // Attach all of the original's properties to the spy + this.addOriginalProperties>(original, spy); + + // Attach all of the original's native methods to the spy + this.#addNativeMethods(original, spy); + + return spy as ClassToSpy & ISpy; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * 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. + */ + #addNativeMethods( + original: ClassToSpy, + spy: ISpy, + ): void { + 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, + ); + }); + } +} diff --git a/src/spy/spy_mixin.ts b/src/spy/spy_mixin.ts new file mode 100644 index 00000000..6e53b657 --- /dev/null +++ b/src/spy/spy_mixin.ts @@ -0,0 +1,107 @@ +import type { Constructor, MethodOf } from "../types.ts"; +import type { IMethodVerifier, ISpy, ISpyStubMethod } from "../interfaces.ts"; +import { SpyStubBuilder } from "./spy_stub_builder.ts"; + +/** + * Create a spy as an extension of an original object. + */ +export function createSpy( + OriginalClass: OriginalConstructor, + ...originalConstructorArgs: unknown[] +): ISpy { + const Original = OriginalClass as unknown as Constructor< + // deno-lint-ignore no-explicit-any + (...args: any[]) => any + >; + return new class SpyExtension extends Original { + /** + * See `ISpy#is_spy`. + */ + is_spy = true; + + /** + * See `ISpy#stubbed_methods`. + */ + #stubbed_methods!: Record, ISpyStubMethod>; + + /** + * The original object that this class creates a spy out of. + */ + #original!: OriginalObject; + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR /////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + constructor() { + super(...originalConstructorArgs); + } + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS ///////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + get stubbed_methods(): Record, ISpyStubMethod> { + return this.#stubbed_methods; + } + + //////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////// + + /** + * @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[]) { + this.#original = original; + this.#stubbed_methods = this.#constructStubbedMethodsProperty( + methodsToTrack, + ); + } + + /** + * Get the verifier for the given method to do actual verification. + */ + public verify( + methodName: MethodOf, + ): IMethodVerifier { + return this.#stubbed_methods[methodName].verify(); + } + + //////////////////////////////////////////////////////////////////////////// + // 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. + */ + #constructStubbedMethodsProperty( + methodsToTrack: string[], + ): Record, ISpyStubMethod> { + const stubbedMethods: Partial< + Record, ISpyStubMethod> + > = {}; + + methodsToTrack.forEach((method: string) => { + const spyMethod = new SpyStubBuilder(this) + .method(method as MethodOf) + .returnValue("spy-stubbed") + .createForObjectMethod(); + stubbedMethods[method as MethodOf] = spyMethod; + }); + + return stubbedMethods as Record< + MethodOf, + ISpyStubMethod + >; + } + }(); +} diff --git a/src/spy/spy_stub_builder.ts b/src/spy/spy_stub_builder.ts new file mode 100644 index 00000000..1b409c96 --- /dev/null +++ b/src/spy/spy_stub_builder.ts @@ -0,0 +1,330 @@ +import type { MethodOf } from "../types.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 { + /** + * See `IFunctionExpressionVerifier.args`. + */ + #args: unknown[]; + + /** + * See `IFunctionExpressionVerifier.calls`. + */ + #calls: number; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param name - See `FunctionExpressionVerifier.#name`. + * @param calls - See `IFunctionExpressionVerifier.calls`. + * @param args - See `IFunctionExpressionVerifier.args`. + */ + constructor( + name: string, + calls: number, + args: unknown[], + ) { + super(name); + 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 //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * See `IVerifier.toBeCalled()`. + */ + public toBeCalled(expectedCalls?: number): this { + return super.toBeCalled( + this.#calls, + expectedCalls, + ); + } + + /** + * See `IVerifier.toBeCalledWithArgs()`. + */ + public toBeCalledWithArgs(...expectedArgs: unknown[]): this { + return super.toBeCalledWithArgs( + this.#args, + expectedArgs, + ); + } + + /** + * See `IVerifier.toBeCalledWithoutArgs()`. + */ + public toBeCalledWithoutArgs() { + super.toBeCalledWithoutArgs( + this.#args, + ); + return this; + } +} + +/** + * 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 + extends MethodVerifier { + /** + * See `IMethodVerifier.args`. + */ + #args: unknown[]; + + /** + * See `IMethodVerifier.calls`. + */ + #calls: number; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param methodName - See `MethodVerifier.method_name`. + * @param calls - See `IMethodVerifier.calls`. + * @param args - See `IMethodVerifier.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 //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * See `IVerifier.toBeCalled()`. + */ + public toBeCalled(expectedCalls?: number): this { + const calls = expectedCalls ?? ""; + + return super.toBeCalled( + this.calls, + expectedCalls, + `.verify().toBeCalled(${calls})`, + ); + } + + /** + * See `IVerifier.toBeCalledWithArgs()`. + */ + public toBeCalledWithArgs(...expectedArgs: unknown[]): this { + const expectedArgsAsString = this.argsAsString(expectedArgs); + + return super.toBeCalledWithArgs( + this.args, + expectedArgs, + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`, + ); + } + + /** + * See `IVerifier.toBeCalledWithoutArgs()`. + */ + public toBeCalledWithoutArgs(): this { + return super.toBeCalledWithoutArgs( + this.args, + `.verify().toBeCalledWithoutArgs()`, + ); + } +} + +/** + * 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 args 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; + } + + ////////////////////////////////////////////////////////////////////////////// + // 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; + } + + /** + * 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", { + value: () => + new SpyStubMethodVerifier( + this.#method!, + this.#calls, + this.#last_called_with_args, + ), + }); + + return this as unknown as ISpyStubMethod & { verify: IMethodVerifier }; + } + + /** + * 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 = (): IFunctionExpressionVerifier => + new SpyStubFunctionExpressionVerifier( + (this.#original as unknown as { name: string }).name, + 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 + : "spy-stubbed"; + }, + writable: true, + }); + } +} diff --git a/src/test_double_builder.ts b/src/test_double_builder.ts index 1e775869..b20cac96 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 args the class constructor takes. This is used to instantiate the + * original with args (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; @@ -51,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 966cad97..81b6320b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,16 +1,30 @@ +/** + * 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; -export type MethodArguments = Record; +/** + * Describes the type as a constructable object using the `new` keyword. + */ +// deno-lint-ignore no-explicit-any +export type Constructor = new (...args: any[]) => Class; -export type MethodCalls = Record; +/** + * 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. + */ +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")`. + */ export type MethodOf = { // deno-lint-ignore no-explicit-any [K in keyof Object]: Object[K] extends (...args: any[]) => unknown ? K : never; }[keyof Object]; - -export type StubReturnValue = T extends (...args: unknown[]) => unknown - ? () => R - : string; diff --git a/src/verifiers/callable_verifier.ts b/src/verifiers/callable_verifier.ts new file mode 100644 index 00000000..9e6b13bd --- /dev/null +++ b/src/verifiers/callable_verifier.ts @@ -0,0 +1,278 @@ +import { VerificationError } from "../errors.ts"; + +export class CallableVerifier { + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * 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"] + * + * ... becomes ... + * + * true, false, "hello" + * + * The above will ultimately end up in stack trace messages like: + * + * .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]) + * + * @param args - The args to convert to a string. + * + * @returns The args as a string. + */ + 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)}`; + }).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 + ">"; + } + + /** + * 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) { + return; + } + + throw new VerificationError( + errorMessage, + codeThatThrew, + `Actual calls -> 0`, + `Expected calls -> 1 (or more)`, + ); + } + + // 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, + `Actual calls -> ${actualCalls}`, + `Expected calls -> ${expectedCalls}`, + ); + } + + /** + * 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 VerificationError( + errorMessage, + codeThatThrew, + `Actual args -> ${ + actualArgs.length > 0 + ? `(${this.argsAsStringWithTypes(actualArgs)})` + : "(no args)" + }`, + `Expected args -> ${this.argsAsStringWithTypes(expectedArgs)}`, + ); + } + } + + /** + * 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 VerificationError( + errorMessage, + codeThatThrew, + `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, + ); + } + } + + /** + * 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[], + errorMessage: string, + codeThatThrew: string, + ): void { + expectedArgs.forEach((arg: unknown, index: number) => { + const parameterPosition = index + 1; + + // Args match? We gucci. + if (actualArgs[index] === arg) { + return; + } + + // 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 = `\`${actualArgs[index]}${this.getArgType(actualArgs[index])}\``; + + throw new VerificationError( + errorMessage + .replace("{{ unexpected_arg }}", unexpectedArg) + .replace("{{ parameter_position }}", parameterPosition.toString()), + codeThatThrew, + `Actual call -> (${this.argsAsStringWithTypes(actualArgs)})`, + `Expected call -> (${this.argsAsStringWithTypes(expectedArgs)})`, + ); + }); + } + + /** + * 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, + ): this { + const actualArgsAsString = JSON.stringify(actualArgs) + .slice(1, -1) + .replace(/,/g, ", "); + + if (actualArgs.length === 0) { + return this; + } + + throw new VerificationError( + errorMessage, + codeThatThrew, + `Actual args -> (${actualArgsAsString})`, + `Expected args -> (no args)`, + ); + } + + ////////////////////////////////////////////////////////////////////////////// + // 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/function_expression_verifier.ts b/src/verifiers/function_expression_verifier.ts new file mode 100644 index 00000000..73c5c288 --- /dev/null +++ b/src/verifiers/function_expression_verifier.ts @@ -0,0 +1,119 @@ +import { CallableVerifier } from "./callable_verifier.ts"; + +/** + * Test doubles use this class to verify that their methods were called, were + * called with a number of args, were called with specific types of args, 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. + */ + public toBeCalled( + actualCalls: number, + 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, + errorMessage, + `.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. + */ + public toBeCalledWithArgs( + actualArgs: unknown[], + expectedArgs: unknown[], + ): this { + const expectedArgsAsString = this.argsAsString(expectedArgs); + const codeThatThrew = + `.verify().toBeCalledWithArgs(${expectedArgsAsString})`; + + this.verifyToBeCalledWithArgsTooManyArgs( + actualArgs, + expectedArgs, + `Function "${this.#name}" received too many args.`, + codeThatThrew, + ); + + this.verifyToBeCalledWithArgsTooFewArgs( + actualArgs, + expectedArgs, + `Function "${this.#name}" was called with ${actualArgs.length} arg(s) instead of ${expectedArgs.length}.`, + codeThatThrew, + ); + + this.verifyToBeCalledWithArgsUnexpectedValues( + actualArgs, + expectedArgs, + `Function "${this.#name}" received unexpected arg {{ unexpected_arg }} at parameter position {{ parameter_position }}.`, + codeThatThrew, + ); + + return this; + } + + /** + * 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. + */ + public toBeCalledWithoutArgs( + actualArgs: unknown[], + ): 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 new file mode 100644 index 00000000..7647fdf4 --- /dev/null +++ b/src/verifiers/method_verifier.ts @@ -0,0 +1,137 @@ +import type { MethodOf } from "../types.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 args, were called with specific types of args, 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 - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * 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 - (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).` + : `Method "${this.#method_name}" was not called.`; + + this.verifyToBeCalled( + actualCalls, + expectedCalls, + errorMessage, + codeThatThrew + ); + + 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 - (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); + codeThatThrew = codeThatThrew ?? `.verify("${this.#method_name}").toBeCalledWithArgs(${expectedArgsAsString})`; + + this.verifyToBeCalledWithArgsTooManyArgs( + actualArgs, + expectedArgs, + `Method "${this.#method_name}" received too many args.`, + codeThatThrew, + ); + + this.verifyToBeCalledWithArgsTooFewArgs( + actualArgs, + expectedArgs, + `Method "${this.#method_name}" was called with ${actualArgs.length} arg(s) instead of ${expectedArgs.length}.`, + codeThatThrew, + ); + + this.verifyToBeCalledWithArgsUnexpectedValues( + actualArgs, + expectedArgs, + `Method "${this.#method_name}" received unexpected arg {{ unexpected_arg }} at parameter position {{ parameter_position }}.`, + codeThatThrew, + ); + + return this; + } + + /** + * 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 - (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.`, + codeThatThrew + ); + + return this; + } +} 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..949b3b3b --- /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"); + } + }); + }); +}); diff --git a/tests/deno/src/errors_test.ts b/tests/deno/src/errors_test.ts new file mode 100644 index 00000000..0a0cc960 --- /dev/null +++ b/tests/deno/src/errors_test.ts @@ -0,0 +1,68 @@ +import { VerificationError } from "../../../src/errors.ts"; +import { assertEquals } 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, + ); + }, + ); +}); 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..ee1504bd --- /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 2 time(s). + 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 \`1\` 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..54ec9e38 --- /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<_MyClass>("doSomething"); + + const error = throwError(() => mv.toBeCalled(1, 2)); + + const expected = ` + +VerificationError: Method "doSomething" was not called 2 time(s). + 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<_MyClass>("doSomething"); + + const error = throwError(() => mv.toBeCalledWithArgs([1], [2])); + + const expected = ` + +VerificationError: Method "doSomething" received unexpected arg \`1\` 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<_MyClass>("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, + ); + }, + ); + }); +}); 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 1bf2c115..1b7bb152 100644 --- a/tests/deno/unit/mod/mock_test.ts +++ b/tests/deno/unit/mod/mock_test.ts @@ -45,14 +45,39 @@ 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 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) => { @@ -245,7 +270,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(); }, @@ -420,11 +445,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 +476,10 @@ class TestObjectLotsOfDataMembers { public set age(val: number) { this.#age = val; } + + public callPrivateService() { + this.#private_service.doSomething(); + } } class TestRequestHandler { @@ -464,3 +499,9 @@ class TestRequestHandler { return "posted"; } } + +class PrivateService { + public doSomething(): boolean { + return true; + } +} diff --git a/tests/deno/unit/mod/spy_test.ts b/tests/deno/unit/mod/spy_test.ts new file mode 100644 index 00000000..0db3c81f --- /dev/null +++ b/tests/deno/unit/mod/spy_test.ts @@ -0,0 +1,480 @@ +import { Fake, 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 "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"; + } +} + +Deno.test("Spy()", async (t) => { + 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 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"); + }, + ); + + 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, "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); + }); + + await t.step("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"]); + }); + }); + + 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(), "spy-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 "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(); + }); + + 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 "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(); + }, + ); + + 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 "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", + ); + }); + + 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 "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", + ); + }, + ); + + 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 "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(); + }); + + 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 "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(); + }, + ); + + 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 "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", + ); + }); + + 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 "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", + ); + }, + ); + + await t.step("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(); + }); + + 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); + }); + }); +}); 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); + }); + }); +});