Created
July 20, 2023 20:20
-
-
Save dflor003/6cb35fc4fda5a3de100649097ef9df48 to your computer and use it in GitHub Desktop.
Jest auto-stub for classes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable jest/expect-expect */ | |
| import { stubOf, Stub } from './stub-of'; | |
| describe('stubOf', () => { | |
| describe('when stubbing classes', () => { | |
| class MyClass { | |
| field: string; | |
| foo() { | |
| return 'bar'; | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| bar(myArg: string): boolean { | |
| return true; | |
| } | |
| } | |
| it('should stub all class methods', () => { | |
| // Act | |
| const stub: Stub<MyClass> = stubOf(MyClass); | |
| stub.foo.mockReturnValue('baz'); | |
| stub.bar.mockReturnValue(false); | |
| // Assert | |
| expect(stub.foo()).toBe('baz'); | |
| expect(stub.bar('asd')).toBe(false); | |
| }); | |
| it('should not stub fields', () => { | |
| // Arrange | |
| const stub = stubOf(MyClass); | |
| // Act | |
| stub.field = 'asd'; | |
| // Assert | |
| // If this compiles, it works | |
| }); | |
| it('should still be assignable to the original class type', () => { | |
| // Arrange | |
| const stub: Stub<MyClass> = stubOf(MyClass); | |
| const doStuff = (val: MyClass) => val.foo(); | |
| // Act | |
| doStuff(stub); | |
| // Assert | |
| // If this compiles, it works | |
| }); | |
| }); | |
| it('should let you stub classes with non-prototype methods', () => { | |
| // Arrange | |
| class MyClass { | |
| foo: () => string; | |
| constructor() { | |
| this.foo = () => 'bar'; | |
| } | |
| } | |
| // Act | |
| const stub = stubOf(MyClass, 'foo'); | |
| stub.foo.mockReturnValue('baz'); | |
| // Assert | |
| expect(stub.foo()).toBe('baz'); | |
| }); | |
| it('should let you stub arbitrary objects', () => { | |
| // Arrange | |
| interface MyObj { | |
| foo(): string; | |
| } | |
| // Act | |
| const stub = stubOf<MyObj>({ | |
| // eslint-disable-next-line @typescript-eslint/no-empty-function | |
| foo() {}, | |
| }); | |
| stub.foo.mockReturnValue('baz'); | |
| // Assert | |
| expect(stub.foo()).toBe('baz'); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Mock = jest.Mock; | |
| const { getPrototypeOf } = Object; | |
| const getProtoProps = Object.getOwnPropertyNames || Object.keys; | |
| const isClass = (obj: any) => obj && obj.constructor === Function; | |
| export const stubFunc = jest.fn; | |
| /** | |
| * A generic type based on T such that it has these two properties: | |
| * 1. It contains all the same members as T except | |
| * their types are Jest mocks. | |
| * 2. It intersects with T such that it can be passed where T is expected. | |
| * 3. Any fields remain as fields (i.e. not mocks). | |
| */ | |
| export type Stub<T> = { | |
| [P in keyof T]: T[P] extends (...args: any[]) => any | |
| ? Mock<ReturnType<T[P]>, Parameters<T[P]>> | |
| : T[P]; | |
| } & T; | |
| /** | |
| * Makes the passed generic type mutable for testing purposes. This | |
| * essentially removes all readonly attributes from all members recursively | |
| */ | |
| export type Mutable<T> = { | |
| -readonly [P in keyof T]: T[P] extends ReadonlyArray<infer U> | |
| ? Mutable<U>[] // Make array types mutable recursively | |
| : Mutable<T[P]>; // Recursively make all non-primitive types mutable; | |
| }; | |
| /** | |
| * Represents any type that can be constructed via the new keyword. | |
| */ | |
| export type Type<T = any> = new (...args: any[]) => T; | |
| /** | |
| * Generates a stub for the given object or class. There are three distinct | |
| * ways of using this: | |
| * | |
| * For objects defined with a proper prototype chain, if you pass in an instance | |
| * of an ES6 class, it will generate a stub based off the class structure in its | |
| * prototype. | |
| * Here's an example of that: | |
| * | |
| * class MyClass { | |
| * method1() { | |
| * ... | |
| * } | |
| * | |
| * method2() { | |
| * ... | |
| * } | |
| * } | |
| * | |
| * describe('Some test', () => { | |
| * let myClass: Stub<MyClass>; | |
| * | |
| * beforeEach(() => { | |
| * myClass = stubOf(MyClass); | |
| * }); | |
| * }); | |
| * | |
| * For cases such as framework-level classes where it cannot detect the | |
| * properties | |
| * off of the prototype of the class, you can pass in a list of properties | |
| * to generate as one or more arguments after the class. Here's an example: | |
| * | |
| * let activatedRoute: Stub<ActivatedRoute>; | |
| * | |
| * beforeEach(() => { | |
| * activatedRoute = stubOf(ActivatedRoute, 'params', 'queryParams'); | |
| * }); | |
| * | |
| * As a last resort, you can pass a dummy object instance representing the | |
| * fields you need stubbed like so: | |
| * | |
| * const myStub = stubOf<SomeService>({ | |
| * method1() { }, | |
| * method2() { }, | |
| * method3() { }, | |
| * }); | |
| * | |
| * @param structure A class or object instance to stub. | |
| * @param keysToPick (Optional) When passing the class, you can pass just a | |
| * subset of properties to mock out. | |
| * @return A stub for that class where all fields are instances of {SinonStub}. | |
| */ | |
| export function stubOf<T, K extends keyof T>( | |
| structure: Type<T>, | |
| ...keysToPick: K[] | |
| ): Stub<T>; | |
| export function stubOf<T>( | |
| structure: Type<T> | Record<string, unknown> | string[], | |
| ): Stub<T>; | |
| export function stubOf<T>( | |
| structure: Type<T> | Record<string, unknown> | string[], | |
| ...keysToPick: string[] | |
| ): Stub<T> { | |
| // If you pass a class, build a stub based off the properties in its prototype | |
| if (isClass(structure)) { | |
| // Stub out all prototype properties | |
| const props: string[] = | |
| keysToPick && keysToPick.length | |
| ? Array.of(...keysToPick) | |
| : getAllProps(structure); | |
| return !props.length | |
| ? {} | |
| : (props.reduce( | |
| (obj, key) => ({ | |
| ...obj, | |
| // eslint-disable-next-line @typescript-eslint/no-empty-function | |
| [key]: jest.fn(), | |
| }), | |
| {}, | |
| ) as any); | |
| } | |
| // For array of properties, generate a jest stub for that | |
| if (Array.isArray(structure)) { | |
| return Object.fromEntries(structure.map(prop => [prop, jest.fn()])) as any; | |
| } | |
| // For a regular object instance, pass directly to stub | |
| // as its supported already | |
| return Object.keys(structure).reduce( | |
| (obj, key) => ({ | |
| ...obj, | |
| // eslint-disable-next-line @typescript-eslint/no-empty-function | |
| [key]: jest.fn(), | |
| }), | |
| {}, | |
| ) as any; | |
| } | |
| // Discover names of properties up the prototype chain | |
| function getAllProps(structure: any): string[] { | |
| if (!structure || structure === getPrototypeOf(Function)) { | |
| return []; | |
| } | |
| const methods = getProtoProps(structure.prototype) | |
| .filter(prop => prop !== 'constructor') | |
| .filter(prop => typeof structure.prototype[prop] === 'function'); | |
| return [...methods, ...getAllProps(getPrototypeOf(structure))]; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment