strong-mock
Version:
Type safe mocking library for TypeScript
1,338 lines (1,287 loc) • 39.3 kB
JavaScript
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