Last active
April 30, 2020 16:36
-
-
Save MikeyBurkman/4c029b90af9109084b3aea40ecbd9dfc to your computer and use it in GitHub Desktop.
Typescript Mocking
This file contains 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 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