Skip to content

Instantly share code, notes, and snippets.

@dflor003
Created July 20, 2023 20:20
Show Gist options
  • Select an option

  • Save dflor003/6cb35fc4fda5a3de100649097ef9df48 to your computer and use it in GitHub Desktop.

Select an option

Save dflor003/6cb35fc4fda5a3de100649097ef9df48 to your computer and use it in GitHub Desktop.
Jest auto-stub for classes
/* 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');
});
});
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