UNPKG

strong-mock

Version:

Type safe mocking library for TypeScript

1,338 lines (1,287 loc) 39.3 kB
var jestMatcherUtils = require('jest-matcher-utils'); var jestDiff = require('jest-diff'); var lodash = require('lodash'); /** * Special symbol denoting the call of a function. */ const ApplyProp = Symbol('apply'); const MATCHER_SYMBOL = Symbol('matcher'); /** * Used to test if an expectation is an argument is a custom matcher. */ function isMatcher(f) { return !!(f && f[MATCHER_SYMBOL]); } const getMatcherDiffs = (matchers, args) => { const matcherDiffs = matchers.map((matcher, i) => matcher.getDiff(args[i])); const actual = matcherDiffs.map(d => d.actual); const expected = matcherDiffs.map(d => d.expected); return { actual, expected }; }; /** * Create a custom matcher. * * @param predicate Will receive the actual value and return whether it matches the expectation. * @param options * @param options.toString An optional function that should return a string that will be * used when the matcher needs to be printed in an error message. By default, * it stringifies `predicate`. * @param options.getDiff An optional function that will be called when printing the * diff for a failed expectation. It will only be called if there's a mismatch * between the expected and received values i.e. `predicate(actual)` fails. * By default, the `toString` method will be used to format the expected value, * while the received value will be returned as-is. * * @example * // Create a matcher for positive numbers. * const fn = mock<(x: number) => number>(); * when(() => fn(It.matches(x => x >= 0))).thenReturn(42); * * fn(2) === 42 * fn(-1) // throws */ const matches = (predicate, options) => { var _options$toString, _options$getDiff; // We can't use destructuring with default values because `options` is optional, // so it needs a default value of `{}`, which will come with a native `toString`. const toString = (_options$toString = options == null ? void 0 : options.toString) != null ? _options$toString : () => `Matcher(${predicate.toString()})`; const getDiff = (_options$getDiff = options == null ? void 0 : options.getDiff) != null ? _options$getDiff : actual => ({ actual, expected: toString() }); const matcher = { [MATCHER_SYMBOL]: true, matches: actual => predicate(actual), toString, getDiff: actual => { if (predicate(actual)) { return { actual, expected: actual }; } return getDiff(actual); } }; return matcher; }; const printProperty = property => { if (property === ApplyProp) { return ''; } if (typeof property === 'symbol') { return `[${property.toString()}]`; } return `.${property}`; }; const printValue = arg => { // Call toString on matchers directly to avoid wrapping strings returned by them in quotes. if (isMatcher(arg)) { return arg.toString(); } return jestMatcherUtils.stringify(arg); }; const printArgs = args => args.map(arg => printValue(arg)).join(', '); const printCall = (property, args) => { const prettyProperty = printProperty(property); if (args) { const prettyArgs = printArgs(args); return `mock${jestMatcherUtils.RECEIVED_COLOR(`${prettyProperty}(${prettyArgs})`)}`; } return `mock${jestMatcherUtils.RECEIVED_COLOR(`${prettyProperty}`)}`; }; const printReturns = ({ isError, isPromise, value }, min, max) => { let thenPrefix = ''; if (isPromise) { if (isError) { thenPrefix += 'thenReject'; } else { thenPrefix += 'thenResolve'; } } else if (isError) { thenPrefix += 'thenThrow'; } else { thenPrefix += 'thenReturn'; } return `.${thenPrefix}(${jestMatcherUtils.RECEIVED_COLOR(printValue(value))}).between(${min}, ${max})`; }; const printWhen = (property, args) => { const prettyProperty = printProperty(property); if (args) { return `when(() => mock${jestMatcherUtils.EXPECTED_COLOR(`${prettyProperty}(${printArgs(args)})`)})`; } return `when(() => mock${jestMatcherUtils.EXPECTED_COLOR(`${printProperty(property)}`)})`; }; const printExpectation = (property, args, returnValue, min, max) => `${printWhen(property, args)}${printReturns(returnValue, min, max)}`; const printRemainingExpectations = expectations => expectations.length ? `Remaining unmet expectations: - ${expectations.map(e => e.toString()).join('\n - ')}` : 'There are no remaining unmet expectations.'; class UnexpectedAccess extends Error { constructor(property, expectations) { super(jestMatcherUtils.DIM_COLOR(`Didn't expect ${printCall(property)} to be accessed. If you expect this property to be accessed then please set an expectation for it. ${printRemainingExpectations(expectations)}`)); } } const noColor = s => s; const printArgsDiff = (expected, actual) => { const diff = jestDiff.diff(expected, actual, { omitAnnotationLines: true, aColor: noColor, bColor: noColor, changeColor: noColor, commonColor: noColor, patchColor: noColor }); /* istanbul ignore next this is not expected in practice */ if (!diff) { return ''; } const diffLines = diff.split('\n'); let relevantDiffLines; // Strip Array [ ... ] surroundings. if (!expected.length) { // - Array [] // + Array [ // ... // ] relevantDiffLines = diffLines.slice(2, -1); } else if (!actual.length) { // - Array [ // ... // ] // + Array [] relevantDiffLines = diffLines.slice(1, -2); } else { // Array [ // ... // ] relevantDiffLines = diffLines.slice(1, -1); } // Strip the trailing comma. const lastLine = relevantDiffLines[relevantDiffLines.length - 1].slice(0, -1); const coloredDiffLines = [...relevantDiffLines.slice(0, -1), lastLine].map(line => { const first = line.charAt(0); switch (first) { case '-': return jestMatcherUtils.EXPECTED_COLOR(line); case '+': return jestMatcherUtils.RECEIVED_COLOR(line); default: return line; } }); return coloredDiffLines.join('\n'); }; const printExpectationDiff = (e, args) => { var _e$args; if (!((_e$args = e.args) != null && _e$args.length)) { return ''; } const { actual, expected } = getMatcherDiffs(e.args, args); return printArgsDiff(expected, actual); }; const printDiffForAllExpectations = (expectations, actual) => expectations.map(e => { const diff = printExpectationDiff(e, actual); if (diff) { return `${e.toString()} ${jestMatcherUtils.EXPECTED_COLOR('- Expected')} ${jestMatcherUtils.RECEIVED_COLOR('+ Received')} ${diff}`; } return undefined; }).filter(x => x).join('\n\n'); class UnexpectedCall extends Error { constructor(property, args, expectations) { const header = `Didn't expect ${printCall(property, args)} to be called.`; const propertyExpectations = expectations.filter(e => e.property === property); if (propertyExpectations.length) { var _propertyExpectations; super(jestMatcherUtils.DIM_COLOR(`${header} Remaining expectations: ${printDiffForAllExpectations(propertyExpectations, args)}`)); // If we have a single expectation we can attach the actual/expected args // to the error instance, so that an IDE may show its own diff for them. this.matcherResult = void 0; if (propertyExpectations.length === 1 && (_propertyExpectations = propertyExpectations[0].args) != null && _propertyExpectations.length) { const { actual, expected } = getMatcherDiffs(propertyExpectations[0].args, args); this.matcherResult = { actual, expected }; } } else { super(jestMatcherUtils.DIM_COLOR(`${header} No remaining expectations.`)); this.matcherResult = void 0; } } } exports.UnexpectedProperty = void 0; (function (UnexpectedProperty) { /** * Throw an error immediately. * * @example * // Will throw "Didn't expect foo to be accessed". * const { foo } = service; * * // Will throw "Didn't expect foo to be accessed", * // without printing the arguments. * foo(42); */ UnexpectedProperty[UnexpectedProperty["THROW"] = 0] = "THROW"; /** * Return a function that will throw if called. This can be useful if your * code destructures a function but never calls it. * * It will also improve error messages for unexpected calls because arguments * will be captured instead of throwing immediately on the property access. * * The function will be returned even if the property is not supposed to be a * function. This could cause weird behavior at runtime, when your code expects * e.g. a number and gets a function instead. * * @example * // This will NOT throw. * const { foo } = service; * * // This will NOT throw, and might produce unexpected results. * foo > 0 * * // Will throw "Didn't expect foo(42) to be called". * foo(42); */ UnexpectedProperty[UnexpectedProperty["CALL_THROW"] = 1] = "CALL_THROW"; })(exports.UnexpectedProperty || (exports.UnexpectedProperty = {})); /** * Unbox the expectation's return value. * * If the value is an error then throw it. * * If the value is a promise then resolve/reject it. */ const unboxReturnValue = ({ isError, isPromise, value }) => { if (isError) { if (value instanceof Error) { if (isPromise) { return Promise.reject(value); } throw value; } if (isPromise) { return Promise.reject(new Error(value)); } throw new Error(value); } if (isPromise) { return Promise.resolve(value); } return value; }; /** * An expectation repository with a configurable behavior for * unexpected property access. */ class FlexibleRepository { constructor(unexpectedProperty = exports.UnexpectedProperty.THROW) { this.unexpectedProperty = void 0; this.expectations = new Map(); this.expectedCallStats = new Map(); this.unexpectedCallStats = new Map(); this.apply = args => this.get(ApplyProp)(...args); this.handlePropertyWithMatchingExpectations = (property, expectations) => { // Avoid recording call stats for function calls, since the property is an // internal detail. if (property !== ApplyProp) { // An unexpected call could still happen later, if the property returns a // function that will not match the given args. this.recordExpected(property, undefined); } const propertyExpectation = expectations.find(e => e.expectation.matches(undefined)); if (propertyExpectation) { this.countAndConsume(propertyExpectation); return unboxReturnValue(propertyExpectation.expectation.returnValue); } return (...args) => { const callExpectation = expectations.find(e => e.expectation.matches(args)); if (callExpectation) { this.recordExpected(property, args); this.countAndConsume(callExpectation); return unboxReturnValue(callExpectation.expectation.returnValue); } return this.getValueForUnexpectedCall(property, args); }; }; this.handlePropertyWithNoExpectations = property => { switch (property) { case 'toString': return () => 'mock'; case '@@toStringTag': case Symbol.toStringTag: case 'name': return 'mock'; // Promise.resolve() tries to see if it's a "thenable". // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables case 'then': return undefined; // pretty-format case '$$typeof': case 'constructor': case '@@__IMMUTABLE_ITERABLE__@@': case '@@__IMMUTABLE_RECORD__@@': return null; case MATCHER_SYMBOL: return false; case ApplyProp: return (...args) => this.getValueForUnexpectedCall(property, args); default: return this.getValueForUnexpectedAccess(property); } }; this.unexpectedProperty = unexpectedProperty; } add(expectation) { const { property } = expectation; const expectations = this.expectations.get(property) || []; this.expectations.set(property, [...expectations, { expectation, matchCount: 0 }]); } clear() { this.expectations.clear(); this.expectedCallStats.clear(); this.unexpectedCallStats.clear(); } // TODO: this returns any, but the interface returns unknown // unknown causes errors in apply tests, and any causes bugs in bootstrapped SM get(property) { const expectations = this.expectations.get(property); if (expectations && expectations.length) { return this.handlePropertyWithMatchingExpectations(property, expectations); } return this.handlePropertyWithNoExpectations(property); } getAllProperties() { return Array.from(this.expectations.keys()); } getCallStats() { return { expected: this.expectedCallStats, unexpected: this.unexpectedCallStats }; } getUnmet() { return [].concat(...Array.from(this.expectations.values()).map(expectations => expectations.filter(e => e.expectation.min > e.matchCount).map(e => e.expectation))); } recordExpected(property, args) { const calls = this.expectedCallStats.get(property) || []; this.expectedCallStats.set(property, [...calls, { arguments: args }]); } recordUnexpected(property, args) { const calls = this.unexpectedCallStats.get(property) || []; this.unexpectedCallStats.set(property, [...calls, { arguments: args }]); } countAndConsume(expectation) { // eslint-disable-next-line no-param-reassign expectation.matchCount++; this.consumeExpectation(expectation); } consumeExpectation(expectation) { const { property, max } = expectation.expectation; const expectations = this.expectations.get(property); if (expectation.matchCount === max) { this.expectations.set(property, expectations.filter(e => e !== expectation)); } } getValueForUnexpectedCall(property, args) { this.recordUnexpected(property, args); throw new UnexpectedCall(property, args, this.getUnmet()); } getValueForUnexpectedAccess(property) { if (this.unexpectedProperty === exports.UnexpectedProperty.THROW) { this.recordUnexpected(property, undefined); throw new UnexpectedAccess(property, this.getUnmet()); } return (...args) => { this.recordUnexpected(property, args); throw new UnexpectedCall(property, args, this.getUnmet()); }; } } /** * Matches a call with more parameters than expected because it is assumed the * compiler will check that those parameters are optional. * * @example * new StrongExpectation( * 'bar', * deepEquals([1, 2, 3]), * 23 * ).matches('bar', [1, 2, 3]) === true; */ class StrongExpectation { constructor(property, args, returnValue, exactParams = false) { this.property = void 0; this.args = void 0; this.returnValue = void 0; this.exactParams = void 0; this.matched = 0; this.min = 1; this.max = 1; this.property = property; this.args = args; this.returnValue = returnValue; this.exactParams = exactParams; } setInvocationCount(min, max = 1) { this.min = min; this.max = max; } matches(args) { if (!this.matchesArgs(args)) { return false; } this.matched++; return this.max === 0 || this.matched <= this.max; } isUnmet() { return this.matched < this.min; } matchesArgs(received) { if (this.args === undefined) { return !received; } if (!received) { return false; } if (this.exactParams) { if (this.args.length !== received.length) { return false; } } return this.args.every((arg, i) => arg.matches(received[i])); } toString() { return printExpectation(this.property, this.args, this.returnValue, this.min, this.max); } } class UnfinishedExpectation extends Error { constructor(property, args) { super(`There is an unfinished pending expectation: ${printWhen(property, args)} Please finish it by setting a return value even if the value is undefined.`); } } class MissingWhen extends Error { constructor() { super(`You tried setting a return value without an expectation. Every call to set a return value must be preceded by an expectation.`); } } class NotAMock extends Error { constructor() { super(`We couldn't find the mock. Make sure you're passing in an actual mock.`); } } class NestedWhen extends Error { constructor(parentProp, childProp) { const snippet = ` const parentMock = mock<T1>(); const childMock = mock<T2>(); when(() => childMock${printProperty(childProp)}).thenReturn(...); when(() => parentMock${printProperty(parentProp)}).thenReturn(childMock) `; super(`Setting an expectation on a nested property is not supported. You can return an object directly when the first property is accessed, or you can even return a separate mock: ${snippet}`); } } class ExpectationBuilderWithFactory { constructor(createExpectation, concreteMatcher, exactParams) { this.createExpectation = void 0; this.concreteMatcher = void 0; this.exactParams = void 0; this.args = void 0; this.property = void 0; this.createExpectation = createExpectation; this.concreteMatcher = concreteMatcher; this.exactParams = exactParams; } setProperty(value) { if (this.property) { throw new UnfinishedExpectation(this.property, this.args); } this.property = value; } setArgs(value) { this.args = value; } finish(returnValue) { if (!this.property) { throw new MissingWhen(); } const expectation = this.createExpectation(this.property, this.args, returnValue, this.concreteMatcher, this.exactParams); this.property = undefined; this.args = undefined; return expectation; } } const removeUndefined = object => { if (Array.isArray(object)) { return object.map(x => removeUndefined(x)); } if (!lodash.isObjectLike(object)) { return object; } return lodash.omitBy(object, lodash.isUndefined); }; /** * Compare values using deep equality. * * @param expected * @param strict By default, this matcher will treat a missing key in an object * and a key with the value `undefined` as not equal. It will also consider * non `Object` instances with different constructors as not equal. Setting * this to `false` will consider the objects in both cases as equal. * * @see {@link It.containsObject} or {@link It.isArray} if you want to nest matchers. * @see {@link It.is} if you want to use strict equality. */ const deepEquals = (expected, { strict = true } = {}) => matches(actual => { if (strict) { return lodash.isEqual(actual, expected); } return lodash.isEqual(removeUndefined(actual), removeUndefined(expected)); }, { toString: () => printValue(expected), getDiff: actual => ({ actual, expected }) }); const defaults = { concreteMatcher: deepEquals, unexpectedProperty: exports.UnexpectedProperty.CALL_THROW, exactParams: false }; let currentDefaults = defaults; /** * Override strong-mock's defaults. * * @param newDefaults These will be applied to the library defaults. Multiple * calls don't stack e.g. calling this with `{}` will clear any previously * applied defaults. */ const setDefaults = newDefaults => { currentDefaults = { ...defaults, ...newDefaults }; }; /** * Since `when` doesn't receive the mock subject (because we can't make it * consistently return it from `mock()`, `mock.foo` and `mock.bar()`) we need * to store a global state for the currently active mock. * * We also want to throw in the following case: * * ``` * when(() => mock()) // forgot returns here * when(() => mock()) // should throw * ``` * * For that reason we can't just store the currently active mock, but also * whether we finished the expectation or not. */ let activeMock; const setActiveMock = mock => { activeMock = mock; }; const getActiveMock = () => activeMock; /** * Store a global map of all mocks created and their state. * * This is needed because we can't reliably pass the state between `when` * and `thenReturn`. */ const mockMap = new Map(); const getMockState = mock => { if (mockMap.has(mock)) { return mockMap.get(mock); } throw new NotAMock(); }; const setMockState = (mock, state) => { mockMap.set(mock, state); }; const getAllMocks = () => Array.from(mockMap.entries()); var Mode; (function (Mode) { Mode[Mode["EXPECT"] = 0] = "EXPECT"; Mode[Mode["CALL"] = 1] = "CALL"; })(Mode || (Mode = {})); let currentMode = Mode.CALL; const setMode = mode => { currentMode = mode; }; const getMode = () => currentMode; const createProxy = traps => // The Proxy target MUST be a function, otherwise we can't use the `apply` trap: // https://262.ecma-international.org/6.0/#sec-proxy-object-internal-methods-and-internal-slots-call-thisargument-argumentslist // eslint-disable-next-line no-empty-function,@typescript-eslint/no-empty-function new Proxy( /* istanbul ignore next */() => {}, { get: (target, prop) => { if (prop === 'bind') { return (thisArg, ...args) => (...moreArgs) => traps.apply([...args, ...moreArgs]); } if (prop === 'apply') { return (thisArg, args) => traps.apply(args || []); } if (prop === 'call') { return (thisArg, ...args) => traps.apply(args); } return traps.property(prop); }, apply: (target, thisArg, args) => traps.apply(args), ownKeys: () => traps.ownKeys(), getOwnPropertyDescriptor(target, prop) { const keys = traps.ownKeys(); if (keys.includes(prop)) { return { configurable: true, enumerable: true }; } return undefined; } }); const createStub = (repo, builder, getCurrentMode) => { const stub = createProxy({ property: property => { if (getCurrentMode() === Mode.CALL) { return repo.get(property); } setActiveMock(stub); builder.setProperty(property); return createProxy({ property: childProp => { throw new NestedWhen(property, childProp); }, apply: args => { builder.setArgs(args); }, ownKeys: () => { throw new Error('Spreading during an expectation is not supported.'); } }); }, apply: args => { if (getCurrentMode() === Mode.CALL) { return repo.apply(args); } setActiveMock(stub); builder.setProperty(ApplyProp); builder.setArgs(args); return undefined; }, ownKeys: () => { if (getCurrentMode() === Mode.CALL) { return repo.getAllProperties(); } throw new Error('Spreading during an expectation is not supported.'); } }); return stub; }; const strongExpectationFactory = (property, args, returnValue, concreteMatcher, exactParams) => new StrongExpectation(property, // Wrap every non-matcher in the default matcher. args == null ? void 0 : args.map(arg => isMatcher(arg) ? arg : concreteMatcher(arg)), returnValue, exactParams); /** * Create a type safe mock. * * @see {@link when} Set expectations on the mock using `when`. * * @param options Configure the options for this specific mock, overriding any * defaults that were set with {@link setDefaults}. * @param options.unexpectedProperty Controls what happens when an unexpected * property is accessed. * @param options.concreteMatcher The matcher that will be used when one isn't * specified explicitly. * @param options.exactParams Controls whether the number of received arguments * has to match the expectation. * * @example * const fn = mock<() => number>(); * * when(() => fn()).thenReturn(23); * * fn() === 23; */ const mock = ({ unexpectedProperty, concreteMatcher, exactParams } = {}) => { const options = { unexpectedProperty: unexpectedProperty != null ? unexpectedProperty : currentDefaults.unexpectedProperty, concreteMatcher: concreteMatcher != null ? concreteMatcher : currentDefaults.concreteMatcher, exactParams: exactParams != null ? exactParams : currentDefaults.exactParams }; const repository = new FlexibleRepository(options.unexpectedProperty); const builder = new ExpectationBuilderWithFactory(strongExpectationFactory, options.concreteMatcher, options.exactParams); const stub = createStub(repository, builder, getMode); setMockState(stub, { repository, builder, options }); return stub; }; const createInvocationCount = expectation => ({ between(min, max) { expectation.setInvocationCount(min, max); }, /* istanbul ignore next */ times(exact) { expectation.setInvocationCount(exact, exact); }, /* istanbul ignore next */ anyTimes() { expectation.setInvocationCount(0, 0); }, /* istanbul ignore next */ atLeast(min) { expectation.setInvocationCount(min, Infinity); }, /* istanbul ignore next */ atMost(max) { expectation.setInvocationCount(0, max); }, /* istanbul ignore next */ once() { expectation.setInvocationCount(1, 1); }, /* istanbul ignore next */ twice() { expectation.setInvocationCount(2, 2); } /* eslint-enable no-param-reassign, no-multi-assign */ }); const finishExpectation = (returnValue, builder, repo) => { const finishedExpectation = builder.finish(returnValue); repo.add(finishedExpectation); return createInvocationCount(finishedExpectation); }; const getError = errorOrMessage => { if (typeof errorOrMessage === 'string') { return new Error(errorOrMessage); } if (errorOrMessage instanceof Error) { return errorOrMessage; } return new Error(); }; const createReturns = (builder, repository) => ({ thenReturn: returnValue => finishExpectation( // This will handle both thenReturn(23) and thenReturn(Promise.resolve(3)). { value: returnValue, isError: false, isPromise: false }, builder, repository), thenThrow: errorOrMessage => finishExpectation({ value: getError(errorOrMessage), isError: true, isPromise: false }, builder, repository), thenResolve: promiseValue => finishExpectation({ value: promiseValue, isError: false, isPromise: true }, builder, repository), thenReject: errorOrMessage => finishExpectation({ value: getError(errorOrMessage), isError: true, isPromise: true }, builder, repository) }); /** * Set an expectation on a mock. * * The expectation must be finished by setting a return value, even if the value * is `undefined`. * * If a call happens that was not expected then the mock will throw an error. * By default, the call is expected to only be made once. Use the invocation * count helpers to expect a call multiple times. * * @param expectation A callback to set the expectation on your mock. The * callback must return the value from the mock to properly infer types. * * @example * const fn = mock<() => void>(); * when(() => fn()).thenReturn(undefined); * * @example * const fn = mock<() => number>(); * when(() => fn()).thenReturn(42).atMost(3); * * @example * const fn = mock<(x: number) => Promise<number>(); * when(() => fn(23)).thenResolve(42); */ const when = expectation => { setMode(Mode.EXPECT); expectation(); setMode(Mode.CALL); const { builder, repository } = getMockState(getActiveMock()); return createReturns(builder, repository); }; /** * Remove any remaining expectations on the given mock. * * @example * const fn = mock<() => number>(); * * when(() => fn()).thenReturn(23); * * reset(fn); * * fn(); // throws */ const reset = mock => { getMockState(mock).repository.clear(); }; /** * Reset all existing mocks. * * @see reset */ const resetAll = () => { getAllMocks().forEach(([mock]) => { reset(mock); }); }; class UnmetExpectations extends Error { constructor(expectations) { super(jestMatcherUtils.DIM_COLOR(`There are unmet expectations: - ${expectations.map(e => e.toString()).join('\n - ')}`)); } } /** * Merge property accesses and method calls for the same property * into a single call. * * @example * mergeCalls({ getData: [{ arguments: undefined }, { arguments: [1, 2, 3] }] } * // returns { getData: [{ arguments: [1, 2, 3] } } */ const mergeCalls = callMap => new Map(Array.from(callMap.entries()).map(([property, calls]) => { const hasMethodCalls = calls.some(call => call.arguments); const hasPropertyAccesses = calls.some(call => !call.arguments); if (hasMethodCalls && hasPropertyAccesses) { return [property, calls.filter(call => call.arguments)]; } return [property, calls]; })); class UnexpectedCalls extends Error { constructor(unexpectedCalls, expectations) { const printedCalls = Array.from(mergeCalls(unexpectedCalls).entries()).map(([property, calls]) => calls.map(call => printCall(property, call.arguments)).join('\n - ')).join('\n - '); super(jestMatcherUtils.DIM_COLOR(`The following calls were unexpected: - ${printedCalls} ${printRemainingExpectations(expectations)}`)); } } const verifyRepo = repository => { const unmetExpectations = repository.getUnmet(); if (unmetExpectations.length) { throw new UnmetExpectations(unmetExpectations); } const callStats = repository.getCallStats(); if (callStats.unexpected.size) { throw new UnexpectedCalls(callStats.unexpected, unmetExpectations); } }; /** * Verify that all expectations on the given mock have been met. * * @throws Will throw if there are remaining expectations that were set * using `when` and that weren't met. * * @throws Will throw if any unexpected calls happened. Normally those * calls throw on their own, but the error might be caught by the code * being tested. * * @example * const fn = mock<() => number>(); * * when(() => fn()).thenReturn(23); * * verify(fn); // throws */ const verify = mock => { const { repository } = getMockState(mock); verifyRepo(repository); }; /** * Verify all existing mocks. * * @see verify */ const verifyAll = () => { getAllMocks().forEach(([mock]) => { verify(mock); }); }; /** * Compare values using `Object.is`. * * @link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is * * @see It.deepEquals A matcher that uses deep equality. */ const is = expected => matches(actual => Object.is(actual, expected), { toString: () => `${printValue(expected)}`, getDiff: actual => ({ actual, expected }) }); /** * Match any value, including `undefined` and `null`. * * @example * const fn = mock<(x: number, y: string) => number>(); * when(() => fn(It.isAny(), It.isAny())).thenReturn(1); * * fn(23, 'foobar') === 1 */ const isAny = () => matches(() => true, { toString: () => 'Matcher<any>' }); /** * Match an array. * * Supports nested matchers. * * @param containing If given, the matched array has to contain ALL of these * elements in ANY order. * * @example * const fn = mock<(arr: number[]) => number>(); * when(() => fn(It.isArray())).thenReturn(1); * when(() => fn(It.isArray([2, 3]))).thenReturn(2); * * fn({ length: 1, 0: 42 }) // throws * fn([]) === 1 * fn([3, 2, 1]) === 2 * * @example * It.isArray([It.isString({ containing: 'foobar' })]) */ const isArray = containing => matches(actual => { if (!Array.isArray(actual)) { return false; } if (!containing) { return true; } return containing.every(x => actual.find(y => { if (isMatcher(x)) { return x.matches(y); } return deepEquals(x).matches(y); }) !== undefined); }, { toString: () => containing ? `array([${containing.map(v => printValue(v)).join(', ')}])` : 'array', getDiff: actual => { if (containing) { return { actual, expected: `Matcher<array>([${containing.map(value => { if (isMatcher(value)) { return value.toString(); } return value; }).join(', ')}])` }; } return { actual: `${printValue(actual)} (${typeof actual})`, expected: 'Matcher<array>' }; } }); /** * Match any number. * * @example * const fn = mock<(x: number) => number>(); * when(() => fn(It.isNumber())).returns(42); * * fn(20.5) === 42 * fn(NaN) // throws */ const isNumber = () => matches(actual => typeof actual === 'number' && !Number.isNaN(actual), { toString: () => 'Matcher<number>', getDiff: actual => ({ actual: `${printValue(actual)} (${typeof actual})`, expected: 'Matcher<number>' }) }); /** * Matches any plain object e.g. object literals or objects created with `Object.create()`. * * Classes, arrays, maps, sets etc. are not considered plain objects. * You can use {@link containsObject} or {@link matches} to match those. * * @example * const fn = mock<({ foo: string }) => number>(); * when(() => fn(It.isPlainObject())).thenReturn(42); * * fn({ foo: 'bar' }) // returns 42 */ const isPlainObject = () => matches(actual => lodash.isPlainObject(actual), { toString: () => 'Matcher<object>', getDiff: actual => { const type = lodash.isObjectLike(actual) ? 'object-like' : typeof actual; return { actual: `${printValue(actual)} (${type})`, expected: 'Matcher<object>' }; } }); const looksLikeObject = value => lodash.isPlainObject(value); const getExpectedObjectDiff = (actual, expected) => Object.fromEntries(getKeys(expected).map(key => { const expectedValue = getKey(expected, key); const actualValue = getKey(actual, key); if (isMatcher(expectedValue)) { return [key, expectedValue.getDiff(actualValue).expected]; } if (looksLikeObject(expectedValue)) { return [key, getExpectedObjectDiff(actualValue, expectedValue)]; } return [key, expectedValue]; })); const getActualObjectDiff = (actual, expected) => { const actualKeys = getKeys(actual); const expectedKeys = new Set(getKeys(expected)); const commonKeys = actualKeys.filter(key => expectedKeys.has(key)); if (!commonKeys.length) { // When we don't have any common keys we return the whole object // so the user can inspect what's in there. return actual; } return Object.fromEntries(commonKeys.map(key => { const expectedValue = getKey(expected, key); const actualValue = getKey(actual, key); if (isMatcher(expectedValue)) { return [key, expectedValue.getDiff(actualValue).actual]; } if (looksLikeObject(expectedValue)) { return [key, getActualObjectDiff(actualValue, expectedValue)]; } return [key, actualValue]; })); }; const getKeys = value => { if (typeof value === 'object' && value !== null) { return Reflect.ownKeys(value); } return []; }; const getKey = (value, key) => // @ts-expect-error because we're fine with a runtime undefined value value == null ? void 0 : value[key]; const isMatch = (actual, expected) => { const actualKeys = getKeys(actual); const expectedKeys = getKeys(expected); if (!isArray(expectedKeys).matches(actualKeys)) { return false; } return expectedKeys.every(key => { const expectedValue = getKey(expected, key); const actualValue = getKey(actual, key); if (isMatcher(expectedValue)) { return expectedValue.matches(actualValue); } if (looksLikeObject(expectedValue)) { return isMatch(actualValue, expectedValue); } return deepEquals(expectedValue).matches(actualValue); }); }; const deepPrintObject = value => lodash.cloneDeepWith(value, value => { if (isMatcher(value)) { return value.toString(); } return undefined; }); /** * Check if an object recursively contains the expected properties, * i.e. the expected object is a subset of the received object. * * @param partial A subset of the expected object that will be recursively matched. * Supports nested matchers. * Concrete values will be compared with {@link deepEquals}. * * @see {@link isPlainObject} if you want to match any plain object. * * @example * const fn = mock<(pos: { x: number, y: number }) => number>(); * when(() => fn(It.containsObject({ x: 23 }))).returns(42); * * fn({ x: 23, y: 200 }) // returns 42 * * @example * It.containsObject({ foo: It.isString() }) */ // T is not constrained to ObjectType because of // https://github.com/microsoft/TypeScript/issues/57810, // but K is to avoid inferring non-object partials const containsObject = partial => matches(actual => isMatch(actual, partial), { toString: () => `Matcher<object>(${printValue(deepPrintObject(partial))})`, getDiff: actual => ({ actual: getActualObjectDiff(actual, partial), expected: getExpectedObjectDiff(actual, partial) }) }); /** * Match any string. * * @param matching An optional string or RegExp to match the string against. * If it's a string, a case-sensitive search will be performed. * * @example * const fn = mock<(x: string, y: string) => number>(); * when(() => fn(It.isString(), It.isString('bar'))).returns(42); * * fn('foo', 'baz') // throws * fn('foo', 'bar') === 42 */ const isString = matching => matches(actual => { if (typeof actual !== 'string') { return false; } if (!matching) { return true; } if (typeof matching === 'string') { return actual.indexOf(matching) !== -1; } return matching.test(actual); }, { toString: () => { if (matching) { return `Matcher<string>(${matching})`; } return 'Matcher<string>'; }, getDiff: actual => { if (matching) { return { expected: `Matcher<string>(${matching})`, actual }; } return { expected: 'Matcher<string>', actual: `${actual} (${typeof actual})` }; } }); /** * Matches anything and stores the received value. * * This should not be needed for most cases, but can be useful if you need * access to a complex argument outside the expectation e.g. to test a * callback. * * @param name If given, this name will be printed in error messages. * * @example * const fn = mock<(cb: (value: number) => number) => void>(); * const matcher = It.willCapture(); * when(() => fn(matcher)).thenReturn(); * * fn(x => x + 1); * matcher.value?.(3) === 4 */ const willCapture = name => { let capturedValue; const matcher = { [MATCHER_SYMBOL]: true, matches: actual => { capturedValue = actual; return true; }, toString: () => name ? `Capture(${name})` : 'Capture', getDiff: actual => ({ actual, expected: actual }), get value() { return capturedValue; } }; return matcher; }; /* istanbul ignore file */ var it = { __proto__: null, deepEquals: deepEquals, is: is, isAny: isAny, isArray: isArray, isNumber: isNumber, isPlainObject: isPlainObject, containsObject: containsObject, isString: isString, matches: matches, willCapture: willCapture }; exports.It = it; exports.mock = mock; exports.reset = reset; exports.resetAll = resetAll; exports.setDefaults = setDefaults; exports.verify = verify; exports.verifyAll = verifyAll; exports.when = when; //# sourceMappingURL=index.js.map