Skip to content

Instantly share code, notes, and snippets.

@MikeyBurkman
Last active April 30, 2020 16:36
Show Gist options
  • Save MikeyBurkman/4c029b90af9109084b3aea40ecbd9dfc to your computer and use it in GitHub Desktop.
Save MikeyBurkman/4c029b90af9109084b3aea40ecbd9dfc to your computer and use it in GitHub Desktop.
Typescript Mocking
import util from 'util';
/**
* Behaves like Partial<T>, except nested properties also become optional.
* @example
* ```ts
* type Foo = DeepPartial<{x: { y: number, z: number }>;
* const foo: Foo = {x: { y: 42 } }; // Note that z is now not required
* ```
*/
export type DeepPartial<T> = { [N in keyof T]?: DeepPartial<T[N]> };
/* eslint-disable @typescript-eslint/no-explicit-any */
export type IsPrimitive = (o: unknown) => boolean;
// Stack might have symbols in it, so map to string first
const stackToString = (stack: any[]) => stack.map(String).join('.');
const buildProxier = (isAlsoPrimitive: IsPrimitive) => {
const isPrimitive = (o: any) =>
o === null ||
o === undefined ||
typeof o === 'string' ||
typeof o === 'number' ||
typeof o === 'boolean' ||
o instanceof Date ||
isAlsoPrimitive(o) ||
util.types.isProxy(o);
// Builds a proxy where the getter for anything will look for an existing
// property on impls. If it doesn't exist, it'll throw an error.
// If the property is a non-primitive object, it'll mock that up too in the same way.
const buildProxy = (impls: any, stack: any[]): any => {
if (Array.isArray(impls)) {
return impls.map((n, idx) =>
isPrimitive(n) ? n : buildProxy(n, [...stack, `[${idx}]`])
);
}
return new Proxy(impls, {
get: (target: any, prop: any) => {
const newStack = [...stack, prop];
if (prop in target) {
const o = target[prop];
return isPrimitive(o) ? o : buildProxy(o, newStack);
}
if (prop === 'then') {
// Probably the case that Node is checking to see if this object is a promise.
return undefined;
}
const propName = stackToString(newStack);
throw new Error(
`Property "${propName}" was requested, but not provided in the mock`
);
},
set: (target, prop, value) => {
if (util.types.isProxy(target)) {
const propName = stackToString([...stack, prop]);
throw new Error(
`Property "${propName}" was mutated, but mutating a proxied mock is not allowed`
);
}
target[prop] = value;
return true;
}
});
};
return buildProxy;
};
/**
* Builds an immutable mock object that only returns values specified, and throws
* an error if other properties are accessed.
*
* Use this for specifying (in a type-safe way) only the properties that need to be
* used by your test.
*
* NOTE: Mutability may not work in all cases. YMMV.
*
* @param impls The actual implemented fields on the mock.
* @param name The name for this mock, useful for debugging if there are multiple mocks in a test
*
* @example
* ```ts
* interface Foo {
* x: string;
* y: number;
* z: {
* isImportant: boolean;
* }
* }
* const mockedFoo = buildMock<Foo>({ x: 'abc' });
* const x = mockedFoo.x; // 'abc';
* const y = mockedFoo.y; // Will throw an error at runtime because y was not mocked
* const isImportant = mockedFoo.z.isImportant; // Will also throw an error because it was not mocked
* ```
*/
export const buildMock = <T extends object>(
impls: DeepPartial<T>,
name?: string
): T => buildProxier(() => false)(impls, name ? [name] : []);
/**
* Factory function for creating a buildMock function. Use this if you need to specify certain types to NOT be
* proxied.
*
* All objects provided in `impls` will each be passed to the `isAlsoPrimitive` function, and if this function
* returns true, then that object is considered a primitive and won't be proxied further.
*
* This is useful if you have other objects that need to be considered like primities. For instance, if your
* mock object contains Luxon DateTime objects, then you'll want to return true if the object is `instanceof DateTime`.
* You will usually just need the buildMock() function exported by this module.
*
* @example
* ```ts
* // In this case, buildMock will not go in and try to proxy anything that is an instance of Foo
* const buildMock = buildMocker((o) => o instanceof Foo);
* const mockedQuux = buildMock<Quux>({ id: 5, something: fooInstance });
* ...
* const something = mockedQuux.something;
* ```
*/
export const buildMocker = (isAlsoPrimitive: IsPrimitive) => {
const proxier = buildProxier(isAlsoPrimitive);
return <T extends object>(impls: DeepPartial<T>, name?: string): T =>
proxier(impls, name ? [name] : []);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment