diff --git a/README.md b/README.md index 7ea820a5..44ef2258 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,17 @@ -

- Rhum - A lightweight testing framework for Deno. -

Rhum

-

-

A test double module to stub and mock your code

-

- - - - - - - - - - - - - - - -

+# Rhum ---- +[![Latest Release](https://img.shields.io/github/release/drashland/rhum.svg?color=bright_green&label=latest)](#) +[![CI master](https://img.shields.io/github/workflow/status/drashland/rhum/master?label=ci%20-%20master)](#) +[![YouTube](https://img.shields.io/badge/tutorials-youtube-red)](https://rb.gy/vxmeed) -### Features +Rhum logo -- Lightweight -- Zero 3rd party dependencies -- Simple and easy to use -- Asynchronous support -- Mock requests -- Hooks +Rhum is a test double library that follows +[test double definitions](https://martinfowler.com/bliki/TestDouble.html) from +Gerard Meszaros. -### Getting Started +View the full documentation at https://drash.land/rhum. -To add Rhum to your project, follow the quick start guide -[here](https://drash.land/rhum/#/#quickstart). - -## Contributing - -Want to contribute? Follow the Contributing Guidelines -[here](https://github.com/drashland/.github/blob/master/CONTRIBUTING.md). - -## License - -All code is released under the [MIT License](./LICENSE). +In the event the documentation pages are not accessible, please view the raw +version of the documentation at +https://github.com/drashland/website-v2/tree/main/docs. diff --git a/mod.ts b/mod.ts index 3ee3ac39..53715a7d 100644 --- a/mod.ts +++ b/mod.ts @@ -1,63 +1,124 @@ -import type { Constructor, Stubbed } from "./src/types.ts"; -import { MockBuilder } from "./src/mock_builder.ts"; - -export type { Constructor, Stubbed } from "./src/types.ts"; -export { MockBuilder } from "./src/mock_builder.ts"; +import type { Constructor, StubReturnValue } from "./src/types.ts"; +import { MockBuilder } from "./src/mock/mock_builder.ts"; +import { FakeBuilder } from "./src/fake/fake_builder.ts"; +export * as Types from "./src/types.ts"; +export * as Interfaces from "./src/interfaces.ts"; /** - * Get the mock builder to mock classes. + * Create a dummy. * - * @param constructorFn - The constructor function of the object to mock. + * Per Martin Fowler (based on Gerard Meszaros), "Dummy objects are passed + * around but never actually used. Usually they are just used to fill parameter + * lists." + * + * @param constructorFn - The constructor function to use to become the + * prototype of the dummy. Dummy objects should be the same instance as what + * they are standing in for. For example, if a `SomeClass` parameter needs to be + * filled with a dummy because it is out of scope for a test, then the dummy + * should be an instance of `SomeClass`. + * @returns A dummy object being an instance of the given constructor function. + */ +export function Dummy(constructorFn?: Constructor): T { + const dummy = Object.create({}); + Object.setPrototypeOf(dummy, constructorFn ?? Object); + return dummy; +} + +/** + * Get the builder to create fake objects. * - * Returns an instance of the MockBuilder class. + * Per Martin Fowler (based on Gerard Meszaros), "Fake objects actually have + * working implementations, but usually take some shortcut which makes them not + * suitable for production (an InMemoryTestDatabase is a good example)." * - * class ToBeMocked { ... } + * @param constructorFn - The constructor function of the object to fake. * - * const mock = Rhum - * .mock(ToBeMocked) - * .withConstructorArgs("someArg") // if the class to be mocked has a constructor and it requires args - * .create(); + * @returns Instance of `FakeBuilder`. */ -export const Mock = (constructorFn: Constructor): MockBuilder => { - return new MockBuilder(constructorFn); -}; +export function Fake(constructorFn: Constructor): FakeBuilder { + return new FakeBuilder(constructorFn); +} /** - * Stub a member of an object. + * Get the builder to create mocked objects. * - * @param obj -The object containing the member to stub. - * @param member -The member to stub. - * @param value - The return value of the stubbed member. + * Per Martin Fowler (based on Gerard Meszaros), "Mocks are pre-programmed with + * expectations which form a specification of the calls they are expected to + * receive. They can throw an exception if they receive a call they don't expect + * and are checked during verification to ensure they got all the calls they + * were expecting." * - * Returns the object in question as a Stubbed type. Being a Stubbed type - * means it has access to a `.stub()` method for stubbing properties and - * methods. + * @param constructorFn - The constructor function of the object to mock. * - * class MyObject { - * public some_property = "someValue"; - * } + * @returns Instance of `MockBuilder`. + */ +export function Mock(constructorFn: Constructor): MockBuilder { + return new MockBuilder(constructorFn); +} + +/** + * Create a stub function that returns "stubbed". + */ +export function Stub(): () => "stubbed"; +/** + * Take the given object and stub its given data member to return the given + * return value. * - * // Define the object that will have stubbed members as a stubbed object - * const myStubbedObject = Rhum.stubbed(new MyObject()); + * @param obj - The object receiving the stub. + * @param dataMember - The data member on the object to be stubbed. + * @param returnValue - (optional) What the stub should return. Defaults to + * "stubbed". + */ +export function Stub( + obj: T, + dataMember: keyof T, + returnValue?: R, +): StubReturnValue; +/** + * Take the given object and stub its given data member to return the given + * return value. * - * // Stub the object's some_property property to a certain value - * myStubbedObject.stub("some_property", "this property is now stubbed"); + * Per Martin Fowler (based on Gerard Meszaros), "Stubs provide canned answers + * to calls made during the test, usually not responding at all to anything + * outside what's programmed in for the test." * - * // Assert that the property was stubbed - * Rhum.asserts.assertEquals(myStubbedObject.some_property, "this property is now stubbed"); + * @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. + * Only used if `obj` is an object. + * @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 const Stub = (obj: T): Stubbed => { - (obj as unknown as { [key: string]: boolean }).is_stubbed = true; - (obj as unknown as { - [key: string]: (property: string, value: unknown) => void; - }).stub = function ( - property: string, - value: unknown, - ): void { - Object.defineProperty(obj, property, { - value: value, - }); - }; +export function Stub( + obj?: T, + dataMember?: keyof T, + returnValue?: R, +): unknown { + if (obj === undefined) { + return function stubbed() { + return "stubbed"; + }; + } - return obj as Stubbed; -}; + // 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 + // the value of `returnValue`. + if (typeof obj === "object" && dataMember !== undefined) { + // If we are stubbing a method, then make sure the method is still callable + if (typeof obj[dataMember] === "function") { + Object.defineProperty(obj, dataMember, { + value: () => returnValue !== undefined ? returnValue : "stubbed", + writable: true, + }); + } else { + // If we are stubbing a property, then just reassign the property + Object.defineProperty(obj, dataMember, { + value: returnValue !== undefined ? returnValue : "stubbed", + writable: true, + }); + } + } +} diff --git a/src/fake/fake_builder.ts b/src/fake/fake_builder.ts new file mode 100644 index 00000000..556b7b97 --- /dev/null +++ b/src/fake/fake_builder.ts @@ -0,0 +1,283 @@ +import type { Constructor } from "../types.ts"; +import type { IFake } from "../interfaces.ts"; +import { createFake } from "./fake_mixin.ts"; +import { PreProgrammedMethod } from "../pre_programmed_method.ts"; + +/** + * Builder to help build a fake object. This does all of the heavy-lifting to + * create a fake object. + */ +export class FakeBuilder { + /** + * The class to fake (should be constructable). + */ + #constructor_fn: Constructor; + + /** + * A list of arguments the class constructor takes. + */ + #constructor_args: unknown[] = []; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Construct an object of this class. + * + * @param constructorFn - The class to fake (should be constructable). + */ + constructor(constructorFn: Constructor) { + this.#constructor_fn = constructorFn; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Create the fake object. + * + * @returns The original object with capabilities from the fake class. + */ + public create(): ClassToFake & IFake { + const original = new this.#constructor_fn(...this.#constructor_args); + + const fake = createFake, ClassToFake>( + this.#constructor_fn, + ); + fake.init(original, this.#getAllFunctionNames(original)); + + // Attach all of the original's properties to the fake + this.#getAllPropertyNames(original).forEach((property: string) => { + this.#addOriginalObjectPropertyToFakeObject( + original, + fake, + property, + ); + }); + + // Attach all of the original's functions to the fake + this.#getAllFunctionNames(original).forEach((method: string) => { + this.#addOriginalObjectMethodToFakeObject( + original, + fake, + method, + ); + }); + + return fake as ClassToFake & IFake; + } + + /** + * 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 + * constructor function of the object being faked. + * + * @returns `this` so that methods in this class can be chained. + */ + public withConstructorArgs(...args: unknown[]): this { + this.#constructor_args = args; + return this; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Add an original object's method to a fake object without doing anything + * else. + * + * @param original - The original object containing the method to fake. + * @param fake - The fake object receiving the method to fake. + * @param method - The name of the method to fake -- callable via + * `fake[method](...)`. + */ + #addMethodToFakeObject( + original: ClassToFake, + fake: IFake, + method: string, + ): void { + Object.defineProperty(fake, method, { + value: original[method as keyof ClassToFake], + }); + } + + /** + * Add an original object's method to a fake object -- determining whether the + * method should or should not be trackable. + * + * @param original - The original object containing the method to add. + * @param fake - The fake object receiving the method. + * @param method - The name of the method to fake -- callable via + * `fake[method](...)`. + */ + #addOriginalObjectMethodToFakeObject( + original: ClassToFake, + fake: IFake, + method: string, + ): void { + const nativeMethods = [ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + ]; + + // If this is a native method, then do not do anything fancy. Just add it to + // the fake. + if (nativeMethods.includes(method as string)) { + return this.#addMethodToFakeObject( + original, + fake, + method, + ); + } + + // Otherwise, make the method trackable via `.calls` usage. + this.#addTrackableMethodToFakeObject( + original, + fake, + method as keyof ClassToFake, + ); + } + + /** + * Add an original object's property to a fake object. + * + * @param original The original object containing the property. + * @param fake The fake object receiving the property. + * @param property The name of the property -- retrievable via + * `fake[property]`. + */ + #addOriginalObjectPropertyToFakeObject( + original: ClassToFake, + fake: IFake, + property: string, + ): void { + const desc = Object.getOwnPropertyDescriptor(original, property) ?? + Object.getOwnPropertyDescriptor( + this.#constructor_fn.prototype, + property, + ); + + // If we do not have a desc, then we have no idea what the value should be. + // Also, we have no idea what we are copying, so we should just not do it. + if (!desc) { + return; + } + + // Basic property (e.g., public test = "hello"). We do not handle get() and + // set() because those are handled by the fake mixin. + if (("value" in desc)) { + Object.defineProperty(fake, property, { + value: desc.value, + writable: true, + }); + } + } + + /** + * Add a trackable method to a fake object. A trackable method is one that can + * be verified using `fake.calls[someMethod]`. + * + * @param original - The original object containing the method to add. + * @param fake - The fake object receiving the method. + * @param method - The name of the method. + */ + #addTrackableMethodToFakeObject( + original: ClassToFake, + fake: IFake, + method: keyof ClassToFake, + ): void { + Object.defineProperty(fake, method, { + value: (...args: unknown[]) => { + // Make sure the method calls its original self + const methodToCall = + (original[method as keyof ClassToFake] as unknown as ( + ...params: unknown[] + ) => unknown); + + // We need to check if the method was pre-preprogrammed to return + // something. If it was, then we make sure that this method we are + // currently defining returns that pre-programmed value. + if (methodToCall instanceof PreProgrammedMethod) { + if (methodToCall.will_throw) { + throw methodToCall.error; + } + return methodToCall.return; + } + + // When method calls its original self, let the `this` context of the + // original be the fake. Reason being the fake has tracking and the + // original does not. + const bound = methodToCall.bind(fake); + + // console.log(`calling bound()`); + + // Use `return` because the original function could return a value + return bound(...args); + }, + }); + } + + /** + * Get all properties from the original so they can be added to the fake. + * + * @param obj - The object that will be faked. + * + * @returns An array of the object's properties. + */ + #getAllPropertyNames(obj: ClassToFake): string[] { + let functions: string[] = []; + let clone = obj; + do { + functions = functions.concat(Object.getOwnPropertyNames(clone)); + } while ((clone = Object.getPrototypeOf(clone))); + + return functions.sort().filter( + function (e: string, i: number, arr: unknown[]) { + if ( + e != arr[i + 1] && typeof obj[e as keyof ClassToFake] != "function" + ) { + return true; + } + }, + ); + } + + /** + * Get all functions from the original so they can be added to the fake. + * + * @param obj - The object that will be faked. + * + * @returns An array of the object's functions. + */ + #getAllFunctionNames(obj: ClassToFake): string[] { + let functions: string[] = []; + let clone = obj; + do { + functions = functions.concat(Object.getOwnPropertyNames(clone)); + } while ((clone = Object.getPrototypeOf(clone))); + + return functions.sort().filter( + function (e: string, i: number, arr: unknown[]) { + if ( + e != arr[i + 1] && typeof obj[e as keyof ClassToFake] == "function" + ) { + return true; + } + }, + ); + } +} diff --git a/src/fake/fake_mixin.ts b/src/fake/fake_mixin.ts new file mode 100644 index 00000000..87cd4303 --- /dev/null +++ b/src/fake/fake_mixin.ts @@ -0,0 +1,66 @@ +import type { Constructor, MethodOf } from "../types.ts"; +import { PreProgrammedMethod } from "../pre_programmed_method.ts"; +import type { IFake } from "../interfaces.ts"; + +class FakeError extends Error {} + +export function createFake( + OriginalClass: OriginalConstructor, +): IFake { + const Original = OriginalClass as unknown as Constructor< + // deno-lint-ignore no-explicit-any + (...args: any[]) => any + >; + return new class FakeExtension extends Original { + /** + * Helper property to see that this is a fake object and not the original. + */ + is_fake = true; + + /** + * The original object that this class creates a fake of. + */ + #original!: OriginalObject; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param original - The original object to fake. + */ + public init(original: OriginalObject) { + this.#original = original; + } + + /** + * 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. + */ + public method( + methodName: MethodOf, + ): PreProgrammedMethod { + const methodConfiguration = new PreProgrammedMethod< + OriginalObject, + ReturnValueType + >( + methodName, + ); + + if (!((methodName as string) in this.#original)) { + throw new FakeError( + `Method "${methodName}" does not exist.`, + ); + } + + Object.defineProperty(this.#original, methodName, { + value: methodConfiguration, + writable: true, + }); + + return methodConfiguration; + } + }(); +} diff --git a/src/interfaces.ts b/src/interfaces.ts new file mode 100644 index 00000000..72374642 --- /dev/null +++ b/src/interfaces.ts @@ -0,0 +1,46 @@ +import type { MethodCalls, MethodOf } from "./types.ts"; + +export interface IMethodExpectation { + toBeCalled(expectedCalls: number): void; +} + +export interface IError { + name: string; + message?: string; +} + +export interface IPreProgrammedMethod { + willReturn(returnValue: ReturnValue): void; + willThrow(error: IError): void; +} + +export interface IFake { + is_fake: boolean; + + init( + original: OriginalObject, + methodsToTrack: string[], + ): void; + + method( + methodName: MethodOf, + ): IPreProgrammedMethod; +} + +export interface IMock { + calls: MethodCalls; + is_mock: boolean; + + init( + original: OriginalObject, + methodsToTrack: string[], + ): void; + + expects(method: MethodOf): IMethodExpectation; + + method( + methodName: MethodOf, + ): IPreProgrammedMethod; + + verifyExpectations(): void; +} diff --git a/src/mock/mock_builder.ts b/src/mock/mock_builder.ts new file mode 100644 index 00000000..e3aff42a --- /dev/null +++ b/src/mock/mock_builder.ts @@ -0,0 +1,288 @@ +import type { Constructor } from "../types.ts"; +import type { IMock } from "../interfaces.ts"; +import { createMock } from "./mock_mixin.ts"; +import { PreProgrammedMethod } from "../pre_programmed_method.ts"; + +/** + * Builder to help build a mock object. This does all of the heavy-lifting to + * create a mock object. Its `create()` method returns an instance of `Mock`, + * which is basically an original object with added data members for verifying + * behavior. + */ +export class MockBuilder { + /** + * The class object passed into the constructor + */ + #constructor_fn: Constructor; + + /** + * A list of arguments the class constructor takes + */ + #constructor_args: unknown[] = []; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Construct an object of this class. + * + * @param constructorFn - The constructor function of the object to mock. + */ + constructor(constructorFn: Constructor) { + this.#constructor_fn = constructorFn; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Create the mock object. + * + * @returns The original object with capabilities from the Mock class. + */ + public create(): ClassToMock & IMock { + const original = new this.#constructor_fn(...this.#constructor_args); + + const mock = createMock, ClassToMock>( + this.#constructor_fn, + ); + mock.init(original, this.#getAllFunctionNames(original)); + + // Attach all of the original's properties to the mock + this.#getAllPropertyNames(original).forEach((property: string) => { + this.#addOriginalObjectPropertyToMockObject( + original, + mock, + property, + ); + }); + + // Attach all of the original's functions to the mock + this.#getAllFunctionNames(original).forEach((method: string) => { + this.#addOriginalObjectMethodToMockObject( + original, + mock, + method, + ); + }); + + return mock as ClassToMock & IMock; + } + + /** + * Before constructing the mock object, track any constructor function args + * that need to be passed in when constructing the mock object. + * + * @param args - A rest parameter of arguments that will get passed in to the + * constructor function of the object being mocked. + * + * @returns `this` so that methods in this class can be chained. + */ + public withConstructorArgs(...args: unknown[]): this { + this.#constructor_args = args; + return this; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Add an original object's method to a mock object without doing anything + * else. + * + * @param original - The original object containing the method to mock. + * @param mock - The mock object receiving the method to mock. + * @param method - The name of the method to mock -- callable via + * `mock[method](...)`. + */ + #addMethodToMockObject( + original: ClassToMock, + mock: IMock, + method: string, + ): void { + Object.defineProperty(mock, method, { + value: original[method as keyof ClassToMock], + }); + } + + /** + * Add an original object's method to a mock object -- determining whether the + * method should or should not be trackable. + * + * @param original - The original object containing the method to add. + * @param mock - The mock object receiving the method. + * @param method - The name of the method to mock -- callable via + * `mock[method](...)`. + */ + #addOriginalObjectMethodToMockObject( + original: ClassToMock, + mock: IMock, + method: string, + ): void { + const nativeMethods = [ + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + ]; + + // If this is a native method, then do not do anything fancy. Just add it to + // the mock. + if (nativeMethods.includes(method as string)) { + return this.#addMethodToMockObject( + original, + mock, + method, + ); + } + + // Otherwise, make the method trackable via `.calls` usage. + this.#addTrackableMethodToMockObject( + original, + mock, + method as keyof ClassToMock, + ); + } + + /** + * Add an original object's property to a mock object. + * + * @param original The original object containing the property. + * @param mock The mock object receiving the property. + * @param property The name of the property -- retrievable via + * `mock[property]`. + */ + #addOriginalObjectPropertyToMockObject( + original: ClassToMock, + mock: IMock, + property: string, + ): void { + const desc = Object.getOwnPropertyDescriptor(original, property) ?? + Object.getOwnPropertyDescriptor( + this.#constructor_fn.prototype, + property, + ); + + // If we do not have a desc, then we have no idea what the value should be. + // Also, we have no idea what we are copying, so we should just not do it. + if (!desc) { + return; + } + + // Basic property (e.g., public test = "hello"). We do not handle get() and + // set() because those are handled by the mock mixin. + if (("value" in desc)) { + Object.defineProperty(mock, property, { + value: desc.value, + writable: true, + }); + } + } + + /** + * Add a trackable method to a mock object. A trackable method is one that can + * be verified using `mock.calls[someMethod]`. + * + * @param original - The original object containing the method to add. + * @param mock - The mock object receiving the method. + * @param method - The name of the method. + */ + #addTrackableMethodToMockObject( + original: ClassToMock, + mock: IMock, + method: keyof ClassToMock, + ): void { + Object.defineProperty(mock, method, { + value: (...args: unknown[]) => { + // Track that this method was called + mock.calls[method]++; + + // Make sure the method calls its original self + const methodToCall = + (original[method as keyof ClassToMock] as unknown as ( + ...params: unknown[] + ) => unknown); + + // We need to check if the method was pre-preprogrammed to return + // something. If it was, then we make sure that this method we are + // currently defining returns that pre-programmed value. + if (methodToCall instanceof PreProgrammedMethod) { + if (methodToCall.will_throw) { + throw methodToCall.error; + } + return methodToCall.return; + } + + // When method calls its original self, let the `this` context of the + // original be the mock. Reason being the mock has tracking and the + // original does not. + const bound = methodToCall.bind(mock); + + // console.log(`calling bound()`); + + // Use `return` because the original function could return a value + return bound(...args); + }, + }); + } + + /** + * Get all properties from the original so they can be added to the mock. + * + * @param obj - The object that will be mocked. + * + * @returns An array of the object's properties. + */ + #getAllPropertyNames(obj: ClassToMock): string[] { + let functions: string[] = []; + let clone = obj; + do { + functions = functions.concat(Object.getOwnPropertyNames(clone)); + } while ((clone = Object.getPrototypeOf(clone))); + + return functions.sort().filter( + function (e: string, i: number, arr: unknown[]) { + if ( + e != arr[i + 1] && typeof obj[e as keyof ClassToMock] != "function" + ) { + return true; + } + }, + ); + } + + /** + * Get all functions from the original so they can be added to the mock. + * + * @param obj - The object that will be mocked. + * + * @returns An array of the object's functions. + */ + #getAllFunctionNames(obj: ClassToMock): string[] { + let functions: string[] = []; + let clone = obj; + do { + functions = functions.concat(Object.getOwnPropertyNames(clone)); + } while ((clone = Object.getPrototypeOf(clone))); + + return functions.sort().filter( + function (e: string, i: number, arr: unknown[]) { + if ( + e != arr[i + 1] && typeof obj[e as keyof ClassToMock] == "function" + ) { + return true; + } + }, + ); + } +} diff --git a/src/mock/mock_mixin.ts b/src/mock/mock_mixin.ts new file mode 100644 index 00000000..8b642dd1 --- /dev/null +++ b/src/mock/mock_mixin.ts @@ -0,0 +1,160 @@ +import type { Constructor, MethodCalls, MethodOf } from "../types.ts"; +import { PreProgrammedMethod } from "../pre_programmed_method.ts"; +import type { IMock } from "../interfaces.ts"; + +class MockError extends Error {} + +class MethodExpectation { + #method_name: MethodOf; + #expected_calls = 0; + + get method_name(): MethodOf { + return this.#method_name; + } + + get expected_calls(): number { + return this.#expected_calls; + } + + constructor(methodName: MethodOf) { + this.#method_name = methodName; + } + + public toBeCalled(expectedCalls: number) { + this.#expected_calls = expectedCalls; + } +} + +export function createMock( + OriginalClass: OriginalConstructor, +): IMock { + const Original = OriginalClass as unknown as Constructor< + // deno-lint-ignore no-explicit-any + (...args: any[]) => any + >; + return new class MockExtension extends Original { + /** + * Helper property to see that this is a mock object and not the original. + */ + is_mock = true; + + /** + * Property to track method calls. + */ + #calls!: MethodCalls; + + /** + * An array of expectations to verify (if any). + */ + #expectations: MethodExpectation[] = []; + + /** + * The original object that this class creates a mock of. + */ + #original!: OriginalObject; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get calls(): MethodCalls { + return this.#calls; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param original - The original object to mock. + * @param methodsToTrack - The original object's method to make trackable. + */ + public init(original: OriginalObject, methodsToTrack: string[]) { + this.#original = original; + this.#calls = this.#constructCallsProperty(methodsToTrack); + } + + /** + * Create a method expectation, which is basically asserting calls. + * + * @param method - The method to create an expectation for. + * @returns A method expectation. + */ + public expects( + method: MethodOf, + ): MethodExpectation { + const expectation = new MethodExpectation(method); + this.#expectations.push(expectation); + return expectation; + } + + /** + * 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. + */ + public method( + methodName: MethodOf, + ): PreProgrammedMethod { + const methodConfiguration = new PreProgrammedMethod< + OriginalObject, + ReturnValueType + >( + methodName, + ); + + if (!((methodName as string) in this.#original)) { + throw new MockError( + `Method "${methodName}" does not exist.`, + ); + } + + Object.defineProperty(this.#original, methodName, { + value: methodConfiguration, + writable: true, + }); + + return methodConfiguration; + } + + /** + * Verify all expectations created in this mock. + */ + public verifyExpectations(): void { + this.#expectations.forEach((e: MethodExpectation) => { + const expectedCalls = e.expected_calls; + const actualCalls = this.#calls[e.method_name]; + if (expectedCalls !== actualCalls) { + throw new MockError( + `Method "${e.method_name}" expected ${expectedCalls} call(s), but received ${actualCalls} call(s).`, + ); + } + }); + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PRIVATE /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Construct the calls property. Only construct it, do not set it. The + * constructor will set it. + * @param methodsToTrack - All of the methods on the original object to make + * trackable. + * @returns - Key-value object where the key is the method name and the value + * is the number of calls. All calls start at 0. + */ + #constructCallsProperty( + methodsToTrack: string[], + ): Record { + const calls: Partial> = {}; + + methodsToTrack.map((key: string) => { + calls[key as keyof OriginalObject] = 0; + }); + + return calls as Record; + } + }(); +} diff --git a/src/mock_builder.ts b/src/mock_builder.ts deleted file mode 100644 index 174d8ac8..00000000 --- a/src/mock_builder.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Constructor, Mocked } from "./types.ts"; - -export class MockBuilder { - /** - * Properties of the class that is passed in - */ - protected properties: string[] = []; - - /** - * Functions of the class passed in - */ - protected functions: string[] = []; - - /** - * The class object passed into the constructor - */ - protected constructor_fn: Constructor; - - /** - * A list of arguments the class constructor takes - */ - protected constructor_args: unknown[] = []; - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Construct an object of this class. - * - * @param constructorFn - The object's constructor function to instantiate. - */ - constructor(constructorFn: Constructor) { - this.constructor_fn = constructorFn; - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PUBLIC //////////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Create the mock object. - * - * @returns A mocked object. - */ - public create(): Mocked { - // deno-lint-ignore no-explicit-any - const mock: Mocked = { - calls: {}, - is_mock: true, - }; - const original = new this.constructor_fn(...this.constructor_args); - - // Attach all of the original's properties to the mock - this.getAllProperties(original).forEach((property: string) => { - const desc = Object.getOwnPropertyDescriptor(original, property) ?? - Object.getOwnPropertyDescriptor( - this.constructor_fn.prototype, - property, - ); - mock[property] = desc!.value; - }); - - // Attach all of the original's functions to the mock - this.getAllFunctions(original).forEach((method: string) => { - const nativeMethods = [ - "__defineGetter__", - "__defineSetter__", - "__lookupGetter__", - "__lookupSetter__", - "constructor", - "hasOwnProperty", - "isPrototypeOf", - "propertyIsEnumerable", - "toLocaleString", - "toString", - "valueOf", - ]; - - if (nativeMethods.indexOf(method) == -1) { - if (!mock.calls[method]) { - mock.calls[method] = 0; - } - mock[method] = function (...args: unknown[]) { - // Add tracking - mock.calls[method]++; - - // Make sure the method calls its original self - const unbound = (original[method as keyof T] as unknown as ( - ...params: unknown[] - ) => unknown); - - // When method calls its original self, let `this` be the mock. Reason - // being the mock has tracking and the original does not. - const bound = unbound.bind(mock); - - return bound(...args); - }; - } else { - // copy nativeMethod directly without mocking - mock[method] = original[method as keyof T]; - } - }); - - return mock; - } - - /** - * Before constructing the mock object, track any constructur function args - * that need to be passed in when constructing the mock object. - * - * @param args - A rest parameter of arguments that will get passed in to - * the constructor function of the class being mocked. - * - * @returns `this` so that methods in this class can be chained. - */ - public withConstructorArgs(...args: unknown[]): this { - this.constructor_args = args; - return this; - } - - ////////////////////////////////////////////////////////////////////////////// - // FILE MARKER - METHODS - PROTECTED ///////////////////////////////////////// - ////////////////////////////////////////////////////////////////////////////// - - /** - * Get all properties--public, protected, private--from the object that will - * be mocked. - * - * @param obj - The object that will be mocked. - * - * @returns An array of the object's properties. - */ - protected getAllProperties(obj: T): string[] { - let functions: string[] = []; - let clone = obj; - do { - functions = functions.concat(Object.getOwnPropertyNames(clone)); - } while ((clone = Object.getPrototypeOf(clone))); - - return functions.sort().filter( - function (e: string, i: number, arr: unknown[]) { - if ( - e != arr[i + 1] && typeof obj[e as keyof T] != "function" - ) { - return true; - } - }, - ); - } - - /** - * Get all functions--public, protected, private--from the object that will be - * mocked. - * - * @param obj - The object that will be mocked. - * - * @returns An array of the object's functions. - */ - protected getAllFunctions(obj: T): string[] { - let functions: string[] = []; - let clone = obj; - do { - functions = functions.concat(Object.getOwnPropertyNames(clone)); - } while ((clone = Object.getPrototypeOf(clone))); - - return functions.sort().filter( - function (e: string, i: number, arr: unknown[]) { - if ( - e != arr[i + 1] && typeof obj[e as keyof T] == "function" - ) { - return true; - } - }, - ); - } -} diff --git a/src/pre_programmed_method.ts b/src/pre_programmed_method.ts new file mode 100644 index 00000000..9ae32f85 --- /dev/null +++ b/src/pre_programmed_method.ts @@ -0,0 +1,92 @@ +import type { MethodOf } from "./types.ts"; +import type { IError } from "./interfaces.ts"; + +class PreProgrammedMethodError extends Error {} + +/** + * Class that allows to be a "stand-in" for a method. For example, when used in + * a mock object, the mock object can replace methods with pre-programmed + * methods (using this class), and have a system under test use the + * pre-programmed methods. + */ +export class PreProgrammedMethod { + #method_name: MethodOf; + #will_throw = false; + #will_return = false; + #return?: ReturnValue; + #error?: IError; + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - CONSTRUCTOR ///////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * @param methodName - The name of the method to program. Must be a method of + * the original object in question. + */ + constructor(methodName: MethodOf) { + this.#method_name = methodName; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - GETTERS / SETTERS ////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + get error(): void { + if (!this.#will_throw) { + throw new PreProgrammedMethodError( + `Pre-programmed method "${this.#method_name}" is not set up to throw an error.`, + ); + } + if (this.#error === undefined) { + throw new PreProgrammedMethodError( + `Pre-programmed method "${this.#method_name}" is set up to throw an error, but no error was provided.`, + ); + } + + throw this.#error; + } + + get return(): ReturnValue { + if (this.#return === undefined) { + throw new PreProgrammedMethodError( + `Pre-programmed method "${this.#method_name}" does not have a return value.`, + ); + } + + return this.#return; + } + + get will_return(): boolean { + return this.#will_return; + } + + get will_throw(): boolean { + return this.#will_throw; + } + + ////////////////////////////////////////////////////////////////////////////// + // FILE MARKER - METHODS - PUBLIC /////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////// + + /** + * Pre-program this method to return the given value. + + * @param returnValue The value that should be returned when this object is + * being used in place of an original method. + */ + public willReturn(returnValue: ReturnValue): void { + this.#will_return = true; + this.#return = returnValue; + } + + /** + * Pre-program this method to throw the given error. + * + * @param error - The error to throw. + */ + public willThrow(error: IError): void { + this.#will_throw = true; + this.#error = error; + } +} diff --git a/src/types.ts b/src/types.ts index 4c6125a6..17a6c0bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,20 @@ -export type TConstructorFunction = { - new (...args: unknown[]): T; - [key: string]: unknown; -}; - // deno-lint-ignore no-explicit-any -export type Constructor = new (...args: any[]) => T; +export type Constructor = new (...args: any[]) => T; + +export type MethodCalls = Record; + +export type MethodOf = { + // deno-lint-ignore no-explicit-any + [K in keyof Object]: Object[K] extends (...args: any[]) => unknown ? K + : never; +}[keyof Object]; + +export type MemberOf = { + [K in keyof Object]: Object[K]; +}[keyof Object]; -export type Mocked = T & { - calls: { [k in keyof T]: T[k] extends () => void ? number : never }; - is_mock: true; -}; +export type MockedObject = { [k: string]: unknown }; -export type Stubbed = T & { - calls: { [k in keyof T]?: T[k] extends () => void ? number : never }; - stub: (p: string, v: unknown) => void; - is_stubbed: true; -}; +export type StubReturnValue = T extends (...args: unknown[]) => unknown + ? () => R + : string; diff --git a/tests/deps.ts b/tests/deps.ts index 906a5b56..28112501 100644 --- a/tests/deps.ts +++ b/tests/deps.ts @@ -1 +1,4 @@ -export { assertEquals } from "https://deno.land/std@0.116.0/testing/asserts.ts"; +export { + assertEquals, + assertThrows, +} from "https://deno.land/std@0.134.0/testing/asserts.ts"; diff --git a/tests/integration/mock_test.ts b/tests/integration/mock_test.ts deleted file mode 100644 index 3ca57de9..00000000 --- a/tests/integration/mock_test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Mock } from "../../mod.ts"; -import { assertEquals } from "../deps.ts"; - -class MathService { - public add( - num1: number, - num2: number, - useNestedAdd = false, - ): number { - if (useNestedAdd) { - return this.nestedAdd(num1, num2); - } - return num1 + num2; - } - - public nestedAdd(num1: number, num2: number): number { - return num1 + num2; - } -} - -class TestObject { - public name: string; - #age = 0; - protected math_service: MathService; - protected protected_property = "I AM PROTECTED PROPERTY."; - constructor(name: string, mathService: MathService) { - this.math_service = mathService; - this.name = name; - } - public sum( - num1: number, - num2: number, - useNestedAdd = false, - ): number { - const sum = this.math_service.add(num1, num2, useNestedAdd); - return sum; - } - protected protectedMethod() { - return "I AM A PROTECTED METHOD."; - } - - public get age() { - return this.#age; - } - - public set age(val: number) { - this.#age = val; - } -} - -class TestRequestHandler { - // deno-lint-ignore require-await - async handle(request: Request): Promise { - const method = request.method.toLowerCase(); - const contentType = request.headers.get("Content-Type"); - - if (method !== "post") { - return "Method is not post"; - } - - if (contentType !== "application/json") { - return "Content-Type is incorrect"; - } - - return "posted"; - } -} - -Deno.test("mock()", async (t) => { - await t.step({ - name: "can mock an object", - fn() { - const mock = Mock(TestObject) - .create(); - assertEquals(mock.constructor.name, "TestObject"); - assertEquals(mock.is_mock, true); - }, - }); - - await t.step("Can mock an object with constructor args", () => { - const mock = Mock(TestObject) - .withConstructorArgs("my server", new MathService()) - .create(); - assertEquals(mock.constructor.name, "TestObject"); - assertEquals(mock.is_mock, true); - assertEquals(mock.name, "my server"); - }); - - await t.step("can access protected property", () => { - const mock = Mock(TestObject) - .create(); - assertEquals( - (mock as unknown as { [key: string]: string }).protected_property, - "I AM PROTECTED PROPERTY.", - ); - }); - - await t.step("Can access protected method", () => { - const mock = Mock(TestObject) - .create(); - assertEquals( - (mock as unknown as { [key: string]: () => string }).protectedMethod(), - "I AM A PROTECTED METHOD.", - ); - }); - - await t.step("has mocked math service", () => { - const mockMathService = Mock(MathService) - .create(); - const mockTestObject = Mock(TestObject) - .withConstructorArgs("has mocked math service", mockMathService) - .create(); - assertEquals(mockMathService.calls.add, 0); - mockTestObject.sum(1, 1); - assertEquals(mockMathService.calls.add, 1); - }); - - await t.step("call count for outside nested function is increased", () => { - const mockMathService = Mock(MathService) - .create(); - const mockTestObject = Mock(TestObject) - .withConstructorArgs("has mocked math service", mockMathService) - .create(); - assertEquals(mockMathService.calls.add, 0); - assertEquals(mockMathService.calls.nestedAdd, 0); - mockTestObject.sum(1, 1, true); - assertEquals(mockMathService.calls.add, 1); - assertEquals(mockMathService.calls.nestedAdd, 1); - }); - - await t.step("can mock getters and setters", () => { - const mock = Mock(TestObject) - .create(); - mock.age = 999; - assertEquals(mock.age, 999); - }); - - await t.step("Native request mock", async () => { - const router = Mock(TestRequestHandler).create(); - - const reqPost = new Request("https://google.com", { - method: "post", - headers: { - "content-type": "application/json", - }, - }); - assertEquals(router.calls.handle, 0); - assertEquals(await router.handle(reqPost), "posted"); - assertEquals(router.calls.handle, 1); - - const reqPostNotJson = new Request("https://google.com", { - method: "post", - }); - assertEquals(router.calls.handle, 1); - assertEquals( - await router.handle(reqPostNotJson), - "Content-Type is incorrect", - ); - assertEquals(router.calls.handle, 2); - - const reqGet = new Request("https://google.com", { - method: "get", - }); - assertEquals(router.calls.handle, 2); - assertEquals( - await router.handle(reqGet), - "Method is not post", - ); - assertEquals(router.calls.handle, 3); - }); -}); diff --git a/tests/integration/stub_test.ts b/tests/integration/stub_test.ts deleted file mode 100644 index e19dff0b..00000000 --- a/tests/integration/stub_test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Stub } from "../../mod.ts"; -import { assertEquals } from "../deps.ts"; - -class Server { - public greeting = "hello"; - - public methodThatLogs() { - console.log("Server running."); - } - - public methodThatThrows() { - } -} - -Deno.test("stub()", async (t) => { - await t.step("Can stub a propert", () => { - const server = Stub(new Server()); - server.stub("greeting", "you got changed"); - assertEquals(server.greeting, "you got changed"); - }); - - await t.step("Can stub a method", () => { - const server = Stub(new Server()); - server.stub("methodThatLogs", () => { - return "don't run the console.log()"; - }); - assertEquals( - server.methodThatLogs(), - "don't run the console.log()", - ); - assertEquals( - server.is_stubbed, - true, - ); - }); -}); diff --git a/tests/unit/mock_builder_test.ts b/tests/unit/mock_builder_test.ts deleted file mode 100644 index 68fce244..00000000 --- a/tests/unit/mock_builder_test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { assertEquals } from "../deps.ts"; -import { MockBuilder } from "../../src/mock_builder.ts"; - -Deno.test({ - name: "Unit | MockBuilder | constructor() | returns a MockBuilder object", - fn(): void { - const mock = new MockBuilder(TestObjectOne); - assertEquals(mock.constructor.name, "MockBuilder"); - }, -}); - -Deno.test("create()", async (t) => { - await t.step({ - name: "create() | creates mock without constructor args", - fn(): void { - const mock = new MockBuilder(TestObjectTwo) - .create(); - assertEquals(mock.name, undefined); - }, - }); - - await t.step({ - name: "Unit | MockBuilder | create() | creates mock with constructor args", - fn(): void { - const mock = new MockBuilder(TestObjectTwo) - .withConstructorArgs("some name") - .create(); - assertEquals(mock.name, "some name"); - }, - }); -}); - -// TODO(crookse) Write this test when we can invoke non-public members. -// Deno.test({ -// name: "Unit | MockBuilder | getAllProperties() | gets all properties", -// fn(): void { -// }, -// }); - -// TODO(crookse) Write this test when we can invoke non-public members. -// Deno.test({ -// name: "Unit | MockBuilder | getAllFunctions() | gets all functions", -// fn(): void { -// }, -// }); - -// FILE MARKER - DATA ////////////////////////////////////////////////////////// - -class TestObjectOne { -} - -class TestObjectTwo { - public name: string; - constructor(name: string) { - this.name = name; - } -} diff --git a/tests/unit/mod/dummy_test.ts b/tests/unit/mod/dummy_test.ts new file mode 100644 index 00000000..eb5bc9b6 --- /dev/null +++ b/tests/unit/mod/dummy_test.ts @@ -0,0 +1,75 @@ +import { Dummy, Mock } from "../../../mod.ts"; +import { assertEquals } from "../../deps.ts"; + +Deno.test("Dummy()", async (t) => { + await t.step({ + name: "can fill parameter lists", + fn(): void { + const mockServiceOne = Mock(ServiceOne).create(); + const dummy3 = Dummy(ServiceThree); + + const resource = new Resource( + mockServiceOne, + Dummy(ServiceTwo), + dummy3, + ); + + resource.callServiceOne(); + assertEquals(mockServiceOne.calls.methodServiceOne, 1); + }, + }); + + await t.step({ + name: "can be made without specifying constructor args", + fn(): void { + const dummy = Dummy(Resource); + assertEquals(Object.getPrototypeOf(dummy), Resource); + }, + }); +}); + +class Resource { + #service_one: ServiceOne; + #service_two: ServiceTwo; + #service_three: ServiceThree; + + constructor( + serviceOne: ServiceOne, + serviceTwo: ServiceTwo, + serviceThree: ServiceThree, + ) { + this.#service_one = serviceOne; + this.#service_two = serviceTwo; + this.#service_three = serviceThree; + } + + public callServiceOne() { + this.#service_one.methodServiceOne(); + } + + public callServiceTwo() { + this.#service_two.methodServiceTwo(); + } + + public callServiceThree() { + this.#service_three.methodServiceThree(); + } +} + +class ServiceOne { + public methodServiceOne() { + return "Method from ServiceOne was called."; + } +} + +class ServiceTwo { + public methodServiceTwo() { + return "Method from ServiceTwo was called."; + } +} + +class ServiceThree { + public methodServiceThree() { + return "Method from ServiceThree was called."; + } +} diff --git a/tests/unit/mod/fake_test.ts b/tests/unit/mod/fake_test.ts new file mode 100644 index 00000000..713717d3 --- /dev/null +++ b/tests/unit/mod/fake_test.ts @@ -0,0 +1,374 @@ +import { Fake } from "../../../mod.ts"; +import { assertEquals, assertThrows } from "../../deps.ts"; + +Deno.test("Fake()", async (t) => { + await t.step({ + name: "creates fake builder", + fn(): void { + const fake = Fake(TestObjectOne); + assertEquals(fake.constructor.name, "FakeBuilder"); + }, + }); + + await t.step(".create()", async (t) => { + await t.step({ + name: "creates fake object", + fn(): void { + const fake = Fake(TestObjectTwo).create(); + assertEquals(fake.name, undefined); + assertEquals(fake.is_fake, true); + }, + }); + }); + + await t.step(".withConstructorArgs(...)", async (t) => { + await t.step({ + name: "can take 1 arg", + fn(): void { + const fake = Fake(TestObjectTwo) + .withConstructorArgs("some name") + .create(); + assertEquals(fake.name, "some name"); + assertEquals(fake.is_fake, true); + }, + }); + + await t.step({ + name: "can take more than 1 arg", + fn(): void { + const fake = Fake(TestObjectTwoMore) + .withConstructorArgs("some name", ["hello"]) + .create(); + assertEquals(fake.name, "some name"); + assertEquals(fake.array, ["hello"]); + assertEquals(fake.is_fake, true); + }, + }); + }); + + await t.step(".method(...)", async (t) => { + await t.step({ + name: "requires .willReturn(...) or .willThrow(...) to be chained", + fn(): void { + const fake = Fake(TestObjectThree).create(); + assertEquals(fake.is_fake, true); + + // Original returns "World" + assertEquals(fake.test(), "World"); + + // Don't fully pre-program the method. This should cause an error during assertions. + fake.method("test"); + + try { + fake.test(); + } catch (error) { + assertEquals( + error.message, + `Pre-programmed method "test" does not have a return value.`, + ); + } + }, + }); + + await t.step({ + name: + ".willReturn(...) does not call original method and returns given value", + fn(): void { + // Assert that a fake can make a class take a shortcut + const fakeServiceDoingShortcut = Fake(Repository).create(); + fakeServiceDoingShortcut.method("findAllUsers").willReturn("shortcut"); + const resourceWithShortcut = new Resource( + fakeServiceDoingShortcut, + ); + resourceWithShortcut.getUsers(); + assertEquals(fakeServiceDoingShortcut.anotha_one_called, false); + assertEquals(fakeServiceDoingShortcut.do_something_called, false); + assertEquals(fakeServiceDoingShortcut.do_something_else_called, false); + + // Assert that the fake service can call original implementations + const fakeServiceNotDoingShortcut = Fake(Repository).create(); + const resourceWithoutShortcut = new Resource( + fakeServiceNotDoingShortcut, + ); + resourceWithoutShortcut.getUsers(); + assertEquals(fakeServiceNotDoingShortcut.anotha_one_called, true); + assertEquals(fakeServiceNotDoingShortcut.do_something_called, true); + assertEquals( + fakeServiceNotDoingShortcut.do_something_else_called, + true, + ); + }, + }); + + await t.step({ + name: ".willReturn(...) can be performed more than once", + fn(): void { + // Assert that a fake can make a class take a shortcut + const fakeServiceDoingShortcut = Fake(Repository).create(); + fakeServiceDoingShortcut.method("findUserById").willReturn("shortcut"); + fakeServiceDoingShortcut.method("findUserById").willReturn("shortcut2"); + const resourceWithShortcut = new Resource( + fakeServiceDoingShortcut, + ); + resourceWithShortcut.getUser(1); + assertEquals(fakeServiceDoingShortcut.anotha_one_called, false); + assertEquals(fakeServiceDoingShortcut.do_something_called, false); + assertEquals(fakeServiceDoingShortcut.do_something_else_called, false); + + // Assert that the fake service can call original implementations + const fakeServiceNotDoingShortcut = Fake(Repository).create(); + const resourceWithoutShortcut = new Resource( + fakeServiceNotDoingShortcut, + ); + resourceWithoutShortcut.getUser(1); + assertEquals(fakeServiceNotDoingShortcut.anotha_one_called, true); + assertEquals(fakeServiceNotDoingShortcut.do_something_called, true); + assertEquals( + fakeServiceNotDoingShortcut.do_something_else_called, + true, + ); + }, + }); + + await t.step({ + name: ".willThrow(...) does not call original method and throws error", + fn(): void { + // Assert that a fake can make a class take a shortcut + const fakeServiceDoingShortcut = Fake(Repository).create(); + fakeServiceDoingShortcut.method("findAllUsers").willReturn("shortcut"); + const resourceWithShortcut = new Resource( + fakeServiceDoingShortcut, + ); + resourceWithShortcut.getUsers(); + assertEquals(fakeServiceDoingShortcut.anotha_one_called, false); + assertEquals(fakeServiceDoingShortcut.do_something_called, false); + assertEquals(fakeServiceDoingShortcut.do_something_else_called, false); + + // Assert that the fake service can call original implementations + const fakeServiceNotDoingShortcut = Fake(Repository).create(); + const resourceWithoutShortcut = new Resource( + fakeServiceNotDoingShortcut, + ); + resourceWithoutShortcut.getUsers(); + assertEquals(fakeServiceNotDoingShortcut.anotha_one_called, true); + assertEquals(fakeServiceNotDoingShortcut.do_something_called, true); + assertEquals( + fakeServiceNotDoingShortcut.do_something_else_called, + true, + ); + }, + }); + + await t.step({ + name: ".willReturn(fake) returns the fake object (basic)", + fn(): void { + const fake = Fake(TestObjectFourBuilder).create(); + assertEquals(fake.is_fake, true); + + fake + .method("someComplexMethod") + .willReturn(fake); + + assertEquals(fake.someComplexMethod(), fake); + }, + }); + + await t.step({ + name: ".willReturn(fake) returns the fake object (extra)", + fn(): void { + // Assert that the original implementation sets properties + const fake1 = Fake(TestObjectFourBuilder).create(); + assertEquals(fake1.is_fake, true); + fake1.someComplexMethod(); + assertEquals(fake1.something_one, "one"); + assertEquals(fake1.something_two, "two"); + + // Assert that the fake implementation will not set properties + const fake2 = Fake(TestObjectFourBuilder).create(); + assertEquals(fake2.is_fake, true); + fake2 + .method("someComplexMethod") + .willReturn(fake2); + + assertEquals(fake2.someComplexMethod(), fake2); + assertEquals(fake2.something_one, undefined); + assertEquals(fake2.something_two, undefined); + + // Assert that we can also use setters + const fake3 = Fake(TestObjectFourBuilder).create(); + assertEquals(fake3.is_fake, true); + fake3.someComplexMethod(); + assertEquals(fake3.something_one, "one"); + assertEquals(fake3.something_two, "two"); + fake3.something_one = "you got changed"; + assertEquals(fake3.something_one, "you got changed"); + }, + }); + + await t.step({ + name: ".willThrow() causes throwing RandomError (with constructor)", + fn(): void { + const fake = Fake(TestObjectThree).create(); + assertEquals(fake.is_fake, true); + + // Original returns "World" + assertEquals(fake.test(), "World"); + + // Make the original method throw RandomError + fake + .method("test") + .willThrow(new RandomError("Random error message.")); + + assertThrows( + () => fake.test(), + RandomError, + "Random error message.", + ); + }, + }); + + await t.step({ + name: ".willThrow() causes throwing RandomError2 (no constructor)", + fn(): void { + const fake = Fake(TestObjectThree).create(); + assertEquals(fake.is_fake, true); + + // Original returns "World" + assertEquals(fake.test(), "World"); + + // Make the original method throw RandomError + fake + .method("test") + .willThrow(new RandomError2()); + + assertThrows( + () => fake.test(), + RandomError2, + "Some message not by the constructor.", + ); + }, + }); + }); +}); + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - DATA ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +class TestObjectOne { +} + +class TestObjectTwo { + public name: string; + constructor(name: string) { + this.name = name; + } +} + +class TestObjectTwoMore { + public name: string; + public array: string[]; + constructor(name: string, array: string[]) { + this.name = name; + this.array = array; + } +} + +class TestObjectThree { + public hello(): void { + return; + } + + public test(): string { + this.hello(); + this.hello(); + return "World"; + } +} + +class Resource { + #repository: Repository; + + constructor( + serviceOne: Repository, + ) { + this.#repository = serviceOne; + } + + public getUsers() { + this.#repository.findAllUsers(); + } + + public getUser(id: number) { + this.#repository.findUserById(id); + } +} + +class TestObjectFourBuilder { + #something_one?: string; + #something_two?: string; + + get something_one(): string | undefined { + return this.#something_one; + } + + set something_one(value: string | undefined) { + this.#something_one = value; + } + + get something_two(): string | undefined { + return this.#something_two; + } + + someComplexMethod(): this { + this.#setSomethingOne(); + this.#setSomethingTwo(); + return this; + } + + #setSomethingOne(): void { + this.#something_one = "one"; + } + + #setSomethingTwo(): void { + this.#something_two = "two"; + } +} + +class Repository { + public anotha_one_called = false; + public do_something_called = false; + public do_something_else_called = false; + + public findAllUsers(): string { + this.#doSomething(); + this.#doSomethingElse(); + this.#anothaOne(); + return "Finding all users"; + } + + public findUserById(id: number): string { + this.#doSomething(); + this.#doSomethingElse(); + this.#anothaOne(); + return `Finding user by id #${id}`; + } + + #anothaOne() { + this.anotha_one_called = true; + } + + #doSomething() { + this.do_something_called = true; + } + + #doSomethingElse() { + this.do_something_else_called = true; + } +} + +class RandomError extends Error {} +class RandomError2 extends Error { + public name = "RandomError2Name"; + public message = "Some message not by the constructor."; +} diff --git a/tests/unit/mod/mock_test.ts b/tests/unit/mod/mock_test.ts new file mode 100644 index 00000000..973f9a73 --- /dev/null +++ b/tests/unit/mod/mock_test.ts @@ -0,0 +1,484 @@ +import { Mock } from "../../../mod.ts"; +import { assertEquals, assertThrows } from "../../deps.ts"; + +Deno.test("Mock()", async (t) => { + await t.step({ + name: "creates mock builder", + fn(): void { + const mock = Mock(TestObjectOne); + assertEquals(mock.constructor.name, "MockBuilder"); + }, + }); + + await t.step(".create()", async (t) => { + await t.step({ + name: "creates mock object", + fn(): void { + const mock = Mock(TestObjectTwo).create(); + assertEquals(mock.name, undefined); + assertEquals(mock.is_mock, true); + }, + }); + + await t.step("can access protected property", () => { + const mock = Mock(TestObjectLotsOfDataMembers) + .create(); + assertEquals( + (mock as unknown as { [key: string]: string }).protected_property, + "I AM PROTECTED PROPERTY.", + ); + }); + + await t.step("can access protected method", () => { + const mock = Mock(TestObjectLotsOfDataMembers) + .create(); + assertEquals( + (mock as unknown as { [key: string]: () => string }).protectedMethod(), + "I AM A PROTECTED METHOD.", + ); + }); + }); + + await t.step(".withConstructorArgs(...)", async (t) => { + await t.step({ + name: "can take 1 arg", + fn(): void { + const mock = Mock(TestObjectTwo) + .withConstructorArgs("some name") + .create(); + assertEquals(mock.name, "some name"); + assertEquals(mock.is_mock, true); + }, + }); + + await t.step({ + name: "can take more than 1 arg", + fn(): void { + const mock = Mock(TestObjectTwoMore) + .withConstructorArgs("some name", ["hello"]) + .create(); + assertEquals(mock.name, "some name"); + assertEquals(mock.array, ["hello"]); + assertEquals(mock.is_mock, true); + + const mockMathService = Mock(MathService) + .create(); + const mockTestObject = Mock(TestObjectLotsOfDataMembers) + .withConstructorArgs("has mocked math service", mockMathService) + .create(); + assertEquals(mockMathService.calls.add, 0); + mockTestObject.sum(1, 1); + assertEquals(mockMathService.calls.add, 1); + }, + }); + }); + + await t.step("method(...)", async (t) => { + await t.step({ + name: "requires .willReturn(...) or .willThrow(...) to be chained", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + // Original returns "World" + assertEquals(mock.test(), "World"); + + // Don't fully pre-program the method. This should cause an error during assertions. + mock.method("test"); + + try { + mock.test(); + } catch (error) { + assertEquals( + error.message, + `Pre-programmed method "test" does not have a return value.`, + ); + } + assertEquals(mock.calls.test, 2); + assertEquals(mock.calls.hello, 2); + }, + }); + + await t.step({ + name: + ".willReturn(...) does not call original method and returns given value", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + // Original returns "World" + assertEquals(mock.test(), "World"); + + // Change original to return "Hello" + mock.method("test").willReturn("Hello"); + + // Should output "Hello" and make the following calls + assertEquals(mock.test(), "Hello"); + assertEquals(mock.calls.test, 2); + assertEquals(mock.calls.hello, 2); + }, + }); + + await t.step({ + name: ".willReturn(...) can be performed more than once", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + // Original returns "World" + assertEquals(mock.test(), "World"); + + // Change original to return "Hello" + mock.method("test").willReturn("Hello"); + mock.method("test").willReturn("Hello!"); + + assertEquals(mock.test(), "Hello!"); + assertEquals(mock.calls.test, 2); + assertEquals(mock.calls.hello, 2); + }, + }); + + await t.step({ + name: ".willReturn(...) returns specified value", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + // Original returns "World" + assertEquals(mock.test(), "World"); + + // Don't fully pre-program the method. This should cause an error during + // assertions. + mock + .method("test") + .willReturn({ + name: "something", + }); + + assertEquals(mock.test(), { name: "something" }); + assertEquals(mock.calls.test, 2); + assertEquals(mock.calls.hello, 2); + }, + }); + + await t.step({ + name: ".willReturn(mock) returns the mock object (basic)", + fn(): void { + const mock = Mock(TestObjectFourBuilder).create(); + assertEquals(mock.is_mock, true); + + mock + .method("someComplexMethod") + .willReturn(mock); + + assertEquals(mock.someComplexMethod(), mock); + assertEquals(mock.calls.someComplexMethod, 1); + }, + }); + + await t.step({ + name: ".willReturn(mock) returns the mock object (extra)", + fn(): void { + // Assert that the original implementation sets properties + const mock1 = Mock(TestObjectFourBuilder).create(); + assertEquals(mock1.is_mock, true); + mock1.someComplexMethod(); + assertEquals(mock1.something_one, "one"); + assertEquals(mock1.something_two, "two"); + assertEquals(mock1.calls.someComplexMethod, 1); + + // Assert that the mock implementation will not set properties + const mock2 = Mock(TestObjectFourBuilder).create(); + assertEquals(mock2.is_mock, true); + mock2 + .method("someComplexMethod") + .willReturn(mock2); + + assertEquals(mock2.someComplexMethod(), mock2); + assertEquals(mock2.something_one, undefined); + assertEquals(mock2.something_two, undefined); + assertEquals(mock2.calls.someComplexMethod, 1); + + // Assert that we can also use setters + const mock3 = Mock(TestObjectFourBuilder).create(); + assertEquals(mock3.is_mock, true); + mock3.someComplexMethod(); + assertEquals(mock3.something_one, "one"); + assertEquals(mock3.something_two, "two"); + mock3.something_one = "you got changed"; + assertEquals(mock3.something_one, "you got changed"); + assertEquals(mock3.calls.someComplexMethod, 1); + }, + }); + + await t.step({ + name: ".willThrow() causes throwing RandomError (with constructor)", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + // Original returns "World" + assertEquals(mock.test(), "World"); + + // Make the original method throw RandomError + mock + .method("test") + .willThrow(new RandomError("Random error message.")); + + assertThrows( + () => mock.test(), + RandomError, + "Random error message.", + ); + assertEquals(mock.calls.test, 2); + }, + }); + + await t.step({ + name: ".willThrow() causes throwing RandomError2 (no constructor)", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + // Original returns "World" + assertEquals(mock.test(), "World"); + + // Make the original method throw RandomError + mock + .method("test") + .willThrow(new RandomError2()); + + assertThrows( + () => mock.test(), + RandomError2, + "Some message not by the constructor.", + ); + assertEquals(mock.calls.test, 2); + }, + }); + + await t.step({ + name: ".expects(...).toBeCalled(...)", + fn(): void { + const mock = Mock(TestObjectThree).create(); + assertEquals(mock.is_mock, true); + + mock.expects("hello").toBeCalled(2); + mock.test(); + mock.verifyExpectations(); + }, + }); + }); + + // TODO(crookse) Put the below tests into one of the groups above this line + + await t.step("call count for outside nested function is increased", () => { + const mockMathService = Mock(MathService) + .create(); + const mockTestObject = Mock(TestObjectLotsOfDataMembers) + .withConstructorArgs("has mocked math service", mockMathService) + .create(); + assertEquals(mockMathService.calls.add, 0); + assertEquals(mockMathService.calls.nestedAdd, 0); + mockTestObject.sum(1, 1, true); + assertEquals(mockMathService.calls.add, 1); + assertEquals(mockMathService.calls.nestedAdd, 1); + }); + + await t.step("can mock getters and setters", () => { + const mock = Mock(TestObjectLotsOfDataMembers) + .create(); + mock.age = 999; + assertEquals(mock.age, 999); + }); + + await t.step("native request mock", async () => { + const router = Mock(TestRequestHandler).create(); + + const reqPost = new Request("https://google.com", { + method: "post", + headers: { + "content-type": "application/json", + }, + }); + assertEquals(router.calls.handle, 0); + assertEquals(await router.handle(reqPost), "posted"); + assertEquals(router.calls.handle, 1); + + const reqPostNotJson = new Request("https://google.com", { + method: "post", + }); + assertEquals(router.calls.handle, 1); + assertEquals( + await router.handle(reqPostNotJson), + "Content-Type is incorrect", + ); + assertEquals(router.calls.handle, 2); + + const reqGet = new Request("https://google.com", { + method: "get", + }); + + assertEquals(router.calls.handle, 2); + assertEquals( + await router.handle(reqGet), + "Method is not post", + ); + assertEquals(router.calls.handle, 3); + }); + + await t.step("sets the default value for getters", () => { + class Game { + } + + class PlayersEngine { + private game = new Game(); + get Game() { + return this.game; + } + set Game(val: Game) { + this.game = val; + } + } + + const mock = Mock(PlayersEngine).create(); + assertEquals(mock.Game instanceof Game, true); + }); +}); + +//////////////////////////////////////////////////////////////////////////////// +// FILE MARKER - DATA ////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////// + +class TestObjectOne { +} + +class TestObjectTwo { + public name: string; + constructor(name: string) { + this.name = name; + } +} + +class TestObjectTwoMore { + public name: string; + public array: string[]; + constructor(name: string, array: string[]) { + this.name = name; + this.array = array; + } +} + +class TestObjectThree { + public hello(): void { + return; + } + + public test(): string { + this.hello(); + this.hello(); + return "World"; + } +} + +class TestObjectFourBuilder { + #something_one?: string; + #something_two?: string; + + get something_one(): string | undefined { + return this.#something_one; + } + + set something_one(value: string | undefined) { + this.#something_one = value; + } + + get something_two(): string | undefined { + return this.#something_two; + } + + someComplexMethod(): this { + this.#setSomethingOne(); + this.#setSomethingTwo(); + return this; + } + + #setSomethingOne(): void { + this.#something_one = "one"; + } + + #setSomethingTwo(): void { + this.#something_two = "two"; + } +} + +class RandomError extends Error {} +class RandomError2 extends Error { + public name = "RandomError2Name"; + public message = "Some message not by the constructor."; +} + +class MathService { + public add( + num1: number, + num2: number, + useNestedAdd = false, + ): number { + if (useNestedAdd) { + return this.nestedAdd(num1, num2); + } + return num1 + num2; + } + + public nestedAdd(num1: number, num2: number): number { + return num1 + num2; + } +} + +class TestObjectLotsOfDataMembers { + public name: string; + #age = 0; + protected math_service: MathService; + protected protected_property = "I AM PROTECTED PROPERTY."; + constructor(name: string, mathService: MathService) { + this.math_service = mathService; + this.name = name; + } + public sum( + num1: number, + num2: number, + useNestedAdd = false, + ): number { + const sum = this.math_service.add(num1, num2, useNestedAdd); + return sum; + } + protected protectedMethod() { + return "I AM A PROTECTED METHOD."; + } + + public get age() { + return this.#age; + } + + public set age(val: number) { + this.#age = val; + } +} + +class TestRequestHandler { + // deno-lint-ignore require-await + async handle(request: Request): Promise { + const method = request.method.toLowerCase(); + const contentType = request.headers.get("Content-Type"); + + if (method !== "post") { + return "Method is not post"; + } + + if (contentType !== "application/json") { + return "Content-Type is incorrect"; + } + + return "posted"; + } +} diff --git a/tests/unit/mod/stub_test.ts b/tests/unit/mod/stub_test.ts new file mode 100644 index 00000000..46107e3d --- /dev/null +++ b/tests/unit/mod/stub_test.ts @@ -0,0 +1,45 @@ +import { Stub } from "../../../mod.ts"; +import { assertEquals } from "../../deps.ts"; + +class Server { + public greeting = "hello"; + + public methodThatLogs() { + return "server is running!"; + } +} + +Deno.test("Stub()", async (t) => { + await t.step("can stub a class property", () => { + const server = new Server(); + assertEquals(server.greeting, "hello"); + Stub(server, "greeting", "you got changed"); + assertEquals(server.greeting, "you got changed"); + Stub(server, "greeting", null); + assertEquals(server.greeting, null); + Stub(server, "greeting", true); + assertEquals(server.greeting, true); + const obj = { test: "hello" }; + Stub(server, "greeting", obj); + assertEquals(server.greeting, obj); + }); + + await t.step("can stub a class method", () => { + const server = new Server(); + assertEquals(server.methodThatLogs(), "server is running!"); + Stub(server, "methodThatLogs"); + assertEquals(server.methodThatLogs(), "stubbed"); + Stub(server, "methodThatLogs", null); + assertEquals(server.methodThatLogs(), null); + Stub(server, "methodThatLogs", true); + assertEquals(server.methodThatLogs(), true); + const obj = { test: "hello" }; + Stub(server, "methodThatLogs", obj); + assertEquals(server.methodThatLogs(), obj); + }); + + await t.step("can return a stubbed function", () => { + const stub = Stub(); + assertEquals(stub(), "stubbed"); + }); +});