Skip to content

Commit

Permalink
feat!: Allow concrete matcher to be configured for each mock
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The default matcher option previously available only in
`setDefaults` has been renamed to `concreteMatcher`.
  • Loading branch information
NiGhTTraX committed Aug 2, 2022
1 parent c34bacc commit 32c82ba
Show file tree
Hide file tree
Showing 15 changed files with 234 additions and 142 deletions.
3 changes: 2 additions & 1 deletion src/expectation/repository/flexible-repository.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { UnexpectedAccess, UnexpectedCall } from '../../errors';
import { Strictness } from '../../mock/options';
import { ApplyProp } from '../expectation';
import {
MatchingCallExpectation,
MatchingPropertyExpectation,
NotMatchingExpectation,
} from '../expectation.mocks';
import { CallStats } from './expectation-repository';
import { FlexibleRepository, Strictness } from './flexible-repository';
import { FlexibleRepository } from './flexible-repository';

describe('FlexibleRepository', () => {
describe('property expectations', () => {
Expand Down
43 changes: 1 addition & 42 deletions src/expectation/repository/flexible-repository.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,11 @@
import { UnexpectedAccess, UnexpectedCall } from '../../errors';
import { Strictness } from '../../mock/options';
import { returnOrThrow } from '../../mock/stub';
import { Property } from '../../proxy';
import { ApplyProp, Expectation, ReturnValue } from '../expectation';
import { MATCHER_SYMBOL } from '../matcher';
import { CallMap, ExpectationRepository } from './expectation-repository';

/**
* Controls what happens when a property is accessed, or a call is made,
* and there are no expectations set for it.
*/
export enum Strictness {
/**
* Any property that's accessed, or any call that's made, without a matching
* expectation, will throw immediately.
*
* @example
* type Service = { foo: (x: number) => number };
* const service = mock<Service>();
*
* // This will throw.
* const { foo } = service;
*
* // Will throw "Didn't expect foo to be accessed",
* // without printing the arguments.
* foo(42);
*/
SUPER_STRICT,
/**
* Properties with unmatched expectations will return functions 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.
*
* @example
* type Service = { foo: (x: number) => number };
* const service = mock<Service>();
*
* // This will not throw.
* const { foo } = service;
*
* // Will throw "Didn't expect foo(42) to be called".
* foo(42);
*/
STRICT,
}

type CountableExpectation = {
expectation: Expectation;
matchCount: number;
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { reset, resetAll } from './verify/reset';
export { verify, verifyAll } from './verify/verify';
export { It } from './expectation/it';
export { setDefaults } from './mock/defaults';
export { Strictness } from './expectation/repository/flexible-repository';

export type { Matcher } from './expectation/matcher';
export type { StrongMockDefaults } from './mock/defaults';
export type { MockOptions } from './mock/options';
export { Strictness } from './mock/options';
24 changes: 19 additions & 5 deletions src/mock/defaults.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { UnexpectedAccess, UnexpectedCall } from '../errors';
import { Strictness } from '../expectation/repository/flexible-repository';
import { It, when } from '../index';
import { setDefaults } from './defaults';
import { mock } from './mock';
import { Strictness } from './options';

describe('defaults', () => {
beforeEach(() => {
Expand All @@ -11,7 +11,7 @@ describe('defaults', () => {

it('should override the matcher for non matcher values', () => {
setDefaults({
matcher: () => It.matches(() => true),
concreteMatcher: () => It.matches(() => true),
});

const fn = mock<(x: number) => boolean>();
Expand All @@ -23,7 +23,7 @@ describe('defaults', () => {

it('should not override the matcher for matcher values', () => {
setDefaults({
matcher: () => It.matches(() => true),
concreteMatcher: () => It.matches(() => true),
});

const fn = mock<(x: number) => boolean>();
Expand All @@ -33,6 +33,20 @@ describe('defaults', () => {
expect(() => fn(-1)).toThrow();
});

it('should not override the matcher if set on the mock', () => {
setDefaults({
concreteMatcher: () => It.matches(() => false),
});

const fn = mock<(x: number) => boolean>({
concreteMatcher: () => It.matches(() => true),
});

when(() => fn(42)).thenReturn(true);

expect(fn(-1)).toBeTruthy();
});

it('should override the strictness', () => {
setDefaults({ strictness: Strictness.SUPER_STRICT });

Expand All @@ -41,7 +55,7 @@ describe('defaults', () => {
expect(() => foo.bar()).toThrow(UnexpectedAccess);
});

it('should override the strictness', () => {
it('should not override the strictness if set on the mock', () => {
setDefaults({ strictness: Strictness.SUPER_STRICT });

const foo = mock<{ bar: () => number }>({ strictness: Strictness.STRICT });
Expand All @@ -51,7 +65,7 @@ describe('defaults', () => {

it('should not stack', () => {
setDefaults({
matcher: () => It.matches(() => true),
concreteMatcher: () => It.matches(() => true),
});

setDefaults({});
Expand Down
31 changes: 4 additions & 27 deletions src/mock/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,10 @@
import { It } from '../expectation/it';
import { Matcher } from '../expectation/matcher';
import { Strictness } from '../expectation/repository/flexible-repository';
import { MockOptions, Strictness } from './options';

export type StrongMockDefaults = {
/**
* The matcher that will be used when one isn't specified explicitly.
*
* @param expected The non matcher expected value.
*
* @example
* StrongMock.setDefaults({
* matcher: () => It.matches(() => true)
* });
*
* when(() => fn('value')).thenReturn(true);
*
* fn('not-value') === true;
*/
matcher: <T>(expected: T) => Matcher;

/**
* Controls what happens when a property is accessed, or a call is made,
* and there are no expectations set for it.
*/
strictness: Strictness;
};
export type StrongMockDefaults = Required<MockOptions>;

const defaults: StrongMockDefaults = {
matcher: It.deepEquals,
concreteMatcher: It.deepEquals,
strictness: Strictness.STRICT,
};

Expand All @@ -40,7 +17,7 @@ export let currentDefaults: StrongMockDefaults = defaults;
* calls don't stack e.g. calling this with `{}` will clear any previously
* applied defaults.
*/
export const setDefaults = (newDefaults: Partial<StrongMockDefaults>): void => {
export const setDefaults = (newDefaults: MockOptions): void => {
currentDefaults = {
...defaults,
...newDefaults,
Expand Down
2 changes: 2 additions & 0 deletions src/mock/map.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NotAMock } from '../errors';
import { ExpectationRepository } from '../expectation/repository/expectation-repository';
import { PendingExpectation } from '../when/pending-expectation';
import { StrongMockDefaults } from './defaults';
import { Mock } from './mock';

/**
Expand Down Expand Up @@ -33,6 +34,7 @@ export const getActiveMock = (): Mock<any> => activeMock;
type MockState = {
repository: ExpectationRepository;
pendingExpectation: PendingExpectation;
options: StrongMockDefaults;
};

/**
Expand Down
25 changes: 25 additions & 0 deletions src/mock/mock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { UnexpectedAccess } from '../errors';
import { It } from '../expectation/it';
import { when } from '../when/when';
import { mock } from './mock';
import { Strictness } from './options';

describe('mock', () => {
it('should override concrete matcher', () => {
const fn = mock<(value: string) => boolean>({
concreteMatcher: () => It.matches(() => true),
});

when(() => fn('value')).thenReturn(true);

expect(fn('not-value')).toBeTruthy();
});

it('should override strictness', () => {
const foo = mock<{ bar: () => number }>({
strictness: Strictness.SUPER_STRICT,
});

expect(() => foo.bar()).toThrow(UnexpectedAccess);
});
});
42 changes: 25 additions & 17 deletions src/mock/mock.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
import { isMatcher } from '../expectation/matcher';
import {
FlexibleRepository,
Strictness,
} from '../expectation/repository/flexible-repository';
import { FlexibleRepository } from '../expectation/repository/flexible-repository';
import { StrongExpectation } from '../expectation/strong-expectation';
import {
ExpectationFactory,
RepoSideEffectPendingExpectation,
} from '../when/pending-expectation';
import { currentDefaults } from './defaults';
import { currentDefaults, StrongMockDefaults } from './defaults';
import { setMockState } from './map';
import { MockOptions } from './options';
import { createStub } from './stub';

export type Mock<T> = T;

const strongExpectationFactory: ExpectationFactory = (
property,
args,
returnValue
returnValue,
concreteMatcher
) =>
new StrongExpectation(
property,
// Wrap every non-matcher in the default matcher.
args?.map((arg) => (isMatcher(arg) ? arg : currentDefaults.matcher(arg))),
args?.map((arg) => (isMatcher(arg) ? arg : concreteMatcher(arg))),
returnValue
);

Expand All @@ -32,17 +31,16 @@ export const setRecording = (recording: boolean) => {
isRecording = recording;
};

export interface MockOptions {
strictness?: Strictness;
}

/**
* Create a type safe mock.
*
* @see {@link when} Set expectations on the mock using `when`.
*
* @param strictness Controls what happens when a property is accessed,
* @param options Configure the options for this specific mock, overriding any
* defaults that were set with {@link setDefaults}.
* @param options.strictness Controls what happens when a property is accessed,
* or a call is made, and there are no expectations set for it.
* @param options.concreteMatcher The matcher that will be used when one isn't specified explicitly.
*
* @example
* const fn = mock<() => number>();
Expand All @@ -51,18 +49,28 @@ export interface MockOptions {
*
* fn() === 23;
*/
export const mock = <T>({ strictness }: MockOptions = {}): Mock<T> => {
export const mock = <T>({
strictness,
concreteMatcher,
}: MockOptions = {}): Mock<T> => {
const pendingExpectation = new RepoSideEffectPendingExpectation(
strongExpectationFactory
);

const repository = new FlexibleRepository(
strictness ?? currentDefaults.strictness
);
const options: StrongMockDefaults = {
strictness: strictness ?? currentDefaults.strictness,
concreteMatcher: concreteMatcher ?? currentDefaults.concreteMatcher,
};

const repository = new FlexibleRepository(options.strictness);

const stub = createStub<T>(repository, pendingExpectation, () => isRecording);

setMockState(stub, { repository, pendingExpectation });
setMockState(stub, {
repository,
pendingExpectation,
options,
});

return stub;
};

0 comments on commit 32c82ba

Please sign in to comment.