├── .nvmrc ├── CHANGELOG.md ├── jest.config.js ├── .DS_Store ├── .husky └── pre-commit ├── .size-limit.js ├── .size.json ├── .travis.yml ├── .gitignore ├── src ├── index.ts ├── magic-symbols.ts ├── deep-partial.ts └── partial-mock.tsx ├── tsconfig.json ├── package.json ├── .eslintrc.js ├── README.md └── __tests__ └── index.tsx /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.1 (2023-03-16) 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theKashey/partial-mock/HEAD/.DS_Store -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.size-limit.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | "path": "dist/es2015/index.js", 4 | "limit": "5 KB" 5 | } 6 | ]; -------------------------------------------------------------------------------- /.size.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "dist/es2015/index.js", 4 | "passed": true, 5 | "size": 853, 6 | "sizeLimit": 5000 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | cache: yarn 5 | script: 6 | - yarn 7 | - yarn test:ci 8 | - codecov 9 | notifications: 10 | email: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log* 2 | yarn-debug.log* 3 | yarn-error.log* 4 | 5 | lib-cov 6 | coverage 7 | .nyc_output 8 | 9 | .docz 10 | dist 11 | node_modules/ 12 | 13 | .eslintcache -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type {DeepPartial, DeepPartialStub} from './deep-partial' 2 | export {partialMock,exactMock, getKeysUsedInMock, resetMockUsage, expectNoUnusedKeys} from './partial-mock' 3 | export {DOES_NOT_MATTER, DO_NOT_CALL, DO_NOT_USE} from './magic-symbols' -------------------------------------------------------------------------------- /src/magic-symbols.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A magic symbol allowing access to a field, yet keeping field undefined. 3 | * Matches any key but dont hold any value 4 | */ 5 | export const DOES_NOT_MATTER: any = Symbol('DOES_NOT_MATTER'); 6 | /** 7 | * A magic symbol marking field as non-accessible. 8 | * @throws on field access 9 | */ 10 | export const DO_NOT_USE: any = Symbol('DO_NOT_USE'); 11 | /** 12 | * A magic symbol marking field as non-callbable 13 | * @throws on field invocation 14 | */ 15 | export const DO_NOT_CALL: any = Symbol('DO_NOT_CALL'); 16 | 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "allowSyntheticDefaultImports": true, 5 | "strict": true, 6 | "strictNullChecks": true, 7 | "strictFunctionTypes": true, 8 | "noImplicitThis": true, 9 | "alwaysStrict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": true, 15 | "importHelpers": true, 16 | "isolatedModules": true, 17 | "target": "es6", 18 | "moduleResolution": "node", 19 | "lib": [ 20 | "dom", 21 | "es5", 22 | "scripthost", 23 | "es2015.collection", 24 | "es2015.symbol", 25 | "es2015.iterable", 26 | "es2015.promise" 27 | ], 28 | "types": [ 29 | "node", 30 | "jest" 31 | ], 32 | "typeRoots": [ 33 | "./node_modules/@types" 34 | ], 35 | "jsx": "react-jsx" 36 | } 37 | } -------------------------------------------------------------------------------- /src/deep-partial.ts: -------------------------------------------------------------------------------- 1 | type DeepPartialObject = { 2 | [Key in keyof Type]?: DeepPartial; 3 | }; 4 | 5 | 6 | // Adapted from type-fest's PartialDeep + react-magnetic-di 7 | /** 8 | * A deep partial on Array/Object/Tuple/Function/Promise 9 | */ 10 | export type DeepPartial = Type extends ReadonlyArray 11 | ? InferredArrayMember[] extends Type 12 | ? readonly InferredArrayMember[] extends Type 13 | ? ReadonlyArray> // readonly list 14 | : Array> // mutable list 15 | : DeepPartialObject // tuple 16 | : Type extends Set ? Set> 17 | : Type extends Map ? Map, DeepPartial> 18 | : Type extends (...args: infer FunctionalArgs) => infer ReturnType 19 | ? (...args: FunctionalArgs) => DeepPartial 20 | : Type extends Promise 21 | ? Promise> // promise 22 | : Type extends object 23 | ? DeepPartialObject // everything 24 | : Type | undefined; 25 | 26 | /** 27 | * A deep partial on a function(stub) return type 28 | */ 29 | export type DeepPartialStub any> = 30 | Type extends (...args: infer FunctionalArgs) => infer ReturnType ? (...args: FunctionalArgs) => DeepPartial : never; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "partial-mock", 3 | "description": "Comprehensive solution for partial mocking", 4 | "keywords": [ 5 | "mocking", 6 | "typescript", 7 | "spy", 8 | "partial" 9 | ], 10 | "version": "1.0.0", 11 | "main": "dist/es5/index.js", 12 | "author": "Anton Korzunov ", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@theuiteam/lib-builder": "^0.2.3", 16 | "@size-limit/preset-small-lib": "^8.1.2" 17 | }, 18 | "module": "dist/es2015/index.js", 19 | "module:es2019": "dist/es2019/index.js", 20 | "types": "dist/es5/index.d.ts", 21 | "engines": { 22 | "node": ">=10" 23 | }, 24 | "scripts": { 25 | "dev": "lib-builder dev", 26 | "test": "jest", 27 | "test:ci": "jest --runInBand --coverage", 28 | "build": "lib-builder build && yarn size:report", 29 | "release": "yarn build && yarn test", 30 | "size": "size-limit", 31 | "size:report": "size-limit --json > .size.json", 32 | "lint": "lib-builder lint", 33 | "format": "lib-builder format", 34 | "update": "lib-builder update", 35 | "prepack": "yarn build && yarn changelog", 36 | "prepare": "husky install", 37 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 38 | "changelog:rewrite": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0" 39 | }, 40 | "dependencies": { 41 | "tslib": "^2.1.0" 42 | }, 43 | "files": [ 44 | "dist" 45 | ], 46 | "repository": "https://github.com/theKashey/partial-mock", 47 | "lint-staged": { 48 | "*.{ts,tsx}": [ 49 | "prettier --write", 50 | "eslint --fix" 51 | ], 52 | "*.{js,css,json,md}": [ 53 | "prettier --write" 54 | ] 55 | }, 56 | "prettier": { 57 | "printWidth": 120, 58 | "trailingComma": "es5", 59 | "tabWidth": 2, 60 | "semi": true, 61 | "singleQuote": true 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:@typescript-eslint/recommended', 4 | 'plugin:import/typescript', 5 | 'plugin:react-hooks/recommended' 6 | ], 7 | parser: '@typescript-eslint/parser', 8 | plugins: ['@typescript-eslint', 'prettier', 'import'], 9 | rules: { 10 | '@typescript-eslint/ban-ts-comment': 0, 11 | '@typescript-eslint/ban-ts-ignore': 0, 12 | '@typescript-eslint/no-var-requires': 0, 13 | '@typescript-eslint/camelcase': 0, 14 | 'import/order': [ 15 | 'error', 16 | { 17 | 'newlines-between': 'always-and-inside-groups', 18 | alphabetize: { 19 | order: 'asc', 20 | }, 21 | groups: ['builtin', 'external', 'internal', ['parent', 'index', 'sibling']], 22 | }, 23 | ], 24 | "padding-line-between-statements": [ 25 | "error", 26 | // IMPORT 27 | { 28 | blankLine: "always", 29 | prev: "import", 30 | next: "*", 31 | }, 32 | { 33 | blankLine: "any", 34 | prev: "import", 35 | next: "import", 36 | }, 37 | // EXPORT 38 | { 39 | blankLine: "always", 40 | prev: "*", 41 | next: "export", 42 | }, 43 | { 44 | blankLine: "any", 45 | prev: "export", 46 | next: "export", 47 | }, 48 | { 49 | blankLine: "always", 50 | prev: "*", 51 | next: ["const", "let"], 52 | }, 53 | { 54 | blankLine: "any", 55 | prev: ["const", "let"], 56 | next: ["const", "let"], 57 | }, 58 | // BLOCKS 59 | { 60 | blankLine: "always", 61 | prev: ["block", "block-like", "class", "function", "multiline-expression"], 62 | next: "*", 63 | }, 64 | { 65 | blankLine: "always", 66 | prev: "*", 67 | next: ["block", "block-like", "class", "function", "return", "multiline-expression"], 68 | }, 69 | ], 70 | }, 71 | settings: { 72 | 'import/parsers': { 73 | '@typescript-eslint/parser': ['.ts', '.tsx'], 74 | }, 75 | 'import/resolver': { 76 | typescript: { 77 | alwaysTryTypes: true, 78 | }, 79 | }, 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

👯 partial-mock

3 | always watching your data 4 |
5 | 6 | 7 | 8 |
9 |
10 |
11 | 12 | 13 | 14 | 15 | Proxy-base, testing-framework-independent solution to solve _overmocking_, and _undermocking_. 16 | Never provide more information than you should, never provide less. 17 | 18 | - solves the `as` problem in TypeScript tests when an inappropriate object can be used as a mock 19 | - ensures provided mocks are suitable for the test case 20 | 21 | ```bash 22 | npm add --dev partial-mock 23 | ``` 24 | 25 | 📖 [Partial: how NOT to mock the whole world](https://dev.to/thekashey/partial-how-not-to-mock-the-whole-world-32pj) 26 | 27 | ## Problem statement 28 | ```tsx 29 | type User = { 30 | id: string; 31 | name: string; 32 | // Imagine oodles of other properties... 33 | }; 34 | 35 | const getUserId = (user:User) => user.id; 36 | 37 | it("Should return an id", () => { 38 | getUserId({ 39 | id: "123", // I really dont need anything more 40 | } as User /* 💩 */); 41 | }); 42 | 43 | // ------ 44 | 45 | // solution 1 - correct your CODE 46 | const getUserId = (user:Pick) => user.id; 47 | 48 | getUserId({ 49 | id: "123", // nothing else is required 50 | }); 51 | 52 | // solution 2 - correct your TEST 53 | it("Should return an id", () => { 54 | getUserId(partialMock({ 55 | id: "123", // it's ok to provide "partial data" now 56 | })); 57 | }); 58 | // Example was adopted from `mock-utils` 59 | ``` 60 | But what will happen with solution 2 in time, when internal implementation of getUserId change? 61 | ```tsx 62 | const getUserId = (user:User) => user.uid ? user.uid : user.id; 63 | ``` 64 | This is where `partial-mock` will save the day as it will break your test 65 | > 🤯Error: reading partial key .uid not defined in mock 66 | 67 | 68 | ```tsx 69 | import {partialMock} from 'partial-mock'; 70 | 71 | // complexFunction = () => ({ 72 | // complexPart: any, 73 | // simplePart: boolean, 74 | // rest: number 75 | // }); 76 | 77 | jest.mocked(complexFunction).mockReturnValue( 78 | partialMock({simpleResult: true, rest: 1}) 79 | ); 80 | 81 | // as usual 82 | complexFunction().simpleResult // ✅=== true 83 | 84 | // safety for undermocking 85 | complexFunction().complexPart // 🤯 run time exception - field is not defined 86 | 87 | import {partialMock, expectNoUnusedKeys} from 'partial-mock'; 88 | // safety for overmocking 89 | expectNoUnusedKeys(complexFunction()) // 🤯 run time exception - rest is not used 90 | ``` 91 | 92 | # API 93 | - `partialMock(mock) -> mockObject` - to create partial mock (TS + runtime) 94 | - `exactMock(mock) -> mockObject` - to create monitored mock (runtime) 95 | - `expectNoUnusedKeys(mockObject)` - to control overmocking 96 | - `getKeysUsedInMock(mockObject)`, `resetMockUsage(mockObject)` - to better understand usage 97 | - `DOES_NOT_MATTER`, `DO_NOT_USE`, `DO_NOT_CALL` - magic symbols, see below 98 | 99 | 100 | # Theory 101 | ## Definition of overmocking 102 | [Overtesting](https://portal.gitnation.org/contents/overtesting-why-it-happens-and-how-to-avoid-it) is a symptom of tests doing more than they should and thus brittle. 103 | A good example here is Snapshots capturing a lot of unrelated details, while you might want to focus on something particular. 104 | The makes tests more sensitive and brittle. 105 | 106 | `Overmocking` is the same - you might need to create and maintain complex mock, while in fact a little part of it is used. 107 | This makes tests more complicated and more expensive to write for no reason. 108 | 109 | > (Deep) Partial Mocking for the rescue! 🥳 110 | 111 | Example: 112 | ```tsx 113 | const complexFunction = () => ({ 114 | doA():ComplexObject, 115 | doB():ComplexObject, 116 | }); 117 | 118 | // direct mock 119 | const complexFunctionMock = () => ({ 120 | doA():ComplexObject, 121 | doB():ComplexObject, 122 | }); 123 | 124 | // partial mock 125 | const complexFunctionPartialMock = () => ({ 126 | doA():{ singleField: boolean }, 127 | }); 128 | ``` 129 | 130 | And there are many usecases when such mocks are more than helpful, until they cause `Undermocking` 131 | 132 | ## Definition of undermocking 133 | 134 | Right after doing over-specification, one can easily experience under-specification - too narrow mocks altering testing behavior without anyone noticing. 135 | 136 | > ⚠️ partial-mock will throw an exception if code is trying to access not provided functionality 137 | 138 | A little safety net securing correct behavior. 139 | 140 | If you dont want to provide any value - use can use `DOES_NOT_MATTER` magic symbol. 141 | 142 | ```tsx 143 | import {partialMock, DOES_NOT_MATTER} from "partial-mock"; 144 | 145 | const mock = partialMock({ 146 | x: 1, 147 | then: DOES_NOT_MATTER 148 | }); 149 | Promise.resolve(mock); 150 | // promise resolve will try to read mock.then per specification 151 | // but it "DOES_NOT_MATTER" 152 | ``` 153 | 154 | `DOES_NOT_MATTER` is one of magic symbols: 155 | - `DOES_NOT_MATTER` - defines a key without a value. It is just more _semantic_ than setting key to `undefined`. 156 | - `DO_NOT_USE` - throws on field access. Handy to create a "trap" and ensure expected behavior. Dont forget that partial-mock will throw in any case on undefined field access. 157 | - `DO_NOT_CALL` - throws on method invocation. Useful when you need to allow method access, but not usage. 158 | 159 | ## Non partial mocks 160 | Partial mocks are mostly TypeScript facing feature. The rest is a proxy-powered javascript runtime. 161 | And that runtime, especially with magic symbol defined above, can be useful for other cases. 162 | 163 | For situation like this use `exactMock` 164 | 165 | # Inspiration 166 | This library is a mix of ideas from: 167 | - [react-magnetic-di](https://github.com/albertogasparin/react-magnetic-di) - mocking solution with built-in partial support. Partial-mock is reimplementation of their approach for general mocking. 168 | - [mock-utils](https://github.com/mattpocock/mock-utils) - typescript solution for partial mocks. Partial-mocks implements the same idea but adds runtime logic for over and under mocking protection. 169 | - [rewiremock](https://github.com/theKashey/rewiremock) - dependnecy mocking solution with over/under mocking protection (isolation/reverse isolation) 170 | - [proxyequal](https://github.com/theKashey/proxyequal) - proxy based usage tracking 171 | 172 | # Licence 173 | MIT 174 | -------------------------------------------------------------------------------- /__tests__/index.tsx: -------------------------------------------------------------------------------- 1 | import { partialMock } from '../src'; 2 | import { 3 | DOES_NOT_MATTER, 4 | getKeysUsedInMock, 5 | resetMockUsage, 6 | expectNoUnusedKeys, 7 | DO_NOT_CALL, 8 | DO_NOT_USE, 9 | } from '../src'; 10 | 11 | describe('partial mocks', () => { 12 | it('simple one level values', () => { 13 | const x = { 14 | a: 1, 15 | b: 2, 16 | longField: 3, 17 | } as const; 18 | partialMock({ a: 1 }); 19 | partialMock({ b: 2 }); 20 | partialMock({ longField: 3 }); 21 | // @ts-expect-error 22 | partialMock({ a: 2 }); 23 | // @ts-expect-error 24 | partialMock({ notExisting: 2 }); 25 | 26 | expect(partialMock({ a: 1 }).a).toBe(1); 27 | expect(() => partialMock({ a: 1 }).b).toThrow(); 28 | }); 29 | 30 | it('allows random access', () => { 31 | const x = { 32 | a: 1, 33 | b: 'some', 34 | c: 'matters', 35 | } as const; 36 | expect(partialMock({ a: 1 }).a).toBe(1); 37 | expect(partialMock({ b: DOES_NOT_MATTER }).b).toBe(undefined); 38 | expect(() => partialMock({ a: 1 }).c).toThrow(); 39 | }); 40 | 41 | it('functional mocks', () => { 42 | const x = { 43 | onClick(_event: string) { 44 | return { a: 1, b: 2 }; 45 | }, 46 | } as const; 47 | partialMock({ onClick: () => ({}) }); 48 | // @ts-expect-error 49 | partialMock({ onClick: () => ({ c: 1 }) }); 50 | 51 | const exampleFn = partialMock({ onClick: () => ({ b: 2 }) }); 52 | expect(exampleFn.onClick('23').b).toBe(2); 53 | expect(() => exampleFn.onClick('23').a).toThrow(); 54 | }); 55 | 56 | it('promise mocks', async () => { 57 | const x = { 58 | a: Promise.resolve({ x: 1, y: 2 }), 59 | async fetch() { 60 | return { a: 1, b: 2 }; 61 | }, 62 | } as const; 63 | partialMock({ a: Promise.resolve({ x: 1 }) }); 64 | partialMock({ fetch: () => Promise.resolve({ a: 1 }) }); 65 | 66 | // @ts-expect-error 67 | partialMock({ a: Promise.resolve({ z: 1 }) }); 68 | 69 | const exampleFn = partialMock({ fetch: () => Promise.resolve({ a: 1 }) }); 70 | 71 | const payload = await exampleFn.fetch(); 72 | expect(payload.a).toBe(1); 73 | expect(() => payload.b).toThrow(); 74 | }); 75 | 76 | it('set mocks', async () => { 77 | const x = { 78 | a: new Set([{ x: 1, y: 1 }]), 79 | } as const; 80 | // @ts-expect-error 81 | partialMock({ a: new Set([{ z: 1 }]) }); 82 | 83 | const values = [...partialMock({ a: new Set([{ x: 1 }]) }).a.values()]; 84 | expect(values[0].x).toBe(1); 85 | expect(() => values[0].y).toThrow(); 86 | }); 87 | 88 | it('map mocks', async () => { 89 | const x = { 90 | a: new Map([['key', { x: 1, y: 1 }]]), 91 | } as const; 92 | // @ts-expect-error 93 | partialMock({ a: new Map([[1, { x: 1, y: 1 }]]) }); 94 | // @ts-expect-error 95 | partialMock({ a: new Map([['1', { z: 1 }]]) }); 96 | 97 | const value = partialMock({ a: new Map([['key', { x: 1 }]]) }).a.get('key'); 98 | expect(value?.x).toBe(1); 99 | expect(() => value?.y).toThrow(); 100 | }); 101 | 102 | it('htmlelement mocks', () => { 103 | const element = partialMock({ clientHeight: 50 }); 104 | expect(element.clientHeight).toBe(50); 105 | expect(() => element.clientWidth).toThrow(); 106 | }); 107 | 108 | it('tracking test', async () => { 109 | const x = { 110 | a: { b: { c: 1, d: 1 } }, 111 | s: new Set([{ x: 1, y: 1 }]), 112 | async fetch() { 113 | return { a: 1, b: 2 }; 114 | }, 115 | }; // as const; 116 | const mock = partialMock(x); 117 | expect(getKeysUsedInMock(mock)).toEqual([]); 118 | expect(() => (mock.a.b.c = 2)).toThrow(); 119 | expect(getKeysUsedInMock(mock)).toEqual(['a', 'a.b']); 120 | resetMockUsage(mock); 121 | 122 | const promise = mock.fetch(); 123 | expect(getKeysUsedInMock(mock)).toEqual(['fetch', 'fetch()']); 124 | resetMockUsage(mock); 125 | (await promise).b; 126 | expect(getKeysUsedInMock(mock)).toEqual(['fetch()', 'fetch().then', 'fetch().b']); 127 | }); 128 | 129 | it('usage test', async () => { 130 | const x = { 131 | a: { b: { c: 1, d: 1 } }, 132 | b: 1, 133 | } as const; 134 | const mock = partialMock(x); 135 | expect(mock.a.b.c).toBe(1); 136 | expect(() => expectNoUnusedKeys(mock)).toThrow(); 137 | expect(mock.a.b.d).toBe(1); 138 | expect(() => expectNoUnusedKeys(mock)).toThrow(); 139 | expect(mock.b).toBe(1); 140 | expect(() => expectNoUnusedKeys(mock)).not.toThrow(); 141 | }); 142 | 143 | it('array usage test', async () => { 144 | const x = { 145 | a: [undefined, 1], 146 | b: DOES_NOT_MATTER, 147 | } as const; 148 | const mock1 = partialMock(x); 149 | // side effect 150 | mock1.a[1]; 151 | expect(() => expectNoUnusedKeys(mock1)).not.toThrow(); 152 | 153 | const mock2 = partialMock(x); 154 | const [, _y] = mock2.a; 155 | expect(() => expectNoUnusedKeys(mock2)).not.toThrow(); 156 | }); 157 | 158 | it('usage limitation test', async () => { 159 | const x = { 160 | fn() { 161 | return { x: 1 }; 162 | }, 163 | }; // as const; 164 | const mock = partialMock(x); 165 | expect(() => expectNoUnusedKeys(mock)).toThrow(); 166 | mock.fn(); 167 | // tracking is not working beyond function calls 168 | expect(() => expectNoUnusedKeys(mock)).not.toThrow(); 169 | }); 170 | 171 | it('DO_NOT tests', () => { 172 | const x = { 173 | fn() { 174 | //empty 175 | }, 176 | x: 1, 177 | y: 2, 178 | z: Promise.resolve(null), 179 | w: false, 180 | } as const; 181 | const mock = partialMock({ 182 | fn: DO_NOT_CALL, 183 | x: DO_NOT_USE, 184 | y: 2, 185 | z: DOES_NOT_MATTER, 186 | }); 187 | // should pass 188 | expect(mock.fn).toBeTruthy(); 189 | expect(() => mock.fn()).toThrow(); 190 | expect(() => mock.x).toThrow(); 191 | expect(mock.y).toBe(2); 192 | expect(mock.z).toBe(undefined); 193 | expect(() => mock.w).toThrow(); 194 | }); 195 | 196 | it('does not span over native methods', () => { 197 | const mock = partialMock<[{ a: number }]>([{ a: 1 }]).map( 198 | (x) => 199 | ({ ...x, y: 2 } as { 200 | a: number; 201 | y: number; 202 | b?: number; 203 | }) 204 | ); 205 | // should pass 206 | expect(mock[0].a).toBe(1); 207 | // should pass 208 | expect(mock[0].b).toBe(undefined); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /src/partial-mock.tsx: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from './deep-partial'; 2 | import { DOES_NOT_MATTER, DO_NOT_USE, DO_NOT_CALL } from './magic-symbols'; 3 | 4 | export type NoInfer = [T][T extends any ? 0 : never]; 5 | 6 | const A_REAL_INSTANCE = Symbol(); 7 | 8 | const join = (a: string, b: string) => (a ? `${a}.${b}` : b); 9 | 10 | type RefsMap = WeakMap>; 11 | 12 | const getReal = (entity: any) => (entity && entity[A_REAL_INSTANCE]) ?? entity; 13 | 14 | const unproxify = (ref: any) => { 15 | const target = getReal(ref); 16 | 17 | const followProp = (value: any): any => { 18 | if ( 19 | typeof value === 'object' && 20 | // value.then && 21 | value instanceof Promise 22 | ) { 23 | return value.then((res) => followProp(res)); 24 | } 25 | 26 | if (Array.isArray(value) || (typeof value === 'object' && value) || typeof value === 'function') { 27 | return unproxify(value); 28 | } 29 | 30 | return getReal(value); 31 | }; 32 | 33 | return new Proxy(target, { 34 | get(_target, prop) { 35 | if (prop === A_REAL_INSTANCE) { 36 | return target; 37 | } 38 | 39 | const value = target[prop]; 40 | 41 | if (typeof prop !== 'string') { 42 | return value; 43 | } 44 | 45 | if (value === DOES_NOT_MATTER) { 46 | return undefined; 47 | } 48 | 49 | return followProp(value); 50 | }, 51 | }); 52 | }; 53 | 54 | const proxify = (refs: RefsMap, target: any, extras: any, suffix: string, report: (name: string) => void) => { 55 | const ref = refs.get(target) || {}; 56 | 57 | if (ref[suffix]) { 58 | return ref[suffix]; 59 | } 60 | 61 | const followProp = (value: any, name: string, extras = value): any => { 62 | report(name); 63 | 64 | if ( 65 | typeof value === 'object' && 66 | // value.then && 67 | value instanceof Promise 68 | ) { 69 | return value.then((res) => followProp(res, name, { then: DOES_NOT_MATTER })); 70 | } 71 | 72 | if (Array.isArray(value) || typeof value === 'object') { 73 | return proxify(refs, value, extras, name, report); 74 | } 75 | 76 | if ( 77 | typeof value === 'function' && 78 | // skip build in methods like `.map` 79 | !Object.getPrototypeOf(target).hasOwnProperty(name) 80 | ) { 81 | return proxify(refs, value, extras, name, report); 82 | } 83 | 84 | return value; 85 | }; 86 | 87 | ref[suffix] = new Proxy(target, { 88 | set(name) { 89 | throw new Error(`attempt to set ${join(suffix, name)} to a read only mock`); 90 | }, 91 | apply(fn, thisArg, argumentsList) { 92 | if (fn === DO_NOT_CALL) { 93 | throw new Error(`key ${suffix} was configured as DO_NOT_CALL`); 94 | } 95 | 96 | const realThis = getReal(thisArg); 97 | 98 | return followProp(Reflect.apply(fn, realThis, argumentsList), `${suffix}()`); 99 | }, 100 | get(_target, prop) { 101 | if (prop === A_REAL_INSTANCE) { 102 | return target; 103 | } 104 | 105 | const value = target[prop]; 106 | 107 | if (typeof prop !== 'string') { 108 | return value; 109 | } 110 | 111 | if (value === DOES_NOT_MATTER) { 112 | return undefined; 113 | } 114 | 115 | if (value === DO_NOT_USE) { 116 | throw new Error(`key ${join(suffix, prop)} was configured as DO_NOT_USE`); 117 | } 118 | 119 | if (!(prop in target) && !(prop in extras)) { 120 | throw new Error(`reading partial key ${join(suffix, prop)} not defined in mock`); 121 | } 122 | 123 | return followProp(value, join(suffix, prop)); 124 | }, 125 | }); 126 | 127 | return ref[suffix]; 128 | }; 129 | 130 | const mockRegistry = new WeakMap< 131 | any, 132 | { 133 | getUsage(): Set; 134 | resetUsage(): void; 135 | getReal(): any; 136 | } 137 | >(); 138 | 139 | /** 140 | * @returns keyed location used in mock 141 | */ 142 | export const getKeysUsedInMock = (mock: any): string[] => { 143 | const record = mockRegistry.get(mock); 144 | 145 | if (!record) { 146 | throw new Error('trying get usage for non mock'); 147 | } 148 | 149 | return Array.from(record.getUsage()); 150 | }; 151 | 152 | /** 153 | * resets usage tracking 154 | */ 155 | export const resetMockUsage = (mock: any) => { 156 | const record = mockRegistry.get(mock); 157 | 158 | if (!record) { 159 | throw new Error('trying reset usage for non mock'); 160 | } 161 | 162 | return record.resetUsage(); 163 | }; 164 | 165 | const getDeepKeys = (obj: any): string[] => { 166 | const keys: string[] = []; 167 | 168 | for (const key in obj) { 169 | const value = obj[key]; 170 | 171 | if (value !== undefined && value !== DOES_NOT_MATTER) { 172 | keys.push(key); 173 | } 174 | 175 | if (typeof obj[key] === 'object') { 176 | keys.push(...getDeepKeys(obj[key]).map((subkey) => `${key}.${subkey}`)); 177 | } 178 | } 179 | 180 | return keys; 181 | }; 182 | 183 | /** 184 | * Expects a "Minimum Viable Mock" with all provided values to be read 185 | * @throws in case of some information being used 186 | * @see {@link resetMockUsage} and {@link getKeysUsedInMock} 187 | * 188 | * @param mock - a mock created by {@link partialMock} 189 | */ 190 | export const expectNoUnusedKeys = (mock: any) => { 191 | const record = mockRegistry.get(mock); 192 | 193 | if (!record) { 194 | throw new Error('trying get usage for non mock'); 195 | } 196 | 197 | const usage = record.getUsage(); 198 | const knownKeys = getDeepKeys(record.getReal()); 199 | 200 | const unusedKeys = knownKeys.filter((key) => !usage.has(key)); 201 | 202 | if (unusedKeys.length) { 203 | console.error('unused keys:', unusedKeys); 204 | throw new Error('You have defined a larger object than you use. Unused keys ' + unusedKeys.join(', ')); 205 | } 206 | }; 207 | 208 | /** 209 | * Creates a deep partial mock for a given type with build in _under-mocking_ tracking 210 | * 211 | * @see combine with {@link expectNoUnusedKeys} for _over-mocking_ tracking 212 | * 213 | * @remarks magic symbols for access control: 214 | * - {@link DOES_NOT_MATTER} - allows access to a field, but holds no value 215 | * - {@link DO_NOT_USE} - prevents access to a field 216 | * - {@link DO_NOT_CALL} - prevents call of a method 217 | * 218 | * @param mock 219 | * @returns Proxy over original object 220 | */ 221 | export const partialMock = (input: DeepPartial>): T => { 222 | const usageList = new Set(); 223 | const mock = proxify(new WeakMap(), input, input, '', (name) => usageList.add(name)); 224 | 225 | mockRegistry.set(mock, { 226 | getReal: () => input, 227 | getUsage: () => usageList, 228 | resetUsage: () => { 229 | usageList.clear(); 230 | }, 231 | }); 232 | 233 | return mock; 234 | }; 235 | 236 | /** 237 | * creates exact mock with usage tracking and magic symbols support 238 | * @see {@link partialMock} for details 239 | */ 240 | export const exactMock = (input: NoInfer): T => partialMock(input as any); 241 | --------------------------------------------------------------------------------